Compare commits
429 Commits
4a680b863e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 46edbe50c1 | |||
| cd7e1bc114 | |||
| 600d0caad6 | |||
| 733582900a | |||
| 8b14dfdc14 | |||
| dced44548d | |||
| 4c0af92b3c | |||
| 5cc1e6bea1 | |||
| 102149a5d6 | |||
| 0c849f4dcd | |||
| 94490f114d | |||
| c360bbe14e | |||
| 6cb29ee70e | |||
| a6e2f78c27 | |||
| a201c20bf2 | |||
| 56676fe83c | |||
| 77bead5ba4 | |||
| ee456de713 | |||
| e16f0140f7 | |||
| 637c21387a | |||
| da03de18cb | |||
| 1b0184eed8 | |||
| 4fb8387f79 | |||
| cb884abdc8 | |||
| 0a6b6fd18d | |||
| 8f359ef4c9 | |||
| 327d5a495e | |||
| 69925c6cde | |||
| a5410642fa | |||
| c3851f8e25 | |||
| 29f8797fbf | |||
| 9886d80b1c | |||
| 5bc709221e | |||
| 14f8a5f405 | |||
| 16d2a06d95 | |||
| 45f799c57d | |||
| e814122c73 | |||
| d45f2fc6cb | |||
| c2319739e2 | |||
| 1e68f62499 | |||
| 5504918a61 | |||
| 17a129038b | |||
| 93c17fe653 | |||
| ae622dfa88 | |||
| 6637332b3e | |||
| 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 | |||
| d7de1f47d4 | |||
| 646031e5c0 | |||
| 195b07deef | |||
| 2a4eb51bd7 | |||
| 7704ffddab | |||
| 588d032335 | |||
| e84386b03e | |||
| a16287e19c | |||
| f88c2a95c1 | |||
| ed55612cfa | |||
| 47ade040e6 | |||
| 852e723cc8 | |||
| f90e5f4519 | |||
| c3d1cc87e2 | |||
| 88c2659a9c | |||
| 6f7498a956 | |||
| a1cea1a1ea | |||
| 60765469aa | |||
| 68ad291a8f | |||
| e08d812598 | |||
| 45fb61000a | |||
| 403961da51 | |||
| 64efe18500 | |||
| 98360ad31f | |||
| 09a1c98514 | |||
| aff0105700 | |||
| 2db839aef9 | |||
| 9967155332 | |||
| 3c0a24feb7 | |||
| 38f8c96b66 | |||
| c3c7c2ca91 | |||
| feba73a11a | |||
| 16f4683859 | |||
| 41f46569bf | |||
| ac475ba509 | |||
| ef1e7eb8e6 |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mc2-memory": {
|
||||||
|
"command": "python",
|
||||||
|
"args": [
|
||||||
|
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mc2-system": {
|
||||||
|
"command": "python",
|
||||||
|
"args": [
|
||||||
|
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mc2-web": {
|
||||||
|
"command": "python",
|
||||||
|
"args": [
|
||||||
|
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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*
|
||||||
+37
-19
@@ -1,19 +1,37 @@
|
|||||||
{
|
{
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "mc2",
|
"name": "mc2",
|
||||||
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"-m",
|
"-m",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
"app:app",
|
"app:app",
|
||||||
"--app-dir",
|
"--app-dir",
|
||||||
"F:\\Coding Stuff\\mission-control-2\\backend",
|
"F:\\Coding Stuff\\mission-control-2\\backend",
|
||||||
"--port",
|
"--port",
|
||||||
"9000"
|
"9000"
|
||||||
],
|
],
|
||||||
"port": 9000
|
"port": 9000
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||||
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
|
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
||||||
|
"autoPort": false,
|
||||||
|
"port": 5180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frontend-mock",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
||||||
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
|
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
||||||
|
"autoPort": false,
|
||||||
|
"port": 5181
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"autoMode": {
|
||||||
|
"allow": [
|
||||||
|
"$defaults",
|
||||||
|
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git rev-list *)",
|
||||||
|
"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)"
|
||||||
|
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
|
||||||
|
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
*.service text eol=lf
|
*.service text eol=lf
|
||||||
*.timer 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.
|
# Windows-Batch-Wrapper bleiben CRLF.
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
if [ $rot -eq 0 ]; then
|
||||||
|
echo "== Frontend: Push nach release-dist =="
|
||||||
|
git config --global user.name "Gitea Actions"
|
||||||
|
git config --global user.email "actions@gitea.local"
|
||||||
|
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||||
|
# Erzeuge (oder hole) den orphan branch 'release-dist'
|
||||||
|
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
|
||||||
|
git rm -rf . >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Checkout nur frontend/dist vom aktuellen Stand
|
||||||
|
git add -f frontend/dist
|
||||||
|
|
||||||
|
if ! git diff-index --quiet HEAD; then
|
||||||
|
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
|
||||||
|
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
|
||||||
|
git push origin HEAD:release-dist -f
|
||||||
|
else
|
||||||
|
echo "Keine Änderungen am Frontend-Build."
|
||||||
|
fi
|
||||||
|
# Zurück zum originalen Branch für den Rest des Skripts
|
||||||
|
git checkout $CURRENT_COMMIT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
+26
-17
@@ -1,17 +1,26 @@
|
|||||||
# Python
|
# Python
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Node / Vite
|
# Node / Vite
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||||
|
|
||||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
||||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
||||||
frontend/public/avatar.vrm
|
frontend/public/avatar.vrm
|
||||||
frontend/dist/avatar.vrm
|
frontend/dist/avatar.vrm
|
||||||
|
|
||||||
# Env / local
|
# Env / local
|
||||||
*.env
|
*.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||||
|
box_recon*
|
||||||
|
gemma_swap*
|
||||||
|
|
||||||
|
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
frontend/dist/
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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 (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||||
|
|
||||||
|
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
|
||||||
|
- **System:** Bosgame M5 (AMD Strix Halo APU)
|
||||||
|
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
|
||||||
|
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
|
||||||
|
- **Inference-Backend:** Primär Vulkan/RADV, experimentell ROCm.
|
||||||
|
|
||||||
|
**WICHTIG:** Alle alten Dienste wie `mem0`, `governor` oder `eigenleben` sind komplett gelöscht. Hermes ist das EINZIGE "Gehirn" und läuft autonom. Es nutzt keine alte Mem0-DB, sondern sein eigenes System.
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Agentic IDE & Vibe Coding
|
||||||
|
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
|
||||||
|
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||||
|
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
|
||||||
|
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
|
||||||
|
|
||||||
|
## Zeit & Umgebung
|
||||||
|
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||||
|
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||||
|
(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,144 @@
|
|||||||
# Mission Control 2.0
|
# Mission Control 2.0
|
||||||
|
|
||||||
Komponierbarer Local-AI-Stack für den Bosgame M5. Greenfield-Neuaufbau —
|
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
||||||
siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
|
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**
|
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
||||||
|
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
||||||
|
|
||||||
- **Phase 0** — FastAPI-Skeleton + React/shadcn-Shell (Cmd+K, Dark, PWA).
|
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
|
||||||
- **Phase 1** — Compute-Module (fit/caps/sources, portiert), **Discover** (live HF + Fit + Caps),
|
Bau-Sitzung und wird nie unterbrochen.
|
||||||
**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).
|
|
||||||
|
|
||||||
- **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei),
|
## Ports & Dienste
|
||||||
**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.
|
|
||||||
|
|
||||||
- **Phase 4** — Hermes-**Agent-Status** (`/api/agent/status`, AgentView mit Tiles + „Hermes öffnen"),
|
| Port | Dienst | Erreichbar |
|
||||||
`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.
|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
||||||
|
|
||||||
- **Phase 5** — **Backup** (Memory + Configs), **Services-Health** + Observability-Links,
|
| `8080` | **llama-swap** — Modell-Router | LAN |
|
||||||
**Theme-Toggle** (Hell/Dunkel). Box-Deploy/-Wiring + Cutover (Phase 6) brauchen die Box.
|
| `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 |
|
||||||
|
|
||||||
API: `health · models · discover · fit · models/register · groups · routing · system/* · connect ·
|
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
|
||||||
memory/* · agent/status` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`).
|
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||||
Box-Runbooks: `docs/HERMES_SETUP.md` + `deploy/` (Units, deploy.sh, backup.sh).
|
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
||||||
|
|
||||||
|
## Der Weg einer Idee
|
||||||
|
|
||||||
|
```
|
||||||
|
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
|
||||||
|
└── autonomes Vibe Coding via OpenCode Desktop + MCP
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
||||||
|
Wächtern geboren**:
|
||||||
|
|
||||||
|
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
||||||
|
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
|
||||||
|
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
|
||||||
|
Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||||
|
|
||||||
|
## Modelle & Rollen
|
||||||
|
|
||||||
|
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
|
||||||
|
|
||||||
|
| 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. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
|
||||||
|
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
|
||||||
|
|
||||||
|
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
||||||
|
Kontext-Komprimierung löscht still die Regeln mit.
|
||||||
|
|
||||||
|
## 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/* · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
||||||
|
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
||||||
|
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||||
|
|
||||||
## Entwickeln
|
## Entwickeln
|
||||||
|
|
||||||
**Backend:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# Backend
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
|
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
|
||||||
.venv/Scripts/python -m uvicorn app:app --port 9000
|
.venv/Scripts/python -m uvicorn app:app --port 9000
|
||||||
|
|
||||||
|
# Frontend (Dev, proxyt /api → :9000)
|
||||||
|
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||||
|
|
||||||
|
# Frontend (Build → wird vom Backend ausgeliefert)
|
||||||
|
cd frontend && npm run build # → frontend/dist
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend (Dev, proxyt /api → :9000):**
|
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
|
||||||
```bash
|
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend (Build → wird vom Backend ausgeliefert):**
|
|
||||||
```bash
|
|
||||||
cd frontend && npm run build # → frontend/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
## Env-Vars (Auswahl)
|
## Env-Vars (Auswahl)
|
||||||
|
|
||||||
| Variable | Default | Zweck |
|
| Variable | Default | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
|
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
|
||||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
||||||
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
|
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
||||||
| `MC_PORT` | `9000` | MC-Backend-Port |
|
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
||||||
| `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_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
||||||
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
|
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
||||||
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
|
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
| [`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).
|
||||||
+97
-22
@@ -9,18 +9,40 @@ Server (proxyt /api hierher), daher CORS für localhost offen.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from routers import (
|
||||||
|
agent,
|
||||||
|
auftragsbuch,
|
||||||
|
chronik,
|
||||||
|
connect,
|
||||||
|
console,
|
||||||
|
events,
|
||||||
|
gateway_proxy,
|
||||||
|
health,
|
||||||
|
hermes_ui,
|
||||||
|
ideen,
|
||||||
|
maintenance,
|
||||||
|
models,
|
||||||
|
routing,
|
||||||
|
skills,
|
||||||
|
system,
|
||||||
|
voice,
|
||||||
|
wissen,
|
||||||
|
zeitmaschine,
|
||||||
|
)
|
||||||
|
from routers import reminders as reminders_router
|
||||||
|
from services import metrics_history, reminders, sentry, warmer
|
||||||
from starlette.requests import Request
|
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
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||||
# für alle Module (logging.getLogger(__name__)).
|
# für alle Module (logging.getLogger(__name__)).
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -29,17 +51,35 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn."""
|
"""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
|
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
||||||
if task:
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
log.info("Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL)
|
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()))
|
||||||
|
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||||
|
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||||
|
# 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:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
if task:
|
for task in tasks:
|
||||||
task.cancel()
|
_ = task.cancel()
|
||||||
|
await app.state.gw_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
||||||
@@ -54,7 +94,9 @@ app.add_middleware(
|
|||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@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)
|
resp = await call_next(request)
|
||||||
if request.url.path.startswith("/api"):
|
if request.url.path.startswith("/api"):
|
||||||
resp.headers["Cache-Control"] = "no-cache"
|
resp.headers["Cache-Control"] = "no-cache"
|
||||||
@@ -65,28 +107,61 @@ app.include_router(health.router)
|
|||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
app.include_router(routing.router)
|
app.include_router(routing.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
|
|
||||||
app.include_router(connect.router)
|
app.include_router(connect.router)
|
||||||
app.include_router(memory.router)
|
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
app.include_router(
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
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(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(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(skills.router) # Skills & Jobs Dashboard
|
||||||
|
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.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
if FRONTEND_DIST.exists():
|
if FRONTEND_DIST.exists():
|
||||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
||||||
|
|
||||||
|
_DIST_ROOT = FRONTEND_DIST.resolve()
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
def spa(full_path: str):
|
def spa(full_path: str):
|
||||||
# Falls die Datei direkt in FRONTEND_DIST liegt (z.B. manifest.webmanifest, favicon.ico), liefere sie aus
|
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
||||||
target = FRONTEND_DIST / full_path
|
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
||||||
if target.is_file():
|
try:
|
||||||
return FileResponse(target)
|
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"
|
index = FRONTEND_DIST / "index.html"
|
||||||
if index.exists():
|
if index.exists():
|
||||||
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
|
# 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"}
|
return {"detail": "frontend not built"}
|
||||||
|
|
||||||
|
|||||||
+136
-118
@@ -1,118 +1,136 @@
|
|||||||
"""
|
"""
|
||||||
Zentrale Konfiguration für Mission Control 2.0.
|
Zentrale Konfiguration für Mission Control 2.0.
|
||||||
|
|
||||||
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
||||||
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
||||||
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
# --- Engine (llama-swap) -----------------------------------------------------
|
# --- Engine (llama-swap) -----------------------------------------------------
|
||||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||||
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
||||||
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
||||||
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
||||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
||||||
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
||||||
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
||||||
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
||||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||||
_DEFAULT_CMD_TEMPLATE = (
|
_DEFAULT_CMD_TEMPLATE = (
|
||||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||||
"-c {ctx} -ngl 999 -fa 1 --no-mmap"
|
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
||||||
)
|
)
|
||||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||||
if "{model}" not in CMD_TEMPLATE:
|
if "{model}" not in CMD_TEMPLATE:
|
||||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||||
|
|
||||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
# 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.
|
# 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_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.
|
||||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
|
||||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||||
def _read_hermes_env(key: str) -> str:
|
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||||
try:
|
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
||||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
def _read_hermes_env(key: str) -> str:
|
||||||
line = line.strip()
|
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||||
if line.startswith(f"{key}="):
|
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
try:
|
||||||
except OSError:
|
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||||
pass
|
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||||
return ""
|
line = line.strip()
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
HERMES_API_KEY = (
|
except OSError:
|
||||||
os.environ.get("HERMES_API_KEY")
|
pass
|
||||||
or os.environ.get("API_SERVER_KEY")
|
return ""
|
||||||
or _read_hermes_env("API_SERVER_KEY")
|
|
||||||
)
|
|
||||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
HERMES_API_KEY = (
|
||||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
os.environ.get("HERMES_API_KEY")
|
||||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
or os.environ.get("API_SERVER_KEY")
|
||||||
|
or _read_hermes_env("API_SERVER_KEY")
|
||||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
)
|
||||||
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||||
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI (Ersatz für AnythingLLM-Chat).
|
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||||
# 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("/")
|
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||||
# GitHub-Repo für Update-Checks.
|
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
||||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
# 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.
|
||||||
# --- Server ------------------------------------------------------------------
|
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
||||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
||||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
BOX_CONSOLE_PATH = "/console/"
|
||||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
# 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
|
||||||
# Version (Phase 0 — Greenfield-Skeleton).
|
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||||
VERSION = "2.0.0-w8"
|
# 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.
|
||||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||||
yaml = YAML()
|
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||||
yaml.preserve_quotes = True
|
# GitHub-Repo für Update-Checks.
|
||||||
|
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||||
|
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||||
|
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||||
|
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
||||||
|
|
||||||
|
# --- Server ------------------------------------------------------------------
|
||||||
|
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||||
|
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||||
|
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||||
|
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||||
|
|
||||||
|
# Version (Phase 0 — Greenfield-Skeleton).
|
||||||
|
VERSION = "2.0.0-w8"
|
||||||
|
|
||||||
|
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||||
|
yaml = YAML()
|
||||||
|
yaml.preserve_quotes = True
|
||||||
|
|||||||
@@ -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}
|
||||||
+57
-56
@@ -1,56 +1,57 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add backend directory to sys.path so we can import services
|
# Add backend directory to sys.path so we can import services
|
||||||
sys.path.append(str(Path(__file__).resolve().parent))
|
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 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}...")
|
def migrate():
|
||||||
if not CONFIG_PATH.exists():
|
print(f"Reading config from {CONFIG_PATH}...")
|
||||||
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
|
if not CONFIG_PATH.exists():
|
||||||
return
|
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
|
||||||
|
return
|
||||||
cfg = read_config()
|
|
||||||
models = cfg.get("models", {})
|
cfg = read_config()
|
||||||
|
models = cfg.get("models", {})
|
||||||
for name, spec in models.items():
|
|
||||||
if not isinstance(spec, dict):
|
for name, spec in models.items():
|
||||||
continue
|
if not isinstance(spec, dict):
|
||||||
cmd = spec.get("cmd", "")
|
continue
|
||||||
if not cmd:
|
cmd = spec.get("cmd", "")
|
||||||
continue
|
if not cmd:
|
||||||
|
continue
|
||||||
print(f"Migrating model: {name}")
|
|
||||||
|
print(f"Migrating model: {name}")
|
||||||
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
|
|
||||||
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
|
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
|
||||||
# automatisch pro Slot.
|
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
|
||||||
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
|
# automatisch pro Slot.
|
||||||
|
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
|
||||||
# 2. Extract aliases/role
|
|
||||||
aliases = spec.get("aliases", [])
|
# 2. Extract aliases/role
|
||||||
role = aliases[0] if aliases else None
|
aliases = spec.get("aliases", [])
|
||||||
|
role = aliases[0] if aliases else None
|
||||||
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
|
|
||||||
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
|
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
|
||||||
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
|
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
|
||||||
if role in ("fast", "coder"):
|
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
|
||||||
if "--parallel" not in cmd:
|
if role in ("fast", "coder"):
|
||||||
cmd = cmd.strip() + " --parallel 2"
|
if "--parallel" not in cmd:
|
||||||
if "--spec-draft-model" not in cmd:
|
cmd = cmd.strip() + " --parallel 2"
|
||||||
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
|
if "--spec-draft-model" not in cmd:
|
||||||
cmd = cmd.strip() + spec_draft_flags(target)
|
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
|
||||||
|
cmd = cmd.strip() + spec_draft_flags(target)
|
||||||
# Update cmd
|
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
# Update cmd
|
||||||
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
|
||||||
print(f"Writing updated config back to {CONFIG_PATH}...")
|
|
||||||
write_config(cfg)
|
print(f"Writing updated config back to {CONFIG_PATH}...")
|
||||||
print("Migration completed successfully!")
|
write_config(cfg)
|
||||||
|
print("Migration completed successfully!")
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate()
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
|
|||||||
+71
-60
@@ -1,60 +1,71 @@
|
|||||||
{
|
{
|
||||||
"_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.",
|
"_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": [
|
"models": [
|
||||||
{
|
{
|
||||||
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
|
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
|
||||||
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
|
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
|
"role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
|
||||||
"family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
|
"family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
|
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF",
|
"role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF",
|
||||||
"family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10,
|
"family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10,
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
|
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF",
|
"role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF",
|
||||||
"family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
|
"family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5,
|
||||||
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
|
"moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF",
|
"role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF",
|
||||||
"family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3,
|
"family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3,
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
|
"role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
|
||||||
"family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
|
"family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
|
"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": 8, "active_params_b": 8,
|
"family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
|
||||||
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF",
|
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
|
||||||
"family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2,
|
"family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8,
|
||||||
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
{
|
"role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF",
|
||||||
"role": "scout", "name": "gemma-4-26B-A4B-it", "repo": "google/gemma-4-26B-A4B-it-GGUF",
|
"family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2,
|
||||||
"family": "gemma", "generation": 4.0, "total_params_b": 26, "active_params_b": 4,
|
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
},
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"role": "scout", "name": "gemma-4-31B-it", "repo": "google/gemma-4-31B-it-GGUF",
|
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
||||||
"family": "gemma", "generation": 4.0, "total_params_b": 31, "active_params_b": 31,
|
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
|
||||||
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
|
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
||||||
}
|
},
|
||||||
]
|
|
||||||
}
|
{
|
||||||
|
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
|
||||||
|
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
|
||||||
|
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
|
||||||
|
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
|
||||||
|
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
fastapi>=0.115
|
fastapi>=0.115
|
||||||
|
python-multipart>=0.0.9
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
ruamel.yaml>=0.18
|
ruamel.yaml>=0.18
|
||||||
psutil>=5.9
|
psutil>=5.9
|
||||||
huggingface_hub>=0.27
|
huggingface_hub>=0.27
|
||||||
mcp>=1.2.0
|
mcp>=1.2.0
|
||||||
|
tzdata>=2024.1
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
"""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 pydantic import BaseModel
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -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)."""
|
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
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,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
|
import httpx
|
||||||
|
from config import LLAMA_SWAP_URL
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||||
from config import LLAMA_SWAP_URL
|
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
||||||
from services.gateway_stream import record_stream_chunk, record_usage
|
|
||||||
from services.router_logic import LANES, choose_for_lane
|
|
||||||
from services.routing_policy import load_policy
|
from services.routing_policy import load_policy
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1")
|
||||||
|
|
||||||
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet.
|
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
|
||||||
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
|
# (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")
|
@router.get("/models")
|
||||||
async def models():
|
async def models(request: Request):
|
||||||
async with httpx.AsyncClient(timeout=10) as c:
|
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||||
r = await c.get(f"{LLAMA_SWAP_URL}/v1/models")
|
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
|
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
|
||||||
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
|
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
|
||||||
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
|
# 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):
|
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)
|
return JSONResponse(data, status_code=r.status_code)
|
||||||
|
|
||||||
|
|
||||||
async def _proxy(path: str, request: Request):
|
async def _proxy(path: str, request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
|
||||||
requested = str(body.get("model") or "auto")
|
requested = str(body.get("model") or "auto")
|
||||||
if requested.lower() in ("auto", "chat", "coding"):
|
if requested.lower() in ("auto", "chat", "coding"):
|
||||||
lane = requested.lower()
|
lane = requested.lower()
|
||||||
@@ -37,26 +198,59 @@ async def _proxy(path: str, request: Request):
|
|||||||
else:
|
else:
|
||||||
alias = requested
|
alias = requested
|
||||||
routed = {"x-mc-routed-to": requested}
|
routed = {"x-mc-routed-to": requested}
|
||||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
|
||||||
pol = load_policy()
|
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:
|
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
|
||||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
|
_inject_language(body, alias)
|
||||||
url = f"{LLAMA_SWAP_URL}{path}"
|
url = f"{LLAMA_SWAP_URL}{path}"
|
||||||
|
|
||||||
if body.get("stream"):
|
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 def gen():
|
||||||
async with httpx.AsyncClient(timeout=None) as c:
|
try:
|
||||||
async with c.stream("POST", url, json=body) as r:
|
async for chunk in r.aiter_raw():
|
||||||
async for chunk in r.aiter_raw():
|
record_stream_chunk(chunk, alias, body.get("max_tokens"))
|
||||||
record_stream_chunk(chunk, alias)
|
yield chunk
|
||||||
yield chunk
|
finally:
|
||||||
|
await r.aclose()
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=600) as c:
|
r = await client.post(url, json=body, timeout=600.0)
|
||||||
r = await c.post(url, json=body)
|
resp_json = r.json()
|
||||||
resp_json = r.json()
|
if isinstance(resp_json, dict):
|
||||||
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
|
record_usage(resp_json.get("usage"), alias)
|
||||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
||||||
|
for c in resp_json.get("choices") or []):
|
||||||
|
warn_truncation(alias, body.get("max_tokens"))
|
||||||
|
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat/completions")
|
@router.post("/chat/completions")
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from config import VERSION
|
from config import VERSION
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import gateway, llamaswap
|
from services import gateway, llamaswap
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
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:
|
def health() -> dict:
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"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 Agentic IDE
|
||||||
|
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."""
|
"""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 pydantic import BaseModel
|
||||||
|
|
||||||
from services import maintenance
|
from services import maintenance
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -65,6 +64,11 @@ def hermes_update() -> dict:
|
|||||||
return maintenance.hermes_update_job()
|
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")
|
@router.post("/maintenance/reboot")
|
||||||
def reboot(body: SudoReq) -> dict:
|
def reboot(body: SudoReq) -> dict:
|
||||||
return maintenance.reboot(body.sudo_password)
|
return maintenance.reboot(body.sudo_password)
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from services import memory
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class MemIn(BaseModel):
|
|
||||||
content: str
|
|
||||||
category: str = "knowledge"
|
|
||||||
source: str = "manual"
|
|
||||||
|
|
||||||
|
|
||||||
class MemUp(BaseModel):
|
|
||||||
content: str | None = None
|
|
||||||
category: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DedupeIn(BaseModel):
|
|
||||||
apply: bool = False
|
|
||||||
threshold: float = 0.85
|
|
||||||
|
|
||||||
|
|
||||||
class LearnIn(BaseModel):
|
|
||||||
text: str | None = None
|
|
||||||
messages: list[dict] | None = None
|
|
||||||
source: str = "auto"
|
|
||||||
category: str = "knowledge"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memory/export")
|
|
||||||
def export() -> dict:
|
|
||||||
return memory.export_text()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memory/graph")
|
|
||||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
|
||||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
|
|
||||||
return memory.graph(min_score=min_score, top_k=top_k)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/learn", status_code=201)
|
|
||||||
def learn(body: LearnIn) -> dict:
|
|
||||||
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
|
|
||||||
return memory.learn(text=body.text, messages=body.messages,
|
|
||||||
source=body.source, category=body.category)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/dedupe")
|
|
||||||
def dedupe(body: DedupeIn) -> dict:
|
|
||||||
return memory.dedupe(apply=body.apply, threshold=body.threshold)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memory")
|
|
||||||
def list_mem(q: str = "", category: str = "") -> list[dict]:
|
|
||||||
return memory.list_memories(q=q, category=category)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory", status_code=201)
|
|
||||||
def add(body: MemIn) -> dict:
|
|
||||||
if body.category not in memory.CATEGORIES:
|
|
||||||
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
|
|
||||||
return memory.add_memory(body.content, body.category, body.source)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/memory/{mid}")
|
|
||||||
def update(mid: str, body: MemUp) -> dict:
|
|
||||||
res = memory.update_memory(mid, content=body.content, category=body.category)
|
|
||||||
if not res:
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/memory/{mid}")
|
|
||||||
def delete(mid: str) -> dict:
|
|
||||||
if not memory.delete_memory(mid):
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
return {"ok": True}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
|
||||||
from services import budget, discover, hf, jobengine, llamaswap
|
from services import budget, discover, hf, jobengine, llamaswap
|
||||||
from services.fit import evaluate_fit, max_ctx_for
|
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).
|
# 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"]
|
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:
|
try:
|
||||||
model_id = llamaswap.register_model(
|
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:
|
except PermissionError as exc:
|
||||||
raise HTTPException(500, str(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.
|
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||||
args = [hf.hf_bin(), "download", repo]
|
args = [hf.hf_bin(), "download", repo]
|
||||||
for f in info["files"]:
|
args.extend(info["files"])
|
||||||
args.append(f)
|
|
||||||
if info["mmproj"]:
|
if info["mmproj"]:
|
||||||
args.append(info["mmproj"])
|
args.append(info["mmproj"])
|
||||||
args += ["--local-dir", str(target)]
|
args += ["--local-dir", str(target)]
|
||||||
env = dict(HF_DOWNLOAD_ENV)
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if req.hf_token:
|
||||||
env["HF_TOKEN"] = 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"])
|
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,
|
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
|
||||||
"total_bytes": info["total_bytes"], "files": len(info["files"])}
|
"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")
|
@router.post("/models/{model_id}/role")
|
||||||
def set_model_role(model_id: str, body: RoleReq) -> dict:
|
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 +
|
# 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.
|
# 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
|
from services.agent import set_agent_brain
|
||||||
res = set_agent_brain(model_id)
|
res = set_agent_brain(model_id)
|
||||||
if not res.get("ok"):
|
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 fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import gateway
|
from services import gateway
|
||||||
from services.routing_policy import policy_meta, save_policy
|
from services.routing_policy import policy_meta, save_policy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
class RunSkillRequest(BaseModel):
|
||||||
|
skill_name: str
|
||||||
|
|
||||||
|
@router.get("/skills")
|
||||||
|
def list_skills():
|
||||||
|
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
|
||||||
|
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
repo_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
# Check currently running hermes skill processes
|
||||||
|
running_skills = set()
|
||||||
|
for p in psutil.process_iter(['name', 'cmdline']):
|
||||||
|
try:
|
||||||
|
cmdline = p.info.get('cmdline')
|
||||||
|
if cmdline and any("hermes" in arg for arg in cmdline):
|
||||||
|
for arg in cmdline:
|
||||||
|
if "Führe den " in arg and " Skill aus" in arg:
|
||||||
|
# Extract skill name from "Führe den 'skill_name' Skill aus"
|
||||||
|
import re
|
||||||
|
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
|
||||||
|
if match:
|
||||||
|
running_skills.add(match.group(1))
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
||||||
|
for entry in os.scandir(skills_dir):
|
||||||
|
if entry.is_dir():
|
||||||
|
# Suche nach SKILL.md für Metadaten
|
||||||
|
skill_md_path = os.path.join(entry.path, "SKILL.md")
|
||||||
|
description = ""
|
||||||
|
if os.path.exists(skill_md_path):
|
||||||
|
try:
|
||||||
|
with open(skill_md_path, "r", encoding="utf-8") as f:
|
||||||
|
# Lese erste Zeilen für Description (YAML Frontmatter)
|
||||||
|
lines = f.readlines()[:10]
|
||||||
|
for line in lines:
|
||||||
|
if "description:" in line.lower():
|
||||||
|
description = line.split(":", 1)[1].strip().strip('"\'')
|
||||||
|
break
|
||||||
|
elif "author:" in line.lower() and not description:
|
||||||
|
description = line.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
skills.append({
|
||||||
|
"name": entry.name,
|
||||||
|
"description": description or "Keine Beschreibung verfügbar.",
|
||||||
|
"running": entry.name in running_skills
|
||||||
|
})
|
||||||
|
return {"skills": skills}
|
||||||
|
|
||||||
|
@router.post("/skills/run")
|
||||||
|
def run_skill(req: RunSkillRequest):
|
||||||
|
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
|
||||||
|
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
|
||||||
|
if not os.path.exists(hermes_bin):
|
||||||
|
# Fallback falls lokal (auf Windows) entwickelt wird
|
||||||
|
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
|
||||||
|
|
||||||
|
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat
|
||||||
|
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus", "chat"]
|
||||||
|
try:
|
||||||
|
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{req.skill_name.replace('/', '_')}.log")
|
||||||
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
|
with open(log_path, "a") as f:
|
||||||
|
f.write(f"\n--- Starting Skill: {req.skill_name} ---\n")
|
||||||
|
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
|
||||||
|
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "err": str(e)}
|
||||||
+175
-128
@@ -1,128 +1,175 @@
|
|||||||
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
||||||
|
|
||||||
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
||||||
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
||||||
zurückgegeben statt zu crashen.
|
zurückgegeben statt zu crashen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
import httpx
|
||||||
from pydantic import BaseModel
|
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||||
|
from fastapi import APIRouter
|
||||||
import httpx
|
from pydantic import BaseModel
|
||||||
|
from services import backup as backup_svc
|
||||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
|
from services import maintenance
|
||||||
from services import backup as backup_svc
|
from services.agent import agent_status
|
||||||
from services.agent import agent_status
|
from services.gateway import gateway_reachable
|
||||||
from services.gateway import gateway_reachable
|
from services.llamaswap import engine_reachable, list_models
|
||||||
from services.llamaswap import engine_reachable, list_models
|
from services.pricing import compute_savings
|
||||||
from services.pricing import compute_savings
|
from services.system import system_status
|
||||||
from services.system import system_status
|
from services.token_stats import get_stats
|
||||||
from services.token_stats import get_stats
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
|
||||||
# Nur diese User-Dienste dürfen neugestartet werden.
|
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
|
||||||
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"))
|
@router.get("/system/status")
|
||||||
|
def status() -> dict:
|
||||||
|
return system_status()
|
||||||
@router.get("/system/status")
|
|
||||||
def status() -> dict:
|
|
||||||
return system_status()
|
@router.get("/system/history")
|
||||||
|
def history(minutes: int = 60) -> dict:
|
||||||
|
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
||||||
def _mem0_reachable() -> bool:
|
from services import metrics_history
|
||||||
try:
|
return metrics_history.history(minutes)
|
||||||
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
def _mem0_reachable() -> bool:
|
||||||
|
try:
|
||||||
|
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||||
def _voice_reachable() -> bool:
|
except Exception:
|
||||||
try:
|
return False
|
||||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
def _voice_reachable() -> bool:
|
||||||
|
try:
|
||||||
|
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||||
@router.get("/system/services")
|
except Exception:
|
||||||
def services() -> dict:
|
return False
|
||||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
|
|
||||||
a = agent_status()
|
|
||||||
gw_url = f"{GATEWAY_URL}/v1"
|
def _user_unit_active(unit: str) -> bool:
|
||||||
return {
|
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
|
||||||
"services": [
|
Auf Windows/Dev schlägt das harmlos fehl (False)."""
|
||||||
{"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
|
if os.name != "posix":
|
||||||
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
|
return False
|
||||||
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
|
try:
|
||||||
{"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
|
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
||||||
{"name": "Mem0 (Gedächtnis)", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
|
capture_output=True, text=True, timeout=3)
|
||||||
{"name": "Voice (STT/TTS)", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
|
return r.stdout.strip() == "active"
|
||||||
],
|
except Exception:
|
||||||
"links": {
|
return False
|
||||||
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
|
||||||
"gateway": gw_url,
|
|
||||||
"hermes_terminal": a["terminal_url"],
|
@router.get("/system/services")
|
||||||
},
|
def services() -> dict:
|
||||||
}
|
"""Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel,
|
||||||
|
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
|
||||||
|
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
|
||||||
@router.post("/system/backup")
|
a = agent_status()
|
||||||
def backup() -> dict:
|
gw_url = f"{GATEWAY_URL}/v1"
|
||||||
return backup_svc.backup_now()
|
rows = [
|
||||||
|
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
||||||
|
"ok": engine_reachable(), "scope": "system"},
|
||||||
@router.get("/system/backups")
|
]
|
||||||
def backups() -> dict:
|
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
||||||
return {"backups": backup_svc.list_backups()}
|
# 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,
|
||||||
def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
"ok": gateway_reachable(), "scope": "user"})
|
||||||
try:
|
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
"ok": True, "scope": "user"})
|
||||||
except Exception as exc: # noqa: BLE001
|
else:
|
||||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||||
|
"ok": gateway_reachable(), "scope": "user"})
|
||||||
|
rows += [
|
||||||
class RestartReq(BaseModel):
|
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
||||||
service: str
|
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
||||||
|
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||||
|
"ok": a["gateway_reachable"], "scope": "user"},
|
||||||
@router.post("/system/restart")
|
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||||
def restart(req: RestartReq) -> dict:
|
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||||
if req.service not in ALLOWED_SERVICES:
|
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
|
||||||
raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.")
|
"ok": _mem0_reachable(), "scope": "user"},
|
||||||
return _run(["systemctl", "--user", "restart", req.service])
|
{"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,
|
||||||
@router.post("/system/self-update")
|
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
||||||
def self_update() -> dict:
|
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
||||||
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
|
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
||||||
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
|
]
|
||||||
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
|
return {
|
||||||
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
|
"services": rows,
|
||||||
return {"pull": pull, "reset": reset, "restart": restart_res}
|
"links": {
|
||||||
|
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
||||||
|
"gateway": gw_url,
|
||||||
@router.get("/system/token-stats")
|
"hermes_ui": a["hermes_ui_url"],
|
||||||
def token_stats() -> dict:
|
},
|
||||||
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
}
|
||||||
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
|
|
||||||
role_map: dict[str, str | None] = {}
|
|
||||||
try:
|
@router.post("/system/backup")
|
||||||
for m in list_models():
|
def backup() -> dict:
|
||||||
role_map[m["name"].lower()] = m.get("role")
|
return backup_svc.backup_now()
|
||||||
for alias in m.get("aliases", []):
|
|
||||||
role_map[alias.lower()] = m.get("role")
|
|
||||||
except Exception:
|
@router.get("/system/backups")
|
||||||
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
|
def backups() -> dict:
|
||||||
return compute_savings(get_stats(), role_map)
|
return {"backups": backup_svc.list_backups()}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||||
|
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||||
|
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
class RestartReq(BaseModel):
|
||||||
|
service: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/restart")
|
||||||
|
def restart(req: RestartReq) -> dict:
|
||||||
|
"""Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit):
|
||||||
|
der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von
|
||||||
|
der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste
|
||||||
|
(ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
|
||||||
|
Sprache/MCP schlug fehl."""
|
||||||
|
return maintenance.restart_service(req.service)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/system/self-update")
|
||||||
|
def self_update() -> dict:
|
||||||
|
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
|
||||||
|
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
|
||||||
|
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
|
||||||
|
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
|
||||||
|
return {"pull": pull, "reset": reset, "restart": restart_res}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/token-stats")
|
||||||
|
def token_stats() -> dict:
|
||||||
|
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||||
|
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
|
||||||
|
role_map: dict[str, str | None] = {}
|
||||||
|
try:
|
||||||
|
for m in list_models():
|
||||||
|
role_map[m["name"].lower()] = m.get("role")
|
||||||
|
for alias in m.get("aliases", []):
|
||||||
|
role_map[alias.lower()] = m.get("role")
|
||||||
|
except Exception:
|
||||||
|
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
|
||||||
|
return compute_savings(get_stats(), role_map)
|
||||||
|
|||||||
+357
-242
@@ -1,242 +1,357 @@
|
|||||||
"""
|
"""
|
||||||
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
||||||
|
|
||||||
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
||||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
||||||
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
||||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
||||||
|
|
||||||
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||||
import httpx
|
import sys as _sys
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
import time
|
||||||
from fastapi.responses import Response, StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
import httpx
|
||||||
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from services.voice_metrics import Timer, get_metrics, record_stage # Per-Stage-Latenz (C2)
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
from services import announce
|
||||||
import sys as _sys
|
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
Timer,
|
||||||
if _GUARD_DIR not in _sys.path:
|
TurnTrace,
|
||||||
_sys.path.insert(0, _GUARD_DIR)
|
get_metrics,
|
||||||
try:
|
get_trace,
|
||||||
from guard import wrap_untrusted
|
park,
|
||||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
record_stage,
|
||||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
)
|
||||||
return text
|
|
||||||
|
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||||
log = logging.getLogger(__name__)
|
if _GUARD_DIR not in _sys.path:
|
||||||
router = APIRouter(prefix="/api")
|
_sys.path.insert(0, _GUARD_DIR)
|
||||||
|
try:
|
||||||
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
|
from guard import wrap_untrusted
|
||||||
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
|
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||||
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
|
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
return text
|
||||||
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
|
||||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
log = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
|
||||||
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
|
||||||
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
|
||||||
multi = len(image_urls) > 1
|
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
||||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
|
||||||
if multi else
|
|
||||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
||||||
for u in image_urls:
|
multi = len(image_urls) > 1
|
||||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||||
try:
|
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=5.0)) as client:
|
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
if multi else
|
||||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||||
"messages": [{"role": "user", "content": content}],
|
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||||
})
|
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||||
r.raise_for_status()
|
for u in image_urls:
|
||||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||||
except Exception as exc:
|
try:
|
||||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
|
||||||
return ""
|
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||||
|
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||||
|
"messages": [{"role": "user", "content": content}],
|
||||||
class TTSIn(BaseModel):
|
})
|
||||||
text: str
|
r.raise_for_status()
|
||||||
engine: str = "piper"
|
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||||
voice: str = ""
|
except Exception as exc:
|
||||||
language: str = ""
|
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||||
ref_path: str = ""
|
return ""
|
||||||
|
|
||||||
|
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
||||||
class ChatIn(BaseModel):
|
|
||||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
|
||||||
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
class TTSIn(BaseModel):
|
||||||
session_key: str = "" # optional: Langzeit-Memory-Scope
|
text: str
|
||||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
engine: str = "piper"
|
||||||
model: str = ""
|
voice: str = ""
|
||||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
language: str = ""
|
||||||
|
ref_path: str = ""
|
||||||
|
|
||||||
@router.get("/voice/metrics")
|
|
||||||
def voice_metrics() -> dict:
|
class ChatIn(BaseModel):
|
||||||
"""Per-Stage-Latenz (STT/Vision/Chat-TTFB/TTS) — rollende Statistik, macht die Voice-Pipeline
|
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||||
messbar (C2). Anzeige im Frontend-Overhaul (E)."""
|
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
||||||
return get_metrics()
|
session_key: str = "" # optional: Langzeit-Memory-Scope
|
||||||
|
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||||
|
model: str = ""
|
||||||
@router.get("/voice/health")
|
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||||
def voice_health() -> dict:
|
|
||||||
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
|
||||||
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
class AnnounceIn(BaseModel):
|
||||||
try:
|
text: str # die Meldung (wird von Lucy gesprochen)
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||||
out["sidecar"] = r.status_code == 200
|
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||||
out["detail"] = r.json() if r.status_code == 200 else None
|
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
out["error"] = str(exc)
|
|
||||||
return out
|
class AlarmIn(BaseModel):
|
||||||
|
text: str # die Alarm-Meldung
|
||||||
|
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
||||||
@router.get("/voice/voices")
|
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
||||||
def voice_voices() -> dict:
|
|
||||||
try:
|
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
@router.post("/alarm")
|
||||||
r.raise_for_status()
|
def alarm(body: AlarmIn) -> dict:
|
||||||
return r.json()
|
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||||
except Exception as exc: # noqa: BLE001
|
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
||||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
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."""
|
||||||
@router.post("/voice/stt")
|
text = (body.text or "").strip()
|
||||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
if not text:
|
||||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
raise HTTPException(400, "Leere Meldung.")
|
||||||
data = await audio.read()
|
subject = (body.subject or "[Alarm]").strip()
|
||||||
if not data:
|
try:
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
except ValueError as exc:
|
||||||
try:
|
raise HTTPException(400, str(exc))
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||||
with Timer("stt"):
|
return {"ok": True, "item": item}
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
@router.post("/voice/announce")
|
||||||
except httpx.HTTPError as exc:
|
def voice_announce(body: AnnounceIn) -> dict:
|
||||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
"""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:
|
||||||
@router.post("/voice/reference")
|
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
except ValueError as exc:
|
||||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
raise HTTPException(400, str(exc))
|
||||||
data = await audio.read()
|
|
||||||
if not data:
|
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
@router.get("/voice/announcements")
|
||||||
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||||
try:
|
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
return announce.list_after(after, limit)
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except httpx.HTTPError as exc:
|
@router.get("/voice/health")
|
||||||
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
def voice_health() -> dict:
|
||||||
|
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
||||||
|
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
||||||
@router.get("/voice/reference")
|
try:
|
||||||
def voice_get_reference() -> dict:
|
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
try:
|
out["sidecar"] = r.status_code == 200
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
out["detail"] = r.json() if r.status_code == 200 else None
|
||||||
r.raise_for_status()
|
except Exception as exc:
|
||||||
return r.json()
|
out["error"] = str(exc)
|
||||||
except Exception as exc: # noqa: BLE001
|
return out
|
||||||
return {"active": False, "error": str(exc)}
|
|
||||||
|
|
||||||
|
@router.get("/voice/metrics")
|
||||||
@router.delete("/voice/reference")
|
def voice_metrics() -> dict:
|
||||||
def voice_clear_reference() -> dict:
|
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
||||||
try:
|
return get_metrics()
|
||||||
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
@router.get("/voice/trace")
|
||||||
except httpx.HTTPError as exc:
|
def voice_trace(limit: int = 20) -> dict:
|
||||||
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
"""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."""
|
||||||
@router.post("/voice/tts")
|
return {"turns": get_trace(limit)}
|
||||||
async def voice_tts(body: TTSIn) -> Response:
|
|
||||||
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
|
||||||
try:
|
@router.get("/voice/voices")
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
def voice_voices() -> dict:
|
||||||
with Timer("tts"):
|
try:
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
return r.json()
|
||||||
except httpx.HTTPError as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/voice/chat")
|
@router.post("/voice/stt")
|
||||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||||
|
data = await audio.read()
|
||||||
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
if not data:
|
||||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
if not HERMES_API_KEY:
|
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
messages = []
|
_t0 = time.perf_counter()
|
||||||
if body.system:
|
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||||
messages.append({"role": "system", "content": body.system})
|
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
user_text = body.text
|
record_stage("stt", _ms)
|
||||||
imgs = [u for u in (body.images or []) if u]
|
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
||||||
if imgs:
|
r.raise_for_status()
|
||||||
# Bildschirm-Sicht: erst das Vision-Modell die Monitore beschreiben lassen, dann die Beschreibung
|
return r.json()
|
||||||
# als TEXT-Kontext an Hermes (Lucy antwortet mit vollem Hirn/Gedächtnis, sieht via besserem VL-Modell).
|
except httpx.HTTPError as exc:
|
||||||
with Timer("vision"):
|
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||||
desc = await _describe_images(imgs, body.text)
|
|
||||||
if desc:
|
|
||||||
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
@router.post("/voice/turn")
|
||||||
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||||
messages.append({"role": "user", "content": user_text})
|
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
data = await audio.read()
|
||||||
# Lucy-Hirn ist Thinking-Modell -> fuer die gesprochene Assistentin Thinking AUS (sonst 11k
|
if not data:
|
||||||
# Reasoning-Token vor der kurzen Antwort, gemessen ~30s TTFB). Muster wie gateway_proxy/mem0.
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
try:
|
||||||
headers = {
|
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
with Timer("turn"):
|
||||||
"X-Hermes-Session-Id": body.session_id,
|
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||||
}
|
r.raise_for_status()
|
||||||
if body.session_key:
|
return r.json()
|
||||||
headers["X-Hermes-Session-Key"] = body.session_key
|
except httpx.HTTPError as exc:
|
||||||
|
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||||
async def gen():
|
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||||
t0 = time.perf_counter()
|
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||||
first = True
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
@router.post("/voice/reference")
|
||||||
async with client.stream(
|
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
||||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
||||||
) as r:
|
data = await audio.read()
|
||||||
if r.status_code != 200:
|
if not data:
|
||||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
|
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
||||||
return
|
try:
|
||||||
async for chunk in r.aiter_raw():
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
if first: # Time-To-First-Byte des Hermes-Streams (gefühlte Lucy-Latenz)
|
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
||||||
record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0)
|
r.raise_for_status()
|
||||||
first = False
|
return r.json()
|
||||||
yield chunk
|
except httpx.HTTPError as exc:
|
||||||
except httpx.HTTPError as exc:
|
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
||||||
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
@router.get("/voice/reference")
|
||||||
|
def voice_get_reference() -> dict:
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"active": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/voice/reference")
|
||||||
|
def voice_clear_reference() -> dict:
|
||||||
|
try:
|
||||||
|
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voice/tts")
|
||||||
|
async def voice_tts(body: TTSIn) -> Response:
|
||||||
|
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
with Timer("tts"):
|
||||||
|
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
||||||
|
r.raise_for_status()
|
||||||
|
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voice/chat")
|
||||||
|
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||||
|
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||||
|
|
||||||
|
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||||
|
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||||
|
if not HERMES_API_KEY:
|
||||||
|
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||||
|
"X-Hermes-Session-Id": body.session_id,
|
||||||
|
}
|
||||||
|
if body.session_key:
|
||||||
|
headers["X-Hermes-Session-Key"] = body.session_key
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
|
||||||
|
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||||
|
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
||||||
|
first = True
|
||||||
|
first_content = True
|
||||||
|
committed = False
|
||||||
|
|
||||||
|
def _commit() -> None:
|
||||||
|
nonlocal committed
|
||||||
|
if not committed:
|
||||||
|
committed = True
|
||||||
|
trace.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
||||||
|
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
||||||
|
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
||||||
|
user_text = body.text
|
||||||
|
imgs = [u for u in (body.images or []) if u]
|
||||||
|
trace.had_images = bool(imgs)
|
||||||
|
if imgs:
|
||||||
|
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||||
|
_tv = time.perf_counter()
|
||||||
|
desc = await _describe_images(imgs, body.text)
|
||||||
|
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
|
||||||
|
if desc:
|
||||||
|
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||||
|
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||||
|
messages = []
|
||||||
|
if body.system:
|
||||||
|
messages.append({"role": "system", "content": body.system})
|
||||||
|
messages.append({"role": "user", "content": user_text})
|
||||||
|
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
|
||||||
|
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||||
|
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
||||||
|
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
||||||
|
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
|
||||||
|
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||||
|
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||||
|
) as r:
|
||||||
|
if r.status_code != 200:
|
||||||
|
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||||
|
trace.error = f"Hermes {r.status_code}"
|
||||||
|
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
|
||||||
|
return
|
||||||
|
async for chunk in r.aiter_raw():
|
||||||
|
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
|
||||||
|
trace.note_ttfb()
|
||||||
|
first = False
|
||||||
|
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
|
||||||
|
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
||||||
|
if first_content and b'"content"' in chunk:
|
||||||
|
trace.note_first_content()
|
||||||
|
first_content = False
|
||||||
|
yield chunk
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
trace.error = "verbindung"
|
||||||
|
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
||||||
|
finally:
|
||||||
|
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""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 re
|
||||||
|
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 or "traeume" 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.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wissen/graph")
|
||||||
|
def graph_vault() -> dict:
|
||||||
|
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
|
||||||
|
if not _available():
|
||||||
|
return {"nodes": [], "edges": []}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
|
||||||
|
name_to_id = {}
|
||||||
|
|
||||||
|
# 1. Alle Nodes sammeln
|
||||||
|
for p in VAULT.rglob("*.md"):
|
||||||
|
if ".git" in p.parts or "traeume" in p.parts:
|
||||||
|
continue
|
||||||
|
rel = p.relative_to(VAULT).as_posix()
|
||||||
|
name_to_id[p.stem.lower()] = rel
|
||||||
|
|
||||||
|
# Kategorie aus dem Ordner ableiten
|
||||||
|
cat = "knowledge"
|
||||||
|
parent = p.parent.name if p.parent != VAULT else ""
|
||||||
|
if parent == "traeume":
|
||||||
|
cat = "events"
|
||||||
|
elif parent == "muster":
|
||||||
|
cat = "rules"
|
||||||
|
elif parent == "skill-kandidaten":
|
||||||
|
cat = "identity"
|
||||||
|
|
||||||
|
nodes.append({
|
||||||
|
"id": rel,
|
||||||
|
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
|
||||||
|
"category": cat,
|
||||||
|
"source": "wiki"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Edges extrahieren (Wiki-Links [[Name]])
|
||||||
|
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
|
||||||
|
for n in nodes:
|
||||||
|
try:
|
||||||
|
target = VAULT / n["id"]
|
||||||
|
content = target.read_text(encoding="utf-8", errors="replace")
|
||||||
|
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
|
||||||
|
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
|
||||||
|
|
||||||
|
for link in found_links:
|
||||||
|
target_id = name_to_id.get(link)
|
||||||
|
if target_id and target_id != n["id"]:
|
||||||
|
edges.append({
|
||||||
|
"source": n["id"],
|
||||||
|
"target": target_id,
|
||||||
|
"weight": 1.0
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
@@ -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()
|
||||||
+268
-252
@@ -1,252 +1,268 @@
|
|||||||
"""
|
"""
|
||||||
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
||||||
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
||||||
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
from config import (
|
||||||
|
BOX_CONSOLE_PATH,
|
||||||
from config import HERMES_TERMINAL_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL
|
BOX_CONSOLE_UPSTREAM,
|
||||||
|
HERMES_API_URL,
|
||||||
log = logging.getLogger(__name__)
|
HERMES_BUILTIN_UI_PATH,
|
||||||
|
HERMES_BUILTIN_UI_UPSTREAM,
|
||||||
|
HERMES_HOME,
|
||||||
def _hermes_version(name: str) -> float | None:
|
PC_EXECUTOR_URL,
|
||||||
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
|
)
|
||||||
low = (name or "").lower()
|
|
||||||
if "hermes" not in low:
|
log = logging.getLogger(__name__)
|
||||||
return None
|
|
||||||
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
|
|
||||||
return float(m.group(1)) if m else None
|
def _hermes_version(name: str) -> float | None:
|
||||||
|
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
|
||||||
|
low = (name or "").lower()
|
||||||
def _active_brain_name() -> str:
|
if "hermes" not in low:
|
||||||
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
return None
|
||||||
try:
|
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
|
||||||
from ruamel.yaml import YAML
|
return float(m.group(1)) if m else None
|
||||||
p = HERMES_HOME / "config.yaml"
|
|
||||||
if p.exists():
|
|
||||||
with p.open(encoding="utf-8") as f:
|
def _active_brain_name() -> str:
|
||||||
cfg = YAML().load(f) or {}
|
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
||||||
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
try:
|
||||||
return str(m.get("default") or m.get("model") or "auto")
|
from ruamel.yaml import YAML
|
||||||
except Exception:
|
p = HERMES_HOME / "config.yaml"
|
||||||
log.debug("_active_brain_name: Lesefehler", exc_info=True)
|
if p.exists():
|
||||||
return "auto"
|
with p.open(encoding="utf-8") as f:
|
||||||
|
cfg = YAML().load(f) or {}
|
||||||
|
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
||||||
def hermes_brain_info() -> dict:
|
return str(m.get("default") or m.get("model") or "auto")
|
||||||
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default),
|
except Exception:
|
||||||
plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
|
log.debug("_active_brain_name: Lesefehler", exc_info=True)
|
||||||
from services import llamaswap
|
return "auto"
|
||||||
|
|
||||||
models = llamaswap.list_models()
|
|
||||||
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
|
def hermes_brain_info() -> dict:
|
||||||
bl = brain.lower()
|
"""Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default),
|
||||||
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \
|
plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle."""
|
||||||
or next((m for m in models if bl in (m["name"] or "").lower()), None)
|
from services import llamaswap
|
||||||
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
|
|
||||||
current = None
|
models = llamaswap.list_models()
|
||||||
if cur:
|
brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname
|
||||||
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
|
bl = brain.lower()
|
||||||
"params_b": cur_params, "quant": cur.get("quant"),
|
cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \
|
||||||
"size_bytes": cur.get("size_bytes"),
|
or next((m for m in models if bl in (m["name"] or "").lower()), None)
|
||||||
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
|
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
|
||||||
|
current = None
|
||||||
# Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
|
if cur:
|
||||||
budget = None
|
current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"),
|
||||||
try:
|
"params_b": cur_params, "quant": cur.get("quant"),
|
||||||
from services.budget import footprint_gb, gtt_budget_gb
|
"size_bytes": cur.get("size_bytes"),
|
||||||
groups = llamaswap.list_groups()
|
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
|
||||||
persist = set()
|
|
||||||
for g in groups.values():
|
# Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget?
|
||||||
if isinstance(g, dict) and g.get("persist"):
|
budget = None
|
||||||
persist.update(g.get("members") or [])
|
try:
|
||||||
|
from services.budget import footprint_gb, gtt_budget_gb
|
||||||
cur_name = cur["name"] if cur else None
|
groups = llamaswap.list_groups()
|
||||||
brain_gb = footprint_gb(cur) if cur else 0.0
|
persist = set()
|
||||||
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
|
for g in groups.values():
|
||||||
warm = brain_gb + sum(footprint_gb(m) for m in models
|
if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
|
||||||
if m["name"] in persist and m["name"] != cur_name)
|
persist.update(g.get("members") or [])
|
||||||
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
|
|
||||||
gtt = gtt_budget_gb()
|
cur_name = cur["name"] if cur else None
|
||||||
# Brain muss immer resident sein → passt Brain + größtes on-demand zusammen?
|
brain_gb = footprint_gb(cur) if cur else 0.0
|
||||||
# (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.)
|
# voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info
|
||||||
budget = {
|
warm = brain_gb + sum(footprint_gb(m) for m in models
|
||||||
"gtt_gb": gtt,
|
if m["name"] in persist and m["name"] != cur_name)
|
||||||
"brain_gb": round(brain_gb, 1),
|
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
|
||||||
"warm_projected_gb": round(warm, 1),
|
gtt = gtt_budget_gb()
|
||||||
"largest_ondemand_gb": round(largest_od, 1),
|
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
|
||||||
"fits": (brain_gb + largest_od) <= gtt,
|
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
|
||||||
"free_after_gb": round(gtt - brain_gb - largest_od, 1),
|
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
|
||||||
}
|
budget = {
|
||||||
except Exception:
|
"gtt_gb": gtt,
|
||||||
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
|
"brain_gb": round(brain_gb, 1),
|
||||||
|
"warm_projected_gb": round(warm, 1),
|
||||||
return {"current": current, "recommended": None, "update_available": False, "budget": budget}
|
"largest_ondemand_gb": round(largest_od, 1),
|
||||||
|
"fits": (warm + largest_od) <= gtt,
|
||||||
|
"free_after_gb": round(gtt - warm - largest_od, 1),
|
||||||
def _reach(url: str, path: str = "") -> bool:
|
}
|
||||||
try:
|
except Exception:
|
||||||
with httpx.Client(timeout=3.0) as c:
|
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
|
||||||
return c.get(f"{url}{path}").status_code < 500
|
|
||||||
except httpx.HTTPError:
|
return {"current": current, "recommended": None, "update_available": False, "budget": budget}
|
||||||
return False
|
|
||||||
|
|
||||||
|
def _reach(url: str, path: str = "") -> bool:
|
||||||
def _count_enabled_mcp_servers() -> int:
|
try:
|
||||||
config_path = HERMES_HOME / "config.yaml"
|
with httpx.Client(timeout=3.0) as c:
|
||||||
if not config_path.exists():
|
return c.get(f"{url}{path}").status_code < 500
|
||||||
return 0
|
except httpx.HTTPError:
|
||||||
try:
|
return False
|
||||||
from ruamel.yaml import YAML
|
|
||||||
r_yaml = YAML()
|
|
||||||
with config_path.open("r", encoding="utf-8") as f:
|
def _count_enabled_mcp_servers() -> int:
|
||||||
cfg = r_yaml.load(f) or {}
|
config_path = HERMES_HOME / "config.yaml"
|
||||||
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
|
if not config_path.exists():
|
||||||
if not isinstance(mcp_servers, dict):
|
return 0
|
||||||
return 0
|
try:
|
||||||
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
|
from ruamel.yaml import YAML
|
||||||
except Exception:
|
r_yaml = YAML()
|
||||||
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
|
with config_path.open("r", encoding="utf-8") as f:
|
||||||
return 0
|
cfg = r_yaml.load(f) or {}
|
||||||
|
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
|
||||||
|
if not isinstance(mcp_servers, dict):
|
||||||
def agent_status() -> dict:
|
return 0
|
||||||
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
|
||||||
home = HERMES_HOME
|
except Exception:
|
||||||
brain_model = "auto"
|
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
|
||||||
config_path = home / "config.yaml"
|
return 0
|
||||||
if config_path.exists():
|
|
||||||
try:
|
|
||||||
from ruamel.yaml import YAML
|
def agent_status() -> dict:
|
||||||
r_yaml = YAML()
|
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
||||||
with config_path.open("r", encoding="utf-8") as f:
|
home = HERMES_HOME
|
||||||
cfg = r_yaml.load(f) or {}
|
brain_model = "auto"
|
||||||
if isinstance(cfg, dict):
|
config_path = home / "config.yaml"
|
||||||
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
|
if config_path.exists():
|
||||||
m = cfg.get("model", {}) or {}
|
try:
|
||||||
brain_model = m.get("default") or m.get("model") or "auto"
|
from ruamel.yaml import YAML
|
||||||
except Exception:
|
r_yaml = YAML()
|
||||||
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
with config_path.open("r", encoding="utf-8") as f:
|
||||||
|
cfg = r_yaml.load(f) or {}
|
||||||
|
if isinstance(cfg, dict):
|
||||||
return {
|
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
|
||||||
"gateway_url": HERMES_API_URL,
|
m = cfg.get("model", {}) or {}
|
||||||
# Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2.
|
brain_model = m.get("default") or m.get("model") or "auto"
|
||||||
"terminal_url": HERMES_TERMINAL_URL,
|
except Exception:
|
||||||
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
||||||
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"),
|
|
||||||
"home_exists": home.exists(),
|
|
||||||
"brain_model": brain_model,
|
return {
|
||||||
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
|
"gateway_url": HERMES_API_URL,
|
||||||
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
|
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
|
||||||
"has_skills": (home / "skills").exists(),
|
"box_console_url": BOX_CONSOLE_PATH,
|
||||||
"has_memories": (home / "memories").exists(),
|
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
|
||||||
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
|
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
|
||||||
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
|
||||||
"mcp_server_count": _count_enabled_mcp_servers(),
|
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
||||||
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
|
||||||
}
|
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
|
||||||
|
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
|
||||||
|
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
|
||||||
def set_agent_brain(model_id: str) -> dict:
|
"home_exists": home.exists(),
|
||||||
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
|
"brain_model": brain_model,
|
||||||
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
|
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
|
||||||
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
|
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
|
||||||
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
|
"has_skills": (home / "skills").exists(),
|
||||||
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
|
"has_memories": (home / "memories").exists(),
|
||||||
from services import llamaswap
|
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
|
||||||
models = {m["name"]: m for m in llamaswap.list_models()}
|
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
||||||
if model_id not in models:
|
"mcp_server_count": _count_enabled_mcp_servers(),
|
||||||
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."}
|
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
||||||
old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
|
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
|
||||||
if model_id == old:
|
"pc_executor_url": PC_EXECUTOR_URL,
|
||||||
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
|
}
|
||||||
try:
|
|
||||||
from services.llamaswap import set_ttl
|
|
||||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
def set_agent_brain(model_id: str) -> dict:
|
||||||
if model_id not in brains:
|
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
|
||||||
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
|
||||||
set_ttl(model_id, 0)
|
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
|
||||||
except PermissionError as exc:
|
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
|
||||||
return {"ok": False, "reason": str(exc)}
|
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
|
||||||
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
from services import llamaswap
|
||||||
try:
|
models = {m["name"]: m for m in llamaswap.list_models()}
|
||||||
llamaswap.set_role(model_id, "hermes") # 1) Alias
|
if model_id not in models:
|
||||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."}
|
||||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None)
|
||||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
if model_id == old:
|
||||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
|
||||||
from services.llamaswap import set_ttl, DEFAULT_TTL
|
try:
|
||||||
set_ttl(model_id, 0)
|
from services.llamaswap import set_ttl
|
||||||
if old:
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||||
set_ttl(old, DEFAULT_TTL)
|
if model_id not in brains:
|
||||||
except PermissionError as exc:
|
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
||||||
return {"ok": False, "reason": str(exc)}
|
set_ttl(model_id, 0)
|
||||||
update_brain_model("hermes") # 3) Config + Restart
|
except PermissionError as exc:
|
||||||
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
|
return {"ok": False, "reason": str(exc)}
|
||||||
warning = None
|
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
||||||
try:
|
try:
|
||||||
b = hermes_brain_info().get("budget") or {}
|
llamaswap.set_role(model_id, "hermes") # 1) Alias
|
||||||
if b and not b.get("fits", True):
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||||
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
|
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||||
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
|
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||||
f"(~{b.get('gtt_gb')} GB) — heavy/coder würden das Hirn verdrängen.")
|
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||||
except Exception:
|
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||||
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
|
set_ttl(model_id, 0)
|
||||||
return {"ok": True, "old": old, "new": model_id, "warning": warning}
|
if old:
|
||||||
|
set_ttl(old, DEFAULT_TTL)
|
||||||
|
except PermissionError as exc:
|
||||||
def update_brain_model(new_model: str) -> bool:
|
return {"ok": False, "reason": str(exc)}
|
||||||
from config import HERMES_HOME
|
update_brain_model("hermes") # 3) Config + Restart
|
||||||
home = HERMES_HOME
|
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
|
||||||
config_path = home / "config.yaml"
|
warning = None
|
||||||
|
try:
|
||||||
# Ensure home directory exists
|
b = hermes_brain_info().get("budget") or {}
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
if b and not b.get("fits", True):
|
||||||
|
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
|
||||||
cfg = {}
|
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
|
||||||
if config_path.exists():
|
f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
|
||||||
try:
|
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
|
||||||
from ruamel.yaml import YAML
|
except Exception:
|
||||||
r_yaml = YAML()
|
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
|
||||||
with config_path.open("r", encoding="utf-8") as f:
|
return {"ok": True, "old": old, "new": model_id, "warning": warning}
|
||||||
cfg = r_yaml.load(f) or {}
|
|
||||||
except Exception:
|
|
||||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
def update_brain_model(new_model: str) -> bool:
|
||||||
cfg = {}
|
from config import HERMES_HOME
|
||||||
|
home = HERMES_HOME
|
||||||
if not isinstance(cfg, dict):
|
config_path = home / "config.yaml"
|
||||||
cfg = {}
|
|
||||||
|
# Ensure home directory exists
|
||||||
if "model" not in cfg or not isinstance(cfg["model"], dict):
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
cfg["model"] = {}
|
|
||||||
|
cfg = {}
|
||||||
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
|
if config_path.exists():
|
||||||
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
|
try:
|
||||||
cfg["model"]["default"] = new_model
|
from ruamel.yaml import YAML
|
||||||
cfg["model"]["model"] = new_model
|
r_yaml = YAML()
|
||||||
|
with config_path.open("r", encoding="utf-8") as f:
|
||||||
try:
|
cfg = r_yaml.load(f) or {}
|
||||||
from ruamel.yaml import YAML
|
except Exception:
|
||||||
r_yaml = YAML()
|
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
||||||
with config_path.open("w", encoding="utf-8") as f:
|
cfg = {}
|
||||||
r_yaml.dump(cfg, f)
|
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
# Restart the user-space service to apply changes
|
cfg = {}
|
||||||
try:
|
|
||||||
import services.maintenance as maintenance
|
if "model" not in cfg or not isinstance(cfg["model"], dict):
|
||||||
maintenance.restart_service("hermes-gateway")
|
cfg["model"] = {}
|
||||||
except Exception:
|
|
||||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
|
||||||
|
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
|
||||||
return True
|
cfg["model"]["default"] = new_model
|
||||||
except Exception:
|
cfg["model"]["model"] = new_model
|
||||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
|
||||||
return False
|
try:
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
r_yaml = YAML()
|
||||||
|
with config_path.open("w", encoding="utf-8") as f:
|
||||||
|
r_yaml.dump(cfg, f)
|
||||||
|
|
||||||
|
# Restart the user-space service to apply changes
|
||||||
|
try:
|
||||||
|
from services import maintenance
|
||||||
|
maintenance.restart_service("hermes-gateway")
|
||||||
|
except Exception:
|
||||||
|
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -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,525 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
import urllib.request
|
||||||
|
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
|
||||||
|
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
|
||||||
|
|
||||||
|
def _gitea_creds() -> tuple | None:
|
||||||
|
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
p = subprocess.run(
|
||||||
|
["git", "credential", "fill"],
|
||||||
|
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
|
||||||
|
capture_output=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
out = p.stdout.decode(errors="replace")
|
||||||
|
u_match = re.search(r"username=(.+)", out)
|
||||||
|
p_match = re.search(r"password=(.+)", out)
|
||||||
|
if u_match and p_match:
|
||||||
|
return u_match.group(1).strip(), p_match.group(1).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
|
||||||
|
try:
|
||||||
|
cred = Path("~/.git-credentials").expanduser()
|
||||||
|
for line in cred.read_text(encoding="utf-8").splitlines():
|
||||||
|
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
|
||||||
|
if m: return m.group(1), m.group(2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
|
||||||
|
# Nur auf der Box (wo creds liegen) sinnvoll
|
||||||
|
creds = _gitea_creds()
|
||||||
|
if not creds: return None
|
||||||
|
_user, token = creds
|
||||||
|
cache_key = (repo, branch, head_sha)
|
||||||
|
cached = _CI_CACHE.get(cache_key)
|
||||||
|
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
|
||||||
|
return cached["status"]
|
||||||
|
|
||||||
|
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
|
||||||
|
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as r:
|
||||||
|
data = json.load(r)
|
||||||
|
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
|
||||||
|
if runs:
|
||||||
|
run = runs[0]
|
||||||
|
if run.get("head_sha", "").startswith(head_sha[:7]):
|
||||||
|
st = run.get("status")
|
||||||
|
if st:
|
||||||
|
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
|
||||||
|
return st
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
|
||||||
|
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else 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,
|
||||||
|
"ci_status": ci_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
|
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]:
|
def _components(tarball: Path) -> list[str]:
|
||||||
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
||||||
try:
|
try:
|
||||||
@@ -46,7 +51,7 @@ def backup_now() -> dict:
|
|||||||
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
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)}
|
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
||||||
|
|
||||||
latest = _latest()
|
latest = _latest()
|
||||||
|
|||||||
+205
-143
@@ -1,143 +1,205 @@
|
|||||||
"""
|
"""
|
||||||
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
|
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
|
||||||
|
|
||||||
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, Ein-Gruppen-
|
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB):
|
||||||
Residenz, GTT ~124 GB):
|
• Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident —
|
||||||
• Das Agent-Hirn (Rolle `hermes`) ist IMMER resident.
|
seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist`
|
||||||
• Weitere persist-Mitglieder (fast/vision) dürfen verdrängt werden, wenn ein großes
|
in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last
|
||||||
on-demand-Modell lädt.
|
verdrängte damals die ganze Gruppe).
|
||||||
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss —
|
• Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss
|
||||||
und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein).
|
deren Footprint mit einplanen.
|
||||||
|
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss —
|
||||||
Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine
|
und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein).
|
||||||
ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides.
|
|
||||||
"""
|
Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine
|
||||||
|
ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides.
|
||||||
import re
|
"""
|
||||||
|
|
||||||
import psutil
|
import re
|
||||||
|
|
||||||
from services.fit import (
|
import psutil
|
||||||
QUANT_BYTES_PER_PARAM,
|
|
||||||
estimate_memory_gb,
|
from services.fit import (
|
||||||
extract_params_b,
|
QUANT_BYTES_PER_PARAM,
|
||||||
max_ctx_in_budget,
|
estimate_memory_gb,
|
||||||
)
|
extract_params_b,
|
||||||
|
max_ctx_in_budget,
|
||||||
HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung
|
)
|
||||||
|
|
||||||
|
HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung
|
||||||
def gtt_budget_gb() -> float:
|
|
||||||
"""GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest
|
|
||||||
amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve."""
|
def gtt_budget_gb() -> float:
|
||||||
try:
|
"""GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest
|
||||||
with open("/proc/cmdline") as f:
|
amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve."""
|
||||||
m = re.search(r"amdgpu\.gttsize=(\d+)", f.read())
|
try:
|
||||||
if m:
|
with open("/proc/cmdline") as f:
|
||||||
return round(int(m.group(1)) / 1024.0, 1)
|
m = re.search(r"amdgpu\.gttsize=(\d+)", f.read())
|
||||||
except Exception:
|
if m:
|
||||||
pass
|
return round(int(m.group(1)) / 1024.0, 1)
|
||||||
return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1)
|
except Exception:
|
||||||
|
pass
|
||||||
|
return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1)
|
||||||
def params_of_model(model: dict) -> float:
|
|
||||||
"""Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und
|
|
||||||
Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs
|
def params_of_model(model: dict) -> float:
|
||||||
(size_bytes = nur erster Teil → ignoriert) ab."""
|
"""Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und
|
||||||
caps = model.get("capabilities") or {}
|
Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs
|
||||||
quant = model.get("quant") or "Q4_K_M"
|
(size_bytes = nur erster Teil → ignoriert) ab."""
|
||||||
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
caps = model.get("capabilities") or {}
|
||||||
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
quant = model.get("quant") or "Q4_K_M"
|
||||||
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
|
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||||
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
|
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||||
|
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
|
||||||
|
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
|
||||||
def footprint_gb(model: dict) -> float:
|
|
||||||
"""Loaded-Footprint eines Modells = Gewichte + kalibrierter KV-Anteil (bei seinem
|
|
||||||
aktuellen ctx)."""
|
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
|
||||||
quant = model.get("quant") or "Q4_K_M"
|
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
|
||||||
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)
|
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
|
||||||
pb = params_of_model(model)
|
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
|
||||||
weights = max(pb * bpp, size_gb)
|
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
|
||||||
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
|
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
|
||||||
return weights + max(kv, 0.0)
|
return ck, cv
|
||||||
|
|
||||||
|
|
||||||
def params_b_for(name: str) -> float:
|
def _real_kv_gb(model: dict, ctx: int) -> float | None:
|
||||||
"""Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst,
|
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
|
||||||
sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe."""
|
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
|
||||||
from services import catalog
|
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
|
||||||
meta = catalog.meta_for_name(name) if name else None
|
from services import gguf_meta
|
||||||
if meta and meta.get("total_params_b"):
|
path = model.get("gguf_path")
|
||||||
return float(meta["total_params_b"])
|
if not path:
|
||||||
return extract_params_b(name)
|
return None
|
||||||
|
meta = gguf_meta.arch_meta(path)
|
||||||
|
if not meta:
|
||||||
def _coresident_members(groups: dict) -> set:
|
return None
|
||||||
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
|
ck, cv = _cache_types(model.get("cmd") or "")
|
||||||
(Ko-Residenz, z.B. brains = Hirn+embed+vision). Ein Modell AUSSERHALB dieser Gruppen
|
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
|
||||||
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)."""
|
|
||||||
out: set = set()
|
def footprint_gb(model: dict) -> float:
|
||||||
for g in (groups or {}).values():
|
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
|
||||||
if isinstance(g, dict) and g.get("swap") is False:
|
KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
|
||||||
out.update(g.get("members") or [])
|
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
|
||||||
return out
|
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)
|
||||||
def reserved_gb(role: str | None) -> dict:
|
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||||
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
|
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||||
VERIFIZIERTEN llama-swap-Gruppen-Swap-Semantik (NICHT der früheren Annahme „Hirn bleibt
|
pb = params_of_model(model)
|
||||||
immer"). Nur die ko-residente `swap:false`-Gruppe läuft gemeinsam; ein on-demand-Modell
|
weights = max(pb * bpp, size_gb)
|
||||||
verdrängt die Gruppe und läuft ALLEIN mit dem vollen GTT.
|
kv = _real_kv_gb(model, ctx)
|
||||||
|
if kv is None:
|
||||||
- Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
|
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
|
||||||
Gruppen-Mitgliedern → reserviert deren Summe.
|
return weights + max(kv, 0.0)
|
||||||
- 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.
|
|
||||||
"""
|
def params_b_for(name: str) -> float:
|
||||||
from services import llamaswap
|
"""Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst,
|
||||||
models = llamaswap.list_models()
|
sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe."""
|
||||||
groups = llamaswap.list_groups()
|
from services import catalog
|
||||||
cores = _coresident_members(groups)
|
meta = catalog.meta_for_name(name) if name else None
|
||||||
brain = next((m for m in models if (m.get("role") == "hermes")), None)
|
if meta and meta.get("total_params_b"):
|
||||||
brain_gb = footprint_gb(brain) if brain else 0.0
|
return float(meta["total_params_b"])
|
||||||
role = (role or "").strip().lower()
|
return extract_params_b(name)
|
||||||
|
|
||||||
holder = next((m for m in models if (m.get("role") == role)), None) if role else None
|
|
||||||
holder_name = holder["name"] if holder else None
|
def _coresident_members(groups: dict) -> set:
|
||||||
# Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen.
|
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
|
||||||
in_group = role == "hermes" or bool(holder_name and holder_name in cores)
|
(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
|
||||||
if in_group:
|
Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere
|
||||||
others = sum(footprint_gb(m) for m in models
|
Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key."""
|
||||||
if m["name"] in cores and m["name"] != holder_name)
|
out: set = set()
|
||||||
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
|
for g in (groups or {}).values():
|
||||||
# on-demand: verdrängt die Ko-Residenz-Gruppe → läuft allein, voller GTT für Kontext.
|
if isinstance(g, dict) and g.get("swap") is False:
|
||||||
return {"reserved_gb": 0.0, "mode": "ondemand-alone", "brain_gb": brain_gb}
|
out.update(g.get("members") or [])
|
||||||
|
return out
|
||||||
|
|
||||||
def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict:
|
|
||||||
"""Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem
|
def reserved_gb(role: str | None) -> dict:
|
||||||
bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz)."""
|
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
|
||||||
gtt = gtt_budget_gb()
|
seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente
|
||||||
r = reserved_gb(role)
|
`swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben.
|
||||||
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
|
||||||
ctx = max_ctx_in_budget(params_b, quant, budget)
|
- Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
|
||||||
return {
|
Gruppen-Mitgliedern → reserviert deren Summe.
|
||||||
"ctx": ctx,
|
- Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe →
|
||||||
"gtt_gb": gtt,
|
reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die
|
||||||
"reserved_gb": round(r["reserved_gb"], 1),
|
Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte).
|
||||||
"budget_gb": round(budget, 1),
|
"""
|
||||||
"mode": r["mode"],
|
from services import llamaswap
|
||||||
}
|
models = llamaswap.list_models()
|
||||||
|
groups = llamaswap.list_groups()
|
||||||
|
cores = _coresident_members(groups)
|
||||||
def setup_aware_ctx_for_model(model: dict) -> dict:
|
brain = next((m for m in models if (m.get("role") == "hermes")), None)
|
||||||
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell (aus seiner Rolle,
|
brain_gb = footprint_gb(brain) if brain else 0.0
|
||||||
Params & Quant). Für den 'Auto'-Button an der Modellkarte."""
|
role = (role or "").strip().lower()
|
||||||
return setup_aware_ctx(params_of_model(model),
|
|
||||||
model.get("quant") or "Q4_K_M",
|
holder = next((m for m in models if (m.get("role") == role)), None) if role else None
|
||||||
role=model.get("role"))
|
holder_name = holder["name"] if holder else None
|
||||||
|
# Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen.
|
||||||
|
in_group = role == "hermes" or bool(holder_name and holder_name in cores)
|
||||||
|
|
||||||
|
if in_group:
|
||||||
|
others = sum(footprint_gb(m) for m in models
|
||||||
|
if m["name"] in cores and m["name"] != holder_name)
|
||||||
|
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
|
||||||
|
# on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren.
|
||||||
|
warm = sum(footprint_gb(m) for m in models if m["name"] in cores)
|
||||||
|
return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict:
|
||||||
|
"""Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem
|
||||||
|
bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz)."""
|
||||||
|
gtt = gtt_budget_gb()
|
||||||
|
r = reserved_gb(role)
|
||||||
|
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
||||||
|
ctx = max_ctx_in_budget(params_b, quant, budget)
|
||||||
|
return {
|
||||||
|
"ctx": ctx,
|
||||||
|
"gtt_gb": gtt,
|
||||||
|
"reserved_gb": round(r["reserved_gb"], 1),
|
||||||
|
"budget_gb": round(budget, 1),
|
||||||
|
"mode": r["mode"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
|
||||||
|
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
|
||||||
|
from services.fit import _NICE_CTX
|
||||||
|
if cap:
|
||||||
|
raw_ctx = min(raw_ctx, cap)
|
||||||
|
best = _NICE_CTX[0]
|
||||||
|
for c in _NICE_CTX:
|
||||||
|
if c <= raw_ctx:
|
||||||
|
best = c
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def setup_aware_ctx_for_model(model: dict) -> dict:
|
||||||
|
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
|
||||||
|
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
|
||||||
|
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
|
||||||
|
from services import gguf_meta
|
||||||
|
quant = model.get("quant") or "Q4_K_M"
|
||||||
|
path = model.get("gguf_path")
|
||||||
|
meta = gguf_meta.arch_meta(path) if path else None
|
||||||
|
if not meta:
|
||||||
|
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
|
||||||
|
|
||||||
|
gtt = gtt_budget_gb()
|
||||||
|
r = reserved_gb(model.get("role"))
|
||||||
|
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
||||||
|
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||||
|
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||||
|
weights = max(params_of_model(model) * bpp, size_gb)
|
||||||
|
ck, cv = _cache_types(model.get("cmd") or "")
|
||||||
|
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
|
||||||
|
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
|
||||||
|
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
|
||||||
|
"budget_gb": round(budget, 1), "mode": r["mode"]}
|
||||||
|
|||||||
+74
-72
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
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
|
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
|
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.
|
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 json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
||||||
from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
|
|
||||||
|
|
||||||
DEFAULT_HOST = "192.168.178.151"
|
DEFAULT_HOST = "192.168.178.151"
|
||||||
|
|
||||||
# IDEs bekommen NUR die 'coding'-Lane zu sehen — die Lane routet intern selbst auf das
|
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
||||||
# passende Modell (Coder/Heavy/…). Ein einziger Eintrag, kein manuelles Modell-Wählen mehr.
|
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
||||||
IDE_MODEL = "coding"
|
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:
|
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"
|
return f"http://{host}:{PORT}/v1"
|
||||||
|
|
||||||
|
|
||||||
@@ -31,56 +46,31 @@ def build_snippets(host: str = DEFAULT_HOST,
|
|||||||
gw = _gw(host)
|
gw = _gw(host)
|
||||||
mc_url = f"http://{host}:{PORT}"
|
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",
|
"apiProvider": "openai",
|
||||||
"openAiBaseUrl": gw,
|
"openAiBaseUrl": gw,
|
||||||
"openAiApiKey": "local",
|
"openAiApiKey": "local",
|
||||||
"openAiModelId": "coding",
|
"openAiModelId": PRIMARY_MODEL,
|
||||||
}, indent=2)
|
}, indent=2)
|
||||||
|
|
||||||
opencode = json.dumps({
|
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
||||||
"provider": {
|
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
||||||
"bosgame": {
|
hermes_desktop = (
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
||||||
"name": "Bosgame Gateway",
|
f"#\n"\
|
||||||
"options": {"baseURL": gw, "apiKey": "local"},
|
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
||||||
"models": {IDE_MODEL: {"name": IDE_MODEL}},
|
f"# (MC2-Proxy auf dem Gateway)\n"\
|
||||||
}
|
f"#\n"\
|
||||||
}
|
f"# 2. Session-Token: auf der Box liegen in\n"\
|
||||||
}, indent=2)
|
f"# ~/.hermes/desktop-gateway-token\n"\
|
||||||
|
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
||||||
cursor = json.dumps({
|
f"#\n"\
|
||||||
"Base URL": gw,
|
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
||||||
"API Key": "local",
|
f"#\n"\
|
||||||
"Active Model": "coding"
|
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
||||||
}, 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)
|
|
||||||
|
|
||||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
||||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
# 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"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
||||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
||||||
f"# • oder LiteLLM mit /v1/messages-Bridge\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"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
||||||
f"\n"
|
f"\n"
|
||||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
||||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
||||||
f'export ANTHROPIC_MODEL="coding"'
|
f'export ANTHROPIC_MODEL="coder"'
|
||||||
)
|
)
|
||||||
|
|
||||||
memory_mcp = json.dumps({
|
memory_mcp = json.dumps({
|
||||||
@@ -112,20 +102,21 @@ def build_snippets(host: str = DEFAULT_HOST,
|
|||||||
"host": host,
|
"host": host,
|
||||||
"gateway_url": gw,
|
"gateway_url": gw,
|
||||||
"mc_url": mc_url,
|
"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": {
|
"tools": {
|
||||||
"cline": {"label": "Roo Code / Cline", "lang": "json", "snippet": cline,
|
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
||||||
"note": "OpenAI-Provider → Gateway. Nur Lane 'coding' — die Box routet intern selbst."},
|
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
||||||
"cursor": {"label": "Cursor", "lang": "json", "snippet": cursor,
|
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
||||||
"note": "Einstellungen ➔ Models ➔ OpenAI API key + Base URL."},
|
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
||||||
"opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode,
|
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
||||||
"note": "Datei opencode.jsonc, Key 'provider'."},
|
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
||||||
"zed": {"label": "Zed", "lang": "json", "snippet": zed,
|
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
||||||
"note": "settings.json → language_models.openai_compatible."},
|
|
||||||
"continue": {"label": "Continue", "lang": "json", "snippet": cont,
|
|
||||||
"note": "~/.continue/config.json (oder config.yaml mit identischen Keys)."},
|
|
||||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
"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.
|
# 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,
|
"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:
|
def check_health() -> dict:
|
||||||
"""Live-Erreichbarkeit der beiden Leitungen, aus Sicht der Box:
|
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
||||||
Leitung 1 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0)."""
|
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"}
|
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=3.0) as c:
|
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:
|
if r.status_code == 200:
|
||||||
n = len(r.json().get("data", []))
|
n = len(r.json().get("data", []))
|
||||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||||
else:
|
else:
|
||||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||||
@@ -154,7 +147,16 @@ def check_health() -> dict:
|
|||||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
||||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
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}
|
||||||
|
|||||||
+178
-179
@@ -1,179 +1,178 @@
|
|||||||
"""
|
"""
|
||||||
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
|
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
|
||||||
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
|
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
|
||||||
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
|
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
|
||||||
|
|
||||||
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
|
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
|
||||||
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
|
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
|
||||||
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
|
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import logging
|
||||||
import os
|
import math
|
||||||
import time
|
import os
|
||||||
from datetime import datetime
|
import time
|
||||||
|
from datetime import datetime
|
||||||
import httpx
|
|
||||||
|
import httpx
|
||||||
import logging
|
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||||
|
|
||||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
from services import catalog
|
||||||
from services import catalog
|
from services.caps import capabilities
|
||||||
from services.caps import capabilities
|
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
||||||
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
|
||||||
|
|
||||||
|
def _categorize(repo_id: str) -> str:
|
||||||
def _categorize(repo_id: str) -> str:
|
low = repo_id.lower()
|
||||||
low = repo_id.lower()
|
for cat in CATEGORIES:
|
||||||
for cat in CATEGORIES:
|
if any(k in low for k in cat["kw"]):
|
||||||
if any(k in low for k in cat["kw"]):
|
return cat["role"]
|
||||||
return cat["role"]
|
return "scout"
|
||||||
return "scout"
|
|
||||||
|
|
||||||
|
def _fetch_author_models(author: str) -> list:
|
||||||
def _fetch_author_models(author: str) -> list:
|
url = (f"https://huggingface.co/api/models?author={author}"
|
||||||
url = (f"https://huggingface.co/api/models?author={author}"
|
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
|
||||||
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
|
try:
|
||||||
try:
|
with httpx.Client(timeout=12.0) as c:
|
||||||
with httpx.Client(timeout=12.0) as c:
|
data = c.get(url).json()
|
||||||
data = c.get(url).json()
|
return data if isinstance(data, list) else []
|
||||||
return data if isinstance(data, list) else []
|
except Exception:
|
||||||
except Exception:
|
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
|
||||||
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
|
return []
|
||||||
return []
|
|
||||||
|
|
||||||
|
def _age_days(last_modified, now_ts: float) -> float:
|
||||||
def _age_days(last_modified, now_ts: float) -> float:
|
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
|
||||||
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
|
if not last_modified:
|
||||||
if not last_modified:
|
return 540.0
|
||||||
return 540.0
|
try:
|
||||||
try:
|
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
|
||||||
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
|
return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
|
||||||
return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
|
except Exception:
|
||||||
except Exception:
|
return 540.0
|
||||||
return 540.0
|
|
||||||
|
|
||||||
|
def _score(m: dict, now_ts: float) -> float:
|
||||||
def _score(m: dict, now_ts: float) -> float:
|
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
|
||||||
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
|
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
|
||||||
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
|
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
|
||||||
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
|
- Capability: mehr Parameter (log-skaliert),
|
||||||
- Capability: mehr Parameter (log-skaliert),
|
- Popularity: Downloads (log-skaliert).
|
||||||
- Popularity: Downloads (log-skaliert).
|
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor
|
||||||
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor
|
Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen."""
|
||||||
Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen."""
|
fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
|
||||||
fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
|
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0)
|
||||||
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0)
|
cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0
|
||||||
cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0
|
pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0
|
||||||
pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0
|
return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
|
||||||
return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
|
|
||||||
|
|
||||||
|
def rank_runnable(models: list[dict]) -> list[dict]:
|
||||||
def rank_runnable(models: list[dict]) -> list[dict]:
|
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware.
|
||||||
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware.
|
Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
|
||||||
Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
|
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
|
||||||
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
|
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
|
||||||
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
|
now_ts = time.time()
|
||||||
now_ts = time.time()
|
return sorted(
|
||||||
return sorted(
|
[m for m in models if m["fit"]["level"] != "too_tight"],
|
||||||
[m for m in models if m["fit"]["level"] != "too_tight"],
|
key=lambda m: -_score(m, now_ts),
|
||||||
key=lambda m: -_score(m, now_ts),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
def refresh_discover(ram_gb: float) -> dict:
|
||||||
def refresh_discover(ram_gb: float) -> dict:
|
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
|
||||||
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
|
Quelle erreichbar war."""
|
||||||
Quelle erreichbar war."""
|
raw, seen, ok = [], set(), 0
|
||||||
raw, seen, ok = [], set(), 0
|
for author in TRUSTED_AUTHORS:
|
||||||
for author in TRUSTED_AUTHORS:
|
models = _fetch_author_models(author)
|
||||||
models = _fetch_author_models(author)
|
if models:
|
||||||
if models:
|
ok += 1
|
||||||
ok += 1
|
for m in models:
|
||||||
for m in models:
|
rid = m.get("id")
|
||||||
rid = m.get("id")
|
if not rid or rid in seen:
|
||||||
if not rid or rid in seen:
|
continue
|
||||||
continue
|
seen.add(rid)
|
||||||
seen.add(rid)
|
raw.append(m)
|
||||||
raw.append(m)
|
if ok == 0 and not catalog.entries():
|
||||||
if ok == 0 and not catalog.entries():
|
raise RuntimeError("Keine Quelle erreichbar.")
|
||||||
raise RuntimeError("Keine Quelle erreichbar.")
|
|
||||||
|
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
|
||||||
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
|
for m in raw:
|
||||||
for m in raw:
|
rid = m["id"]
|
||||||
rid = m["id"]
|
low = rid.lower()
|
||||||
low = rid.lower()
|
if any(tok in low for tok in SKIP_TOKENS):
|
||||||
if any(tok in low for tok in SKIP_TOKENS):
|
continue
|
||||||
continue
|
role = _categorize(rid)
|
||||||
role = _categorize(rid)
|
params_b = extract_params_b(rid)
|
||||||
params_b = extract_params_b(rid)
|
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
|
||||||
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
|
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
|
||||||
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
|
tags = [str(t) for t in (m.get("tags") or [])]
|
||||||
tags = [str(t) for t in (m.get("tags") or [])]
|
by_cat[role].append({
|
||||||
by_cat[role].append({
|
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
|
||||||
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
|
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
|
||||||
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
|
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
|
||||||
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
|
"lastModified": m.get("lastModified"),
|
||||||
"lastModified": m.get("lastModified"),
|
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
|
||||||
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
|
"caps": capabilities(name=rid, hf={"tags": tags}),
|
||||||
"caps": capabilities(name=rid, hf={"tags": tags}),
|
})
|
||||||
})
|
|
||||||
|
cats = []
|
||||||
cats = []
|
for c in CATEGORIES:
|
||||||
for c in CATEGORIES:
|
role = c["role"]
|
||||||
role = c["role"]
|
# 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
|
||||||
# 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) —
|
# macht die Empfehlung präzise statt Namens-Raterei.
|
||||||
# macht die Empfehlung präzise statt Namens-Raterei.
|
cat_entries = sorted(catalog.entries_for_role(role),
|
||||||
cat_entries = sorted(catalog.entries_for_role(role),
|
key=lambda e: -catalog.stack_score(e, ram_gb))
|
||||||
key=lambda e: -catalog.stack_score(e, ram_gb))
|
cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
|
||||||
cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries)
|
if m["fit"]["level"] != "too_tight"]
|
||||||
if m["fit"]["level"] != "too_tight"]
|
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
|
||||||
# 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert.
|
hf_ranked = rank_runnable(by_cat[role])
|
||||||
hf_ranked = rank_runnable(by_cat[role])
|
seen = {catalog._norm(m["repo"]) for m in cat_models}
|
||||||
seen = {catalog._norm(m["repo"]) for m in cat_models}
|
extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
|
||||||
extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen]
|
combined = cat_models + extra
|
||||||
combined = cat_models + extra
|
if combined:
|
||||||
if combined:
|
cats.append({
|
||||||
cats.append({
|
"role": role, "title": c["title"], "icon": c["icon"],
|
||||||
"role": role, "title": c["title"], "icon": c["icon"],
|
"models": combined[:6],
|
||||||
"models": combined[:6],
|
# Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
|
||||||
# Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle.
|
"recommended": (cat_models[0]["repo"] if cat_models
|
||||||
"recommended": (cat_models[0]["repo"] if cat_models
|
else (hf_ranked[0]["repo"] if hf_ranked else None)),
|
||||||
else (hf_ranked[0]["repo"] if hf_ranked else None)),
|
})
|
||||||
})
|
|
||||||
|
data = {"updated": time.time(), "categories": cats}
|
||||||
data = {"updated": time.time(), "categories": cats}
|
try:
|
||||||
try:
|
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
|
||||||
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
os.replace(tmp, DISCOVER_CACHE_PATH)
|
||||||
os.replace(tmp, DISCOVER_CACHE_PATH)
|
except Exception:
|
||||||
except Exception:
|
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
|
||||||
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
|
return data
|
||||||
return data
|
|
||||||
|
|
||||||
|
def load_discover() -> dict | None:
|
||||||
def load_discover() -> dict | None:
|
try:
|
||||||
try:
|
if DISCOVER_CACHE_PATH.exists():
|
||||||
if DISCOVER_CACHE_PATH.exists():
|
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
|
||||||
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
|
except Exception:
|
||||||
except Exception:
|
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
|
||||||
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
|
return None
|
||||||
return None
|
|
||||||
|
|
||||||
|
def safe_discover(ram_gb: float) -> dict | None:
|
||||||
def safe_discover(ram_gb: float) -> dict | None:
|
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
|
||||||
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
|
cached = load_discover()
|
||||||
cached = load_discover()
|
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
|
||||||
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
|
return cached
|
||||||
return cached
|
try:
|
||||||
try:
|
return refresh_discover(ram_gb)
|
||||||
return refresh_discover(ram_gb)
|
except Exception:
|
||||||
except Exception:
|
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
|
||||||
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
|
return cached
|
||||||
return cached
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
|
Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
|
||||||
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
|
Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
|
||||||
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
|
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.llamaswap import engine_reachable
|
||||||
from services.routing_policy import load_policy
|
from services.routing_policy import load_policy
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
|
|||||||
p = load_policy()
|
p = load_policy()
|
||||||
coding_default = p["coder_lite"] or p["coder"]
|
coding_default = p["coder_lite"] or p["coder"]
|
||||||
return {
|
return {
|
||||||
"mode": "builtin",
|
"mode": "gateway (eigener Prozess)" if V1_UPSTREAM else "builtin",
|
||||||
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
||||||
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
|
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
|
||||||
"lanes": [
|
"lanes": [
|
||||||
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def gateway_reachable() -> bool:
|
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()
|
return engine_reachable()
|
||||||
|
|||||||
@@ -1,41 +1,78 @@
|
|||||||
"""Token-Erfassung für den Builtin-Gateway.
|
"""Token-Erfassung für den Builtin-Gateway.
|
||||||
|
|
||||||
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
|
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
|
||||||
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
|
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
|
||||||
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
|
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from services.token_stats import increment_tokens
|
|
||||||
|
from services.token_stats import increment_tokens
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
def record_usage(usage: dict | None, model: str) -> None:
|
# Kontext-Limit-Warnung (User-Wunsch 15.07.: „es sollte eine Warnung geben — Kontext ist
|
||||||
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
|
# nicht unendlich"): finish_reason=length heißt, eine Antwort ist am Token-/Kontext-Budget
|
||||||
if not usage:
|
# ABGERISSEN — bei Nacht-Workern stirbt damit still die halbe Arbeit (4 Worker am 15.07.).
|
||||||
return
|
# Statt still: Eintrag in den Briefkasten (silent → Chronik/Panel, kein Sprach-Spam),
|
||||||
prompt = usage.get("prompt_tokens", 0)
|
# je Modell höchstens alle 10 min. Läuft im mc2-gateway-Prozess → announce liefert per
|
||||||
completion = usage.get("completion_tokens", 0)
|
# MC_ANNOUNCE_HTTP beim Steuerpult ab (Unit-Env), nie direkt in die Store-Datei.
|
||||||
if prompt or completion:
|
_trunc_last: dict[str, float] = {}
|
||||||
increment_tokens(prompt, completion, model=model)
|
_TRUNC_EVERY = 600.0 # s
|
||||||
|
|
||||||
|
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
|
||||||
def record_stream_chunk(chunk: bytes, model: str) -> None:
|
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
|
||||||
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
|
||||||
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
|
||||||
if b'"usage"' not in chunk:
|
_PING_MAXTOK = 16
|
||||||
return
|
|
||||||
text = chunk.decode("utf-8", errors="ignore")
|
|
||||||
for line in text.splitlines():
|
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
||||||
if not line.startswith("data:"):
|
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
|
||||||
continue
|
return
|
||||||
data_str = line[5:].strip()
|
now = time.time()
|
||||||
if not data_str or data_str == "[DONE]":
|
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
|
||||||
continue
|
return
|
||||||
try:
|
_trunc_last[model] = now
|
||||||
record_usage(json.loads(data_str).get("usage"), model)
|
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
|
||||||
except json.JSONDecodeError:
|
try:
|
||||||
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
|
from services import announce
|
||||||
|
announce.add(
|
||||||
|
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
|
||||||
|
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
||||||
|
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
|
||||||
|
"[Kontext-Limit]", "gateway", "silent")
|
||||||
|
except Exception:
|
||||||
|
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def record_usage(usage: dict | None, model: str) -> None:
|
||||||
|
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
|
||||||
|
if not usage:
|
||||||
|
return
|
||||||
|
prompt = usage.get("prompt_tokens", 0)
|
||||||
|
completion = usage.get("completion_tokens", 0)
|
||||||
|
if prompt or completion:
|
||||||
|
increment_tokens(prompt, completion, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
|
||||||
|
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
||||||
|
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
||||||
|
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
|
||||||
|
warn_truncation(model, max_tokens)
|
||||||
|
if b'"usage"' not in chunk:
|
||||||
|
return
|
||||||
|
text = chunk.decode("utf-8", errors="ignore")
|
||||||
|
for line in text.splitlines():
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
data_str = line[5:].strip()
|
||||||
|
if not data_str or data_str == "[DONE]":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record_usage(json.loads(data_str).get("usage"), model)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
|
||||||
|
|||||||
+266
-161
@@ -1,161 +1,266 @@
|
|||||||
"""
|
"""
|
||||||
GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem
|
GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem
|
||||||
GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell
|
GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell
|
||||||
**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative
|
**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative
|
||||||
Decoding in llama.cpp — sonst: "draft model vocab type must match target").
|
Decoding in llama.cpp — sonst: "draft model vocab type must match target").
|
||||||
|
|
||||||
Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald
|
Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald
|
||||||
`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab
|
`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab
|
||||||
identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten
|
identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten
|
||||||
Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung
|
Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung
|
||||||
beim Laden bleibt der letzte Schiedsrichter.
|
beim Laden bleibt der letzte Schiedsrichter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import struct
|
import struct
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md)
|
# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md)
|
||||||
_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \
|
_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \
|
||||||
_T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13)
|
_T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13)
|
||||||
|
|
||||||
_SCALAR_FMT = {
|
_SCALAR_FMT = {
|
||||||
_T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
|
_T_UINT8: "<B", _T_INT8: "<b", _T_UINT16: "<H", _T_INT16: "<h",
|
||||||
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
|
_T_UINT32: "<I", _T_INT32: "<i", _T_FLOAT32: "<f", _T_BOOL: "<?",
|
||||||
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
|
_T_UINT64: "<Q", _T_INT64: "<q", _T_FLOAT64: "<d",
|
||||||
}
|
}
|
||||||
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
|
_SCALAR_SIZE = {t: struct.calcsize(f) for t, f in _SCALAR_FMT.items()}
|
||||||
|
|
||||||
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
|
_WANT_STRINGS = {"tokenizer.ggml.model", "tokenizer.ggml.pre", "general.architecture"}
|
||||||
|
|
||||||
|
|
||||||
class _Reader:
|
class _Reader:
|
||||||
def __init__(self, f):
|
def __init__(self, f):
|
||||||
self.f = f
|
self.f = f
|
||||||
|
|
||||||
def read(self, n: int) -> bytes:
|
def read(self, n: int) -> bytes:
|
||||||
b = self.f.read(n)
|
b = self.f.read(n)
|
||||||
if len(b) != n:
|
if len(b) != n:
|
||||||
raise EOFError("unerwartetes Dateiende beim GGUF-Parsen")
|
raise EOFError("unerwartetes Dateiende beim GGUF-Parsen")
|
||||||
return b
|
return b
|
||||||
|
|
||||||
def u32(self) -> int:
|
def u32(self) -> int:
|
||||||
return struct.unpack("<I", self.read(4))[0]
|
return struct.unpack("<I", self.read(4))[0]
|
||||||
|
|
||||||
def u64(self) -> int:
|
def u64(self) -> int:
|
||||||
return struct.unpack("<Q", self.read(8))[0]
|
return struct.unpack("<Q", self.read(8))[0]
|
||||||
|
|
||||||
def gstr(self) -> str:
|
def gstr(self) -> str:
|
||||||
n = self.u64()
|
n = self.u64()
|
||||||
return self.read(n).decode("utf-8", "replace")
|
return self.read(n).decode("utf-8", "replace")
|
||||||
|
|
||||||
def skip_value(self, vtype: int) -> None:
|
def scalar(self, vtype: int):
|
||||||
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
|
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
|
||||||
weiterzuschieben). Arrays werden elementweise konsumiert."""
|
fmt = _SCALAR_FMT.get(vtype)
|
||||||
if vtype == _T_STRING:
|
if not fmt:
|
||||||
self.f.seek(self.u64(), 1)
|
self.skip_value(vtype)
|
||||||
elif vtype in _SCALAR_SIZE:
|
return None
|
||||||
self.f.seek(_SCALAR_SIZE[vtype], 1)
|
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
|
||||||
elif vtype == _T_ARRAY:
|
|
||||||
etype = self.u32()
|
def skip_value(self, vtype: int) -> None:
|
||||||
count = self.u64()
|
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
|
||||||
if etype == _T_STRING:
|
weiterzuschieben). Arrays werden elementweise konsumiert."""
|
||||||
for _ in range(count):
|
if vtype == _T_STRING:
|
||||||
self.f.seek(self.u64(), 1)
|
self.f.seek(self.u64(), 1)
|
||||||
elif etype in _SCALAR_SIZE:
|
elif vtype in _SCALAR_SIZE:
|
||||||
self.f.seek(_SCALAR_SIZE[etype] * count, 1)
|
self.f.seek(_SCALAR_SIZE[vtype], 1)
|
||||||
else:
|
elif vtype == _T_ARRAY:
|
||||||
raise ValueError(f"unbekannter Array-Elementtyp {etype}")
|
etype = self.u32()
|
||||||
else:
|
count = self.u64()
|
||||||
raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}")
|
if etype == _T_STRING:
|
||||||
|
for _ in range(count):
|
||||||
|
self.f.seek(self.u64(), 1)
|
||||||
def _read_fingerprint(path: str) -> dict | None:
|
elif etype in _SCALAR_SIZE:
|
||||||
"""Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF."""
|
self.f.seek(_SCALAR_SIZE[etype] * count, 1)
|
||||||
try:
|
else:
|
||||||
with open(path, "rb") as fh:
|
raise ValueError(f"unbekannter Array-Elementtyp {etype}")
|
||||||
r = _Reader(fh)
|
else:
|
||||||
if r.read(4) != b"GGUF":
|
raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}")
|
||||||
return None
|
|
||||||
r.u32() # version
|
|
||||||
r.u64() # tensor_count
|
def _read_fingerprint(path: str) -> dict | None:
|
||||||
kv_count = r.u64()
|
"""Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF."""
|
||||||
fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None,
|
try:
|
||||||
"tokens_sha": None}
|
with open(path, "rb") as fh:
|
||||||
for _ in range(kv_count):
|
r = _Reader(fh)
|
||||||
key = r.gstr()
|
if r.read(4) != b"GGUF":
|
||||||
vtype = r.u32()
|
return None
|
||||||
if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY:
|
r.u32() # version
|
||||||
etype = r.u32()
|
r.u64() # tensor_count
|
||||||
count = r.u64()
|
kv_count = r.u64()
|
||||||
fp["n_vocab"] = count
|
fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None,
|
||||||
if etype != _T_STRING:
|
"tokens_sha": None}
|
||||||
return None
|
for _ in range(kv_count):
|
||||||
# ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste
|
key = r.gstr()
|
||||||
# (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …).
|
vtype = r.u32()
|
||||||
h = hashlib.sha256()
|
if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY:
|
||||||
h.update(count.to_bytes(8, "little"))
|
etype = r.u32()
|
||||||
for _ in range(count):
|
count = r.u64()
|
||||||
n = r.u64()
|
fp["n_vocab"] = count
|
||||||
h.update(r.read(n))
|
if etype != _T_STRING:
|
||||||
fp["tokens_sha"] = h.hexdigest()
|
return None
|
||||||
# model/pre kommen vor tokens → wir haben alles. Abbrechen.
|
# ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste
|
||||||
break
|
# (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …).
|
||||||
if key in _WANT_STRINGS and vtype == _T_STRING:
|
h = hashlib.sha256()
|
||||||
val = r.gstr()
|
h.update(count.to_bytes(8, "little"))
|
||||||
if key == "tokenizer.ggml.model":
|
for _ in range(count):
|
||||||
fp["model"] = val
|
n = r.u64()
|
||||||
elif key == "tokenizer.ggml.pre":
|
h.update(r.read(n))
|
||||||
fp["pre"] = val
|
fp["tokens_sha"] = h.hexdigest()
|
||||||
else:
|
# model/pre kommen vor tokens → wir haben alles. Abbrechen.
|
||||||
fp["arch"] = val
|
break
|
||||||
else:
|
if key in _WANT_STRINGS and vtype == _T_STRING:
|
||||||
r.skip_value(vtype)
|
val = r.gstr()
|
||||||
if fp["model"] is None and fp["n_vocab"] is None:
|
if key == "tokenizer.ggml.model":
|
||||||
return None
|
fp["model"] = val
|
||||||
return fp
|
elif key == "tokenizer.ggml.pre":
|
||||||
except (OSError, EOFError, ValueError, struct.error):
|
fp["pre"] = val
|
||||||
return None
|
else:
|
||||||
|
fp["arch"] = val
|
||||||
|
else:
|
||||||
@lru_cache(maxsize=256)
|
r.skip_value(vtype)
|
||||||
def _cached(path: str, mtime: float, size: int) -> tuple | None:
|
if fp["model"] is None and fp["n_vocab"] is None:
|
||||||
fp = _read_fingerprint(path)
|
return None
|
||||||
if fp is None:
|
return fp
|
||||||
return None
|
except (OSError, EOFError, ValueError, struct.error):
|
||||||
return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha"))
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(path: str) -> dict | None:
|
@lru_cache(maxsize=256)
|
||||||
"""Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size).
|
def _cached(path: str, mtime: float, size: int) -> tuple | None:
|
||||||
Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar."""
|
fp = _read_fingerprint(path)
|
||||||
import os
|
if fp is None:
|
||||||
try:
|
return None
|
||||||
st = os.stat(path)
|
return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha"))
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
t = _cached(path, st.st_mtime, st.st_size)
|
def fingerprint(path: str) -> dict | None:
|
||||||
if t is None:
|
"""Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size).
|
||||||
return None
|
Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar."""
|
||||||
return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]}
|
import os
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
def vocab_key(path: str) -> tuple | None:
|
except OSError:
|
||||||
"""ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256
|
return None
|
||||||
der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht
|
t = _cached(path, st.st_mtime, st.st_size)
|
||||||
nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität
|
if t is None:
|
||||||
verlangt llama.cpp für Speculative Decoding."""
|
return None
|
||||||
fp = fingerprint(path)
|
return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]}
|
||||||
if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"):
|
|
||||||
return None
|
|
||||||
return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"])
|
def vocab_key(path: str) -> tuple | None:
|
||||||
|
"""ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256
|
||||||
|
der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht
|
||||||
def compatible(target_path: str, draft_path: str) -> bool | None:
|
nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität
|
||||||
"""True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar
|
verlangt llama.cpp für Speculative Decoding."""
|
||||||
(eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'."""
|
fp = fingerprint(path)
|
||||||
a = vocab_key(target_path)
|
if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"):
|
||||||
b = vocab_key(draft_path)
|
return None
|
||||||
if a is None or b is None:
|
return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"])
|
||||||
return None
|
|
||||||
return a == b
|
|
||||||
|
def compatible(target_path: str, draft_path: str) -> bool | None:
|
||||||
|
"""True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar
|
||||||
|
(eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'."""
|
||||||
|
a = vocab_key(target_path)
|
||||||
|
b = vocab_key(draft_path)
|
||||||
|
if a is None or b is None:
|
||||||
|
return None
|
||||||
|
return a == b
|
||||||
|
|
||||||
|
|
||||||
|
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
|
||||||
|
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
|
||||||
|
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
|
||||||
|
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
|
||||||
|
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
|
||||||
|
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
|
||||||
|
_ARCH_WANT = (
|
||||||
|
".block_count", ".attention.head_count_kv", ".attention.head_count",
|
||||||
|
".attention.key_length", ".attention.value_length", ".embedding_length",
|
||||||
|
".context_length",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_arch_meta(path: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
r = _Reader(fh)
|
||||||
|
if r.read(4) != b"GGUF":
|
||||||
|
return None
|
||||||
|
r.u32() # version
|
||||||
|
r.u64() # tensor_count
|
||||||
|
kv_count = r.u64()
|
||||||
|
raw: dict = {}
|
||||||
|
arch = None
|
||||||
|
for _ in range(kv_count):
|
||||||
|
key = r.gstr()
|
||||||
|
vtype = r.u32()
|
||||||
|
if key == "general.architecture" and vtype == _T_STRING:
|
||||||
|
arch = r.gstr()
|
||||||
|
continue
|
||||||
|
if key == "tokenizer.ggml.tokens":
|
||||||
|
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
|
||||||
|
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
|
||||||
|
if suf is not None and vtype in _SCALAR_SIZE:
|
||||||
|
raw[suf] = r.scalar(vtype)
|
||||||
|
else:
|
||||||
|
r.skip_value(vtype)
|
||||||
|
n_layers = raw.get(".block_count")
|
||||||
|
n_head = raw.get(".attention.head_count")
|
||||||
|
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
|
||||||
|
n_embd = raw.get(".embedding_length")
|
||||||
|
hd_k = raw.get(".attention.key_length") \
|
||||||
|
or (int(n_embd / n_head) if (n_embd and n_head) else None)
|
||||||
|
hd_v = raw.get(".attention.value_length") or hd_k
|
||||||
|
if not (n_layers and n_head_kv and hd_k and hd_v):
|
||||||
|
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
|
||||||
|
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
|
||||||
|
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
|
||||||
|
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
|
||||||
|
except (OSError, EOFError, ValueError, struct.error):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
|
||||||
|
return _read_arch_meta(path)
|
||||||
|
|
||||||
|
|
||||||
|
def arch_meta(path: str) -> dict | None:
|
||||||
|
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
|
||||||
|
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
|
||||||
|
oder unvollständig."""
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return _arch_cached(path, st.st_mtime, st.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
|
||||||
|
_KV_BPE = {
|
||||||
|
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
|
||||||
|
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
|
||||||
|
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
|
||||||
|
}
|
||||||
|
_GIB = 1024 ** 3
|
||||||
|
|
||||||
|
|
||||||
|
def _bpe(cache_type: str | None) -> float:
|
||||||
|
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
|
||||||
|
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
|
||||||
|
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
|
||||||
|
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
|
||||||
|
k = per_tok * meta["head_dim_k"] * _bpe(ck)
|
||||||
|
v = per_tok * meta["head_dim_v"] * _bpe(cv)
|
||||||
|
return (k + v) / _GIB
|
||||||
|
|
||||||
|
|
||||||
|
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
|
||||||
|
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
|
||||||
|
return kv_cache_gb(meta, 1, ck, cv)
|
||||||
|
|||||||
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"])
|
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:
|
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||||
job["sudo_failed"] = True
|
job["sudo_failed"] = True
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Fehler: {exc}")
|
_append_log(job, f"[mc] Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
job["state"] = "failed"
|
||||||
job["returncode"] = -1
|
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":
|
if cb and job["state"] == "done":
|
||||||
try:
|
try:
|
||||||
cb()
|
cb()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {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["rate_bps"] = rate
|
||||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||||
prev_t, prev_b = now, cur
|
prev_t, prev_b = now, cur
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
j = JOBS.get(job_id)
|
j = JOBS.get(job_id)
|
||||||
@@ -182,7 +182,7 @@ def cancel_job(job_id: str) -> bool:
|
|||||||
if proc is not None:
|
if proc is not None:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
job["state"] = "canceled"
|
job["state"] = "canceled"
|
||||||
|
|||||||
+612
-550
File diff suppressed because it is too large
Load Diff
+801
-568
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
|||||||
"""
|
|
||||||
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
|
|
||||||
|
|
||||||
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
|
|
||||||
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
|
|
||||||
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
|
|
||||||
|
|
||||||
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
|
|
||||||
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
|
|
||||||
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
|
|
||||||
|
|
||||||
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import MEM0_SERVICE_URL
|
|
||||||
|
|
||||||
CATEGORIES = ("identity", "knowledge", "rules", "events")
|
|
||||||
|
|
||||||
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
|
|
||||||
|
|
||||||
|
|
||||||
def _get(path: str, **params) -> list | dict:
|
|
||||||
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _post(path: str, data: dict) -> dict:
|
|
||||||
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _put(path: str, data: dict) -> dict:
|
|
||||||
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def _delete(path: str) -> dict:
|
|
||||||
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
def list_memories(q: str = "", category: str = "") -> list[dict]:
|
|
||||||
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
|
|
||||||
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
|
|
||||||
|
|
||||||
|
|
||||||
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
|
|
||||||
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
|
|
||||||
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
|
|
||||||
|
|
||||||
|
|
||||||
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
|
|
||||||
try:
|
|
||||||
return _put(f"/memory/{mid}", {"content": content, "category": category})
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code == 404:
|
|
||||||
return None
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def delete_memory(mid: str) -> bool:
|
|
||||||
try:
|
|
||||||
_delete(f"/memory/{mid}")
|
|
||||||
return True
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code == 404:
|
|
||||||
return False
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def learn(text: str | None = None, messages: list[dict] | None = None,
|
|
||||||
source: str = "auto", category: str = "stable") -> dict:
|
|
||||||
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
|
|
||||||
return _post("/learn", {"text": text, "messages": messages,
|
|
||||||
"source": source, "category": category})
|
|
||||||
|
|
||||||
|
|
||||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
|
||||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
|
|
||||||
return _get("/graph", min_score=min_score, top_k=top_k)
|
|
||||||
|
|
||||||
|
|
||||||
def export_text() -> dict:
|
|
||||||
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
|
||||||
lines = ["# Mission Control — Gedächtnis\n"]
|
|
||||||
current = ""
|
|
||||||
for r in rows:
|
|
||||||
if r.get("category") != current:
|
|
||||||
current = r.get("category", "")
|
|
||||||
lines.append(f"\n## {current}\n")
|
|
||||||
src = r.get("source", "")
|
|
||||||
when = (r.get("updated_at") or "")[:10]
|
|
||||||
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
|
|
||||||
return {"text": "\n".join(lines), "count": len(rows)}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
|
|
||||||
def _norm(s: str) -> str:
|
|
||||||
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
|
|
||||||
return re.sub(r"\s+", " ", s).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
|
||||||
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
|
|
||||||
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
|
|
||||||
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
|
|
||||||
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
|
|
||||||
used: set[str] = set()
|
|
||||||
groups: list[dict] = []
|
|
||||||
for i, a in enumerate(rows):
|
|
||||||
if a["id"] in used:
|
|
||||||
continue
|
|
||||||
na = _norm(a.get("content", ""))
|
|
||||||
if not na:
|
|
||||||
continue
|
|
||||||
dups = []
|
|
||||||
for b in rows[i + 1:]:
|
|
||||||
if b["id"] in used or b.get("category") != a.get("category"):
|
|
||||||
continue
|
|
||||||
nb = _norm(b.get("content", ""))
|
|
||||||
if not nb:
|
|
||||||
continue
|
|
||||||
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
|
|
||||||
dups.append(b); used.add(b["id"])
|
|
||||||
if dups:
|
|
||||||
used.add(a["id"])
|
|
||||||
groups.append({
|
|
||||||
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
|
|
||||||
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
|
|
||||||
})
|
|
||||||
dup_count = sum(len(g["remove"]) for g in groups)
|
|
||||||
removed = 0
|
|
||||||
if apply:
|
|
||||||
for g in groups:
|
|
||||||
for d in g["remove"]:
|
|
||||||
if delete_memory(d["id"]):
|
|
||||||
removed += 1
|
|
||||||
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
+58
-58
@@ -1,58 +1,58 @@
|
|||||||
"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
|
"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
|
||||||
|
|
||||||
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
|
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
|
||||||
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
|
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
|
||||||
eingesparte Summe. Wird vom System-Router dünn aufgerufen.
|
eingesparte Summe. Wird vom System-Router dünn aufgerufen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
|
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
|
||||||
PRICING: dict[str, tuple[float, float]] = {
|
PRICING: dict[str, tuple[float, float]] = {
|
||||||
"heavy": (15.0, 75.0),
|
"heavy": (15.0, 75.0),
|
||||||
"coder": (3.0, 15.0),
|
"coder": (3.0, 15.0),
|
||||||
"hermes": (1.0, 5.0),
|
"hermes": (1.0, 5.0),
|
||||||
"fast": (0.15, 0.60),
|
"fast": (0.15, 0.60),
|
||||||
"scout": (0.15, 0.60),
|
"scout": (0.15, 0.60),
|
||||||
"vision": (0.15, 0.60),
|
"vision": (0.15, 0.60),
|
||||||
"reasoning": (0.15, 0.60),
|
"reasoning": (0.15, 0.60),
|
||||||
}
|
}
|
||||||
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
|
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
|
||||||
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
|
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
|
||||||
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
|
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
|
||||||
# damit historische Ersparnis erhalten bleibt.
|
# damit historische Ersparnis erhalten bleibt.
|
||||||
BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
|
BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
|
||||||
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92"))
|
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92"))
|
||||||
|
|
||||||
|
|
||||||
def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
|
def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
|
||||||
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis.
|
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis.
|
||||||
|
|
||||||
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
|
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
|
||||||
"""
|
"""
|
||||||
prompt = stats.get("prompt_tokens", 0)
|
prompt = stats.get("prompt_tokens", 0)
|
||||||
completion = stats.get("completion_tokens", 0)
|
completion = stats.get("completion_tokens", 0)
|
||||||
|
|
||||||
modeled_p = modeled_c = 0
|
modeled_p = modeled_c = 0
|
||||||
saved_usd = 0.0
|
saved_usd = 0.0
|
||||||
for m_name, m_tokens in (stats.get("models") or {}).items():
|
for m_name, m_tokens in (stats.get("models") or {}).items():
|
||||||
mp = m_tokens.get("prompt", 0)
|
mp = m_tokens.get("prompt", 0)
|
||||||
mc = m_tokens.get("completion", 0)
|
mc = m_tokens.get("completion", 0)
|
||||||
modeled_p += mp
|
modeled_p += mp
|
||||||
modeled_c += mc
|
modeled_c += mc
|
||||||
role = role_map.get(m_name, m_name)
|
role = role_map.get(m_name, m_name)
|
||||||
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
|
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
|
||||||
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
|
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
|
||||||
|
|
||||||
baseline_p = max(0, prompt - modeled_p)
|
baseline_p = max(0, prompt - modeled_p)
|
||||||
baseline_c = max(0, completion - modeled_c)
|
baseline_c = max(0, completion - modeled_c)
|
||||||
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
|
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"prompt_tokens": prompt,
|
"prompt_tokens": prompt,
|
||||||
"completion_tokens": completion,
|
"completion_tokens": completion,
|
||||||
"total_tokens": prompt + completion,
|
"total_tokens": prompt + completion,
|
||||||
"saved_usd": round(saved_usd, 2),
|
"saved_usd": round(saved_usd, 2),
|
||||||
"saved_eur": round(saved_usd * USD_TO_EUR, 2),
|
"saved_eur": round(saved_usd * USD_TO_EUR, 2),
|
||||||
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()},
|
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
def _text_of(body: dict) -> str:
|
||||||
msgs = body.get("messages") or []
|
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]:
|
def _route_chat(text: str, n: int) -> tuple[str, str]:
|
||||||
|
|||||||
+131
-127
@@ -1,127 +1,131 @@
|
|||||||
"""
|
"""
|
||||||
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
|
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
|
||||||
|
|
||||||
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
|
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
|
||||||
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
|
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
|
||||||
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
|
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
|
||||||
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
|
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
|
||||||
|
|
||||||
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
|
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
|
||||||
bleiben in router_logic.py im Code.
|
bleiben in router_logic.py im Code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
|
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: str) -> bool:
|
def _env_bool(name: str, default: str) -> bool:
|
||||||
return os.environ.get(name, default) not in ("0", "false", "")
|
return os.environ.get(name, default) not in ("0", "false", "")
|
||||||
|
|
||||||
|
|
||||||
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
|
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
|
||||||
DEFAULTS: dict = {
|
DEFAULTS: dict = {
|
||||||
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
|
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
|
||||||
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
|
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
|
||||||
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
|
"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", ""),
|
||||||
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
|
||||||
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
|
||||||
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
"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")),
|
||||||
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
||||||
FIELDS: list[dict] = [
|
}
|
||||||
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
|
||||||
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
|
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
||||||
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
FIELDS: list[dict] = [
|
||||||
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
|
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
||||||
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
|
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
|
||||||
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
||||||
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
{"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},
|
||||||
_LOCK = threading.Lock()
|
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
||||||
_CACHE: dict = {"mtime": None, "policy": None}
|
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
||||||
|
]
|
||||||
|
|
||||||
def _read_file() -> dict:
|
_LOCK = threading.Lock()
|
||||||
try:
|
_CACHE: dict = {"mtime": None, "policy": None}
|
||||||
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return data if isinstance(data, dict) else {}
|
def _read_file() -> dict:
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
try:
|
||||||
return {}
|
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
def _coerce(patch: dict) -> dict:
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
return {}
|
||||||
spec = {f["key"]: f for f in FIELDS}
|
|
||||||
out: dict = {}
|
|
||||||
for k, v in (patch or {}).items():
|
def _coerce(patch: dict) -> dict:
|
||||||
f = spec.get(k)
|
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
||||||
if not f:
|
spec = {f["key"]: f for f in FIELDS}
|
||||||
continue # unbekannte Keys still verwerfen
|
out: dict = {}
|
||||||
if f["type"] == "int":
|
for k, v in (patch or {}).items():
|
||||||
iv = int(v)
|
f = spec.get(k)
|
||||||
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
if not f:
|
||||||
if not (lo <= iv <= hi):
|
continue # unbekannte Keys still verwerfen
|
||||||
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
if f["type"] == "int":
|
||||||
out[k] = iv
|
iv = int(v)
|
||||||
elif f["type"] == "bool":
|
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
||||||
out[k] = bool(v)
|
if not (lo <= iv <= hi):
|
||||||
else: # str
|
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
||||||
sv = str(v).strip()
|
out[k] = iv
|
||||||
if k != "coder_lite" and not sv:
|
elif f["type"] == "bool":
|
||||||
raise ValueError(f"{k} darf nicht leer sein")
|
out[k] = bool(v)
|
||||||
out[k] = sv
|
else: # str
|
||||||
return out
|
sv = str(v).strip()
|
||||||
|
if k not in ("coder_lite", "vision") and not sv:
|
||||||
|
raise ValueError(f"{k} darf nicht leer sein")
|
||||||
def _coerce_safe(patch: dict) -> dict:
|
out[k] = sv
|
||||||
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
return out
|
||||||
try:
|
|
||||||
return _coerce(patch)
|
|
||||||
except (ValueError, TypeError):
|
def _coerce_safe(patch: dict) -> dict:
|
||||||
return {}
|
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
||||||
|
try:
|
||||||
|
return _coerce(patch)
|
||||||
def load_policy() -> dict:
|
except (ValueError, TypeError):
|
||||||
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
return {}
|
||||||
try:
|
|
||||||
mtime = POLICY_PATH.stat().st_mtime
|
|
||||||
except OSError:
|
def load_policy() -> dict:
|
||||||
mtime = None
|
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
||||||
with _LOCK:
|
try:
|
||||||
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
mtime = POLICY_PATH.stat().st_mtime
|
||||||
merged = {**DEFAULTS}
|
except OSError:
|
||||||
if mtime is not None:
|
mtime = None
|
||||||
merged.update(_coerce_safe(_read_file()))
|
with _LOCK:
|
||||||
_CACHE["mtime"] = mtime
|
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
||||||
_CACHE["policy"] = merged
|
merged = {**DEFAULTS}
|
||||||
return dict(_CACHE["policy"])
|
if mtime is not None:
|
||||||
|
merged.update(_coerce_safe(_read_file()))
|
||||||
|
_CACHE["mtime"] = mtime
|
||||||
def save_policy(patch: dict) -> dict:
|
_CACHE["policy"] = merged
|
||||||
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
return dict(_CACHE["policy"])
|
||||||
clean = _coerce(patch) # wirft bei ungültigem Input
|
|
||||||
with _LOCK:
|
|
||||||
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
|
def save_policy(patch: dict) -> dict:
|
||||||
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
||||||
tmp = POLICY_PATH.with_suffix(".json.tmp")
|
clean = _coerce(patch) # wirft bei ungültigem Input
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with _LOCK:
|
||||||
json.dump(current, f, ensure_ascii=False, indent=2)
|
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
|
||||||
os.replace(tmp, POLICY_PATH)
|
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
|
tmp = POLICY_PATH.with_suffix(".json.tmp")
|
||||||
_CACHE["policy"] = None
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
return current
|
json.dump(current, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, POLICY_PATH)
|
||||||
|
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
|
||||||
def policy_meta() -> dict:
|
_CACHE["policy"] = None
|
||||||
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
|
return current
|
||||||
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
|
|
||||||
|
|
||||||
|
def policy_meta() -> dict:
|
||||||
|
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
|
||||||
|
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
|
||||||
|
|||||||
@@ -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,
|
# Kanonische Rollen — eine Quelle der Wahrheit (deckt sich mit llamaswap.ROLE_IDS,
|
||||||
# maintenance.ROLE_MAP, frontend ModelBadges.ROLES + Discover.ROLE_METADATA).
|
# maintenance.ROLE_MAP, frontend ModelBadges.ROLES + Discover.ROLE_METADATA).
|
||||||
# `hermes` = Lucys Agent-Hirn (warm + ko-resident); UI-Label „Hirn".
|
# `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
|
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Priorität). Ein Modell wird der
|
||||||
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout".
|
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout".
|
||||||
|
|||||||
+20
-14
@@ -9,9 +9,9 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
|||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +116,6 @@ def find_hermes_agent_git() -> dict | None:
|
|||||||
candidates = [
|
candidates = [
|
||||||
"~/hermes-agent",
|
"~/hermes-agent",
|
||||||
"~/.hermes/hermes-agent",
|
"~/.hermes/hermes-agent",
|
||||||
"~/hermes-webui/hermes-agent",
|
|
||||||
"~/.hermes"
|
"~/.hermes"
|
||||||
]
|
]
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
@@ -157,6 +156,7 @@ def get_engine_version() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||||
|
_VERSION_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def check_versions_cached() -> dict:
|
def check_versions_cached() -> dict:
|
||||||
@@ -164,18 +164,24 @@ def check_versions_cached() -> dict:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _VERSION_CACHE["ts"] < 30.0:
|
if now - _VERSION_CACHE["ts"] < 30.0:
|
||||||
return _VERSION_CACHE["data"]
|
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.
|
||||||
data = {
|
# Doppelt geprüft, damit ein zweiter Thread den frisch gefüllten Cache nimmt statt neu zu scannen.
|
||||||
"mc2": get_git_info(mc2_path),
|
with _VERSION_LOCK:
|
||||||
"engine": get_engine_version(),
|
now = time.time()
|
||||||
"hermes_ui": get_git_info("~/hermes-webui"),
|
if now - _VERSION_CACHE["ts"] < 30.0:
|
||||||
"hermes_agent": find_hermes_agent_git()
|
return _VERSION_CACHE["data"]
|
||||||
}
|
|
||||||
_VERSION_CACHE["ts"] = now
|
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
_VERSION_CACHE["data"] = data
|
data = {
|
||||||
return data
|
"mc2": get_git_info(mc2_path),
|
||||||
|
"engine": get_engine_version(),
|
||||||
|
"hermes_agent": find_hermes_agent_git()
|
||||||
|
}
|
||||||
|
_VERSION_CACHE["ts"] = now
|
||||||
|
_VERSION_CACHE["data"] = data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def system_status() -> dict:
|
def system_status() -> dict:
|
||||||
|
|||||||
+123
-99
@@ -1,99 +1,123 @@
|
|||||||
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
|
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
|
||||||
|
|
||||||
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
|
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
|
||||||
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
|
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
|
||||||
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
|
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
|
||||||
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
|
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
|
from config import HERMES_HOME
|
||||||
from config import HERMES_HOME
|
|
||||||
|
STATS_FILE = HERMES_HOME / "token_stats.json"
|
||||||
STATS_FILE = HERMES_HOME / "token_stats.json"
|
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
|
||||||
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
|
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
|
||||||
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
|
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
|
||||||
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
_lock = threading.Lock()
|
||||||
_lock = threading.Lock()
|
_stats: dict | None = None
|
||||||
_stats: dict | None = None
|
_dirty = False
|
||||||
_dirty = False
|
_last_flush = 0.0
|
||||||
_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.
|
||||||
def _load_from_disk() -> dict:
|
_disk_mtime: float | None = None
|
||||||
if not STATS_FILE.exists():
|
|
||||||
return dict(_BASELINE)
|
|
||||||
try:
|
def _stat_mtime() -> float | None:
|
||||||
with open(STATS_FILE, "r", encoding="utf-8") as f:
|
try:
|
||||||
data = json.load(f)
|
return STATS_FILE.stat().st_mtime
|
||||||
data.setdefault("prompt_tokens", 0)
|
except OSError:
|
||||||
data.setdefault("completion_tokens", 0)
|
return None
|
||||||
data.setdefault("models", {})
|
|
||||||
return data
|
|
||||||
except (OSError, json.JSONDecodeError):
|
def _load_from_disk() -> dict:
|
||||||
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
|
global _disk_mtime
|
||||||
return dict(_BASELINE)
|
_disk_mtime = _stat_mtime()
|
||||||
|
if not STATS_FILE.exists():
|
||||||
|
return dict(_BASELINE)
|
||||||
def _ensure_loaded() -> dict:
|
try:
|
||||||
global _stats
|
with open(STATS_FILE, "r", encoding="utf-8") as f:
|
||||||
if _stats is None:
|
data = json.load(f)
|
||||||
_stats = _load_from_disk()
|
data.setdefault("prompt_tokens", 0)
|
||||||
return _stats
|
data.setdefault("completion_tokens", 0)
|
||||||
|
data.setdefault("models", {})
|
||||||
|
return data
|
||||||
def _write(stats: dict) -> None:
|
except (OSError, json.JSONDecodeError):
|
||||||
try:
|
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
|
||||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
return dict(_BASELINE)
|
||||||
tmp = STATS_FILE.with_suffix(".tmp")
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(stats, f)
|
def _ensure_loaded() -> dict:
|
||||||
tmp.replace(STATS_FILE)
|
global _stats
|
||||||
except OSError:
|
if _stats is None:
|
||||||
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
|
_stats = _load_from_disk()
|
||||||
|
return _stats
|
||||||
|
|
||||||
def get_stats() -> dict:
|
|
||||||
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
|
def _write(stats: dict) -> None:
|
||||||
with _lock:
|
global _disk_mtime
|
||||||
return json.loads(json.dumps(_ensure_loaded()))
|
try:
|
||||||
|
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = STATS_FILE.with_suffix(".tmp")
|
||||||
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
|
json.dump(stats, f)
|
||||||
global _dirty, _last_flush
|
tmp.replace(STATS_FILE)
|
||||||
with _lock:
|
_disk_mtime = _stat_mtime() # eigener Write ist kein Fremd-Update
|
||||||
stats = _ensure_loaded()
|
except OSError:
|
||||||
stats["prompt_tokens"] += prompt
|
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
|
||||||
stats["completion_tokens"] += completion
|
|
||||||
if model:
|
|
||||||
m = stats.setdefault("models", {}).setdefault(
|
def get_stats() -> dict:
|
||||||
model.lower(), {"prompt": 0, "completion": 0})
|
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.
|
||||||
m["prompt"] += prompt
|
|
||||||
m["completion"] += completion
|
Multi-Prozess-fähig: Hat ein ANDERER Prozess (mc2-gateway) die Datei inzwischen
|
||||||
_dirty = True
|
geschrieben und liegen hier keine ungeflushten Inkremente, wird frisch geladen.
|
||||||
now = time.monotonic()
|
Im Gateway-Prozess selbst ist nach jedem Flush Datei == Speicher → der
|
||||||
if now - _last_flush >= FLUSH_INTERVAL:
|
mtime-Vergleich lädt dort nie unnötig nach."""
|
||||||
_write(stats)
|
global _stats
|
||||||
_dirty = False
|
with _lock:
|
||||||
_last_flush = now
|
if _stats is not None and not _dirty:
|
||||||
|
mtime = _stat_mtime()
|
||||||
|
if mtime is not None and mtime != _disk_mtime:
|
||||||
def flush() -> None:
|
_stats = _load_from_disk()
|
||||||
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
|
return json.loads(json.dumps(_ensure_loaded()))
|
||||||
global _dirty
|
|
||||||
with _lock:
|
|
||||||
if _dirty and _stats is not None:
|
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
|
||||||
_write(_stats)
|
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
|
||||||
_dirty = False
|
global _dirty, _last_flush
|
||||||
|
with _lock:
|
||||||
|
stats = _ensure_loaded()
|
||||||
atexit.register(flush)
|
stats["prompt_tokens"] += prompt
|
||||||
|
stats["completion_tokens"] += completion
|
||||||
|
if model:
|
||||||
|
m = stats.setdefault("models", {}).setdefault(
|
||||||
|
model.lower(), {"prompt": 0, "completion": 0})
|
||||||
|
m["prompt"] += prompt
|
||||||
|
m["completion"] += completion
|
||||||
|
_dirty = True
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last_flush >= FLUSH_INTERVAL:
|
||||||
|
_write(stats)
|
||||||
|
_dirty = False
|
||||||
|
_last_flush = now
|
||||||
|
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
|
||||||
|
global _dirty
|
||||||
|
with _lock:
|
||||||
|
if _dirty and _stats is not None:
|
||||||
|
_write(_stats)
|
||||||
|
_dirty = False
|
||||||
|
|
||||||
|
|
||||||
|
atexit.register(flush)
|
||||||
|
|||||||
@@ -1,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
|
Zwei Sichten auf dieselben Messungen:
|
||||||
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
|
1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
|
||||||
Frontend-Overhaul (E) analog zur TokenPerformanceCard.
|
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).
|
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
_MAX = 200
|
_MAX = 200
|
||||||
_STAGES: dict[str, deque] = {}
|
_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.
|
# 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:
|
def record_stage(stage: str, ms: float) -> None:
|
||||||
@@ -32,6 +52,36 @@ def record_stage(stage: str, ms: float) -> None:
|
|||||||
dq.append(float(ms))
|
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:
|
class Timer:
|
||||||
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
|
||||||
Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
|
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)
|
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:
|
def _summary(vals: list[float]) -> dict:
|
||||||
if not vals:
|
if not vals:
|
||||||
return {"count": 0}
|
return {"count": 0}
|
||||||
@@ -66,3 +178,10 @@ def get_metrics() -> dict:
|
|||||||
"""Rollende Zusammenfassung je Stufe."""
|
"""Rollende Zusammenfassung je Stufe."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
|
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]
|
||||||
|
|||||||
+140
-77
@@ -1,77 +1,140 @@
|
|||||||
"""
|
"""
|
||||||
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`
|
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
|
||||||
verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; neu vorgewärmt wird sonst erst
|
der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
|
||||||
beim nächsten llama-swap-(Re)Start. Dieser Wächter schließt die Lücke: ist die Box idle
|
Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
|
||||||
(nichts geladen), pingt er das Hirn vor. Während aktiver Last (irgendetwas geladen) hält
|
Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
|
||||||
er sich raus, verdrängt also nie ein gerade genutztes Modell.
|
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
|
||||||
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL, MC_REWARM_MODEL.
|
Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
|
||||||
"""
|
raus, verdrängt also nie ein gerade genutztes Modell.
|
||||||
|
|
||||||
import asyncio
|
warmup.sh deckt korrekt ab: fast über /v1/chat/completions, embed über /v1/embeddings
|
||||||
import logging
|
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
|
||||||
import os
|
|
||||||
|
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
|
||||||
import httpx
|
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast') + MC_WARMUP_EMBED (Default 'embed').
|
||||||
|
"""
|
||||||
from config import LLAMA_SWAP_URL
|
|
||||||
|
import asyncio
|
||||||
log = logging.getLogger(__name__)
|
import logging
|
||||||
|
import os
|
||||||
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
|
import subprocess
|
||||||
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
|
|
||||||
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
|
import httpx
|
||||||
|
from config import LLAMA_SWAP_URL
|
||||||
|
|
||||||
def _brain_model() -> str:
|
log = logging.getLogger(__name__)
|
||||||
"""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."""
|
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
|
||||||
forced = os.environ.get("MC_REWARM_MODEL")
|
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
|
||||||
if forced:
|
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
|
||||||
return forced
|
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
|
||||||
try:
|
|
||||||
from ruamel.yaml import YAML
|
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
|
||||||
from config import HERMES_HOME
|
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
|
||||||
p = HERMES_HOME / "config.yaml"
|
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
|
||||||
if p.exists():
|
|
||||||
with p.open(encoding="utf-8") as f:
|
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
|
||||||
cfg = YAML().load(f) or {}
|
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
|
||||||
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
|
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
|
||||||
v = m.get("default") or m.get("model")
|
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
|
||||||
if v:
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
return str(v)
|
_wake: asyncio.Event | None = None
|
||||||
except Exception:
|
|
||||||
log.debug("rewarm: Hirn-Lookup fehlgeschlagen", exc_info=True)
|
|
||||||
return "fast"
|
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."""
|
||||||
async def _running_empty() -> bool:
|
if _loop is not None and _wake is not None and not _loop.is_closed():
|
||||||
async with httpx.AsyncClient(timeout=8.0) as c:
|
try:
|
||||||
r = await c.get(f"{LLAMA_SWAP_URL}/running")
|
_loop.call_soon_threadsafe(_wake.set)
|
||||||
data = r.json() or {}
|
except RuntimeError:
|
||||||
return not (data.get("running") or [])
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _warm(model: str) -> None:
|
def _run_warmup() -> bool:
|
||||||
async with httpx.AsyncClient(timeout=180.0) as c:
|
"""Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
|
||||||
await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
|
||||||
"model": model, "max_tokens": 1,
|
False, wenn das Skript fehlt."""
|
||||||
"messages": [{"role": "user", "content": "ping"}],
|
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:
|
||||||
async def rewarm_loop() -> None:
|
subprocess.Popen(["bash", _WARMUP_SH],
|
||||||
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch, wärmt bei Idle vor."""
|
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
|
||||||
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
while True:
|
return True
|
||||||
try:
|
except Exception:
|
||||||
if await _running_empty():
|
log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
|
||||||
model = _brain_model()
|
return False
|
||||||
log.info("rewarm: Box idle → Hirn '%s' wird vorgewärmt", model)
|
|
||||||
await _warm(model)
|
|
||||||
except Exception:
|
def _warmset_members() -> set[str]:
|
||||||
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
"""Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
|
||||||
await asyncio.sleep(INTERVAL)
|
|
||||||
|
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
|
||||||
|
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
|
||||||
|
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
|
||||||
|
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
|
||||||
|
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
|
||||||
|
nur die erste ausdrücken.
|
||||||
|
"""
|
||||||
|
explizit = os.environ.get("MC_WARMSET", "").split()
|
||||||
|
if explizit:
|
||||||
|
return set(explizit)
|
||||||
|
try:
|
||||||
|
from services import llamaswap
|
||||||
|
groups = llamaswap.list_groups() or {}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
want: set[str] = set()
|
||||||
|
for g in groups.values():
|
||||||
|
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
|
||||||
|
want.update(g.get("members") or [])
|
||||||
|
return want
|
||||||
|
|
||||||
|
|
||||||
|
async def _warmset_missing() -> list[str] | None:
|
||||||
|
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
|
||||||
|
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der
|
||||||
|
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as c:
|
||||||
|
r = await c.get(f"{LLAMA_SWAP_URL}/running")
|
||||||
|
data = r.json() or {}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
|
||||||
|
want = _warmset_members()
|
||||||
|
if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
|
||||||
|
return [] if ready else ["<leer>"]
|
||||||
|
return [m for m in want if m not in ready]
|
||||||
|
|
||||||
|
|
||||||
|
async def rewarm_loop() -> None:
|
||||||
|
"""Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
|
||||||
|
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
|
||||||
|
global _loop, _wake
|
||||||
|
_loop = asyncio.get_running_loop()
|
||||||
|
_wake = asyncio.Event()
|
||||||
|
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
missing = await _warmset_missing()
|
||||||
|
if missing:
|
||||||
|
log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
|
||||||
|
_run_warmup()
|
||||||
|
except Exception:
|
||||||
|
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
||||||
|
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
|
||||||
|
_wake.clear()
|
||||||
|
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
|
||||||
|
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
|
||||||
|
_run_warmup()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -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],
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
|
||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
encoding="utf-8", errors="replace",
|
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 {
|
return {
|
||||||
"stdout": result.stdout.strip(),
|
"stdout": result.stdout.strip(),
|
||||||
@@ -108,13 +112,49 @@ async def press_keys(req: dict):
|
|||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Medien & Lautstärke (A3: Lucy steuert den PC per Sprache) ──────────────
|
||||||
|
# Media-Keys wirken auf den AKTIVEN Player (Spotify, YouTube, VLC …) — genau wie
|
||||||
|
# die Tasten auf einer Multimedia-Tastatur. Lautstärke = System-Master (2 % je Schritt).
|
||||||
|
MEDIA_KEYS = {"play_pause": "playpause", "next": "nexttrack", "prev": "prevtrack", "stop": "stop"}
|
||||||
|
VOLUME_KEYS = {"up": "volumeup", "down": "volumedown", "mute": "volumemute"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/media", dependencies=[Depends(require_auth)])
|
||||||
|
async def media_control(req: dict):
|
||||||
|
action = (req.get("action") or "").strip().lower()
|
||||||
|
key = MEDIA_KEYS.get(action)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(MEDIA_KEYS)}")
|
||||||
|
try:
|
||||||
|
import pyautogui
|
||||||
|
pyautogui.press(key)
|
||||||
|
return {"ok": True, "action": action}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/volume", dependencies=[Depends(require_auth)])
|
||||||
|
async def volume_control(req: dict):
|
||||||
|
action = (req.get("action") or "").strip().lower()
|
||||||
|
key = VOLUME_KEYS.get(action)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(VOLUME_KEYS)}")
|
||||||
|
steps = 1 if action == "mute" else max(1, min(25, int(req.get("steps", 5))))
|
||||||
|
try:
|
||||||
|
import pyautogui
|
||||||
|
pyautogui.press(key, presses=steps, interval=0.02)
|
||||||
|
return {"ok": True, "action": action, "steps": steps}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
# ── Apps / Browser ─────────────────────────────────────────────────────────
|
# ── Apps / Browser ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.post("/open", dependencies=[Depends(require_auth)])
|
@app.post("/open", dependencies=[Depends(require_auth)])
|
||||||
async def open_target(req: dict):
|
async def open_target(req: dict):
|
||||||
target = req.get("target", "")
|
target = req.get("target", "")
|
||||||
try:
|
try:
|
||||||
if target.startswith("http://") or target.startswith("https://"):
|
if target.startswith(("http://", "https://")):
|
||||||
webbrowser.open(target)
|
webbrowser.open(target)
|
||||||
else:
|
else:
|
||||||
os.startfile(target)
|
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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Security Memo: Gitea Registrierung deaktivieren
|
||||||
|
|
||||||
|
## Status
|
||||||
|
**Aktuell:** Registrierung ist OFFEN (Standard-Gitea-Setup)
|
||||||
|
**Ziel:** `DISABLE_REGISTRATION = true` in `[security]` Abschnitt der app.ini setzen
|
||||||
|
|
||||||
|
## Risiko
|
||||||
|
- **DDNS-Endpunkt** (`git.tobisniceshomelab.ddnsfree.com`) ist öffentlich erreichbar
|
||||||
|
- **Version im Footer** (1.26.2) identifizierbar — potenziell anfällige Version
|
||||||
|
- Jeder kann sich ohne Genehmigung registrieren → potenzielle Angreifer/Spammer
|
||||||
|
|
||||||
|
## Empfohlene Maßnahme
|
||||||
|
1. Auf dem Gitea-Server (Proxmox LXC `192.168.178.153`) in `/etc/gitea/app.ini` im Abschnitt `[security]` die Zeile `DISABLE_REGISTRATION = true` hinzufügen
|
||||||
|
2. Gitea neu starten: `sudo systemctl restart gitea` (oder wie auch der Service heißt)
|
||||||
|
|
||||||
|
## Commander-Approval notwendig
|
||||||
|
- ⚠️ Diese Änderung ist **nur im laufenden Betrieb** auf dem Gitea-Server vorzunehmen
|
||||||
|
- **Branch `wartung/gitea-disable-registration`** enthält eine Beispielkonfiguration und Dokumentation
|
||||||
|
- **Bitte bestätige mit "JA", dass du die Änderung freigibst**, damit sie auf dem Server umgesetzt werden kann
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
Ohne Commander-Ja: keine Änderung an der Gitea-Instanz vornehmen. Die Sicherheitslücke bleibt bestehen, bis die Freigabe erfolgt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt am: 2026-07-17*
|
||||||
|
*Branch: `wartung/gitea-disable-registration`*
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pre_llm_call-Hook (Faden 5, 13.07.2026): verdrahtet Box-Selbstwissen MECHANISCH in
|
||||||
|
# JEDEN Kanban-Worker — egal welches Profil (werkstatt, betrieb, default-als-Worker).
|
||||||
|
# Der Kontext wird ephemer pro Turn an die User-Message angehaengt (agent/turn_context.py),
|
||||||
|
# NUR wenn die Session in einem Kanban-Task-Workspace laeuft. Lucys interaktiver/Voice-Chat
|
||||||
|
# laeuft NICHT dort → sofortiger No-op, kein Persona- oder Latenz-Eingriff. Loest
|
||||||
|
# Fallstrick #1 (Ops/Mess/Verify-Worker flailen, weil sie llama-swap :8080 nicht kennen).
|
||||||
|
# Ausgabe {"context":"..."} = anhaengen; {} = durchlassen (No-op).
|
||||||
|
set -u
|
||||||
|
payload="$(cat -)"
|
||||||
|
|
||||||
|
# Scope-Gate: NUR echte Kanban-Worker — erkannt an der cwd im Task-Workspace.
|
||||||
|
# ACHTUNG (Bug-Fund Faden 8, 13.07.): Worker tragen als task_id den SESSION-Zeitstempel
|
||||||
|
# (z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check feuerte also
|
||||||
|
# NIE fuer Worker. Der zuverlaessige Signal ist die cwd unter kanban/workspaces/; Lucy/
|
||||||
|
# Voice/CLI laufen nie dort.
|
||||||
|
cwd=$(printf %s "$payload" | jq -r '.cwd // ""' 2>/dev/null || echo "")
|
||||||
|
case "$cwd" in
|
||||||
|
"$HOME/.hermes/kanban/workspaces/"*) : ;; # Kanban-Worker → Orientierung anhaengen
|
||||||
|
*) printf '{}'; exit 0 ;; # Lucy / Voice / CLI / interaktiv → No-op
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Zeiger auf den vollen, naechtlich generierten Selbst-Steckbrief (nur wenn er existiert).
|
||||||
|
STECKBRIEF="$HOME/.hermes/state/selbst-steckbrief.md"
|
||||||
|
hint=""
|
||||||
|
[ -f "$STECKBRIEF" ] && hint=" Dein vollstaendiger Selbst-Steckbrief (aktuelle Versionen/Dienste/Modelle/Crons/offene Karten) liegt in $STECKBRIEF — dort nachlesen statt raten."
|
||||||
|
|
||||||
|
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
|
||||||
|
ctx="$(cat <<EOF
|
||||||
|
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
|
||||||
|
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
|
||||||
|
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
|
||||||
|
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
|
||||||
|
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
|
||||||
|
FRONTEND-BAUEN (seit 15.07. abends): Node LTS liegt sudo-frei unter ~/.local (node/npm im Worker-PATH). Bei Frontend-Aenderungen baust DU das dist MIT: cd frontend && npm ci --no-audit --no-fund && npm run build — und committest frontend/dist im selben Branch (die Box zeigt sonst nach Annahme den alten Stand; Bau-Beweis 15.07.: kompletter Build in ~4 s).
|
||||||
|
WO WAS LAEUFT (20.07. — zwei getrennte Maschinen, NICHT verwechseln): Du laeufst auf der AI-Box (192.168.178.151) — das ist die WERKBANK (Agent/Lucy/MC2/Modelle), NICHT das Ziel-System fuer Projekt-Deployments. Der Proxmox-Host 'pve' (192.168.178.108) ist ein SEPARATER Rechner; dort laufen die LXC-Container. Von hier per 'ssh pve' erreichbar (root, Key), Steuerung mit 'pct'. Welche Container es gerade gibt, schaust du bei Bedarf mit 'ssh pve pct list' nach — nicht raten, nicht auswendig annehmen. Soll etwas dauerhaft laufen, gehoert es in einen LXC auf pve, nicht auf die Box.
|
||||||
|
REPO FEHLT? (20.07.): Soll dein Ergebnis in ein Gitea-Repo, das noch NICHT existiert (eigenes/neues Projekt), dann lege es SELBST an — NICHT den Commander fragen, NICHT git init: bash ~/.hermes/scripts/gitea-repo-create.sh <name> "<kurze Beschreibung>" (immer privat, letzte Ausgabezeile = CLONE <url>), dann voll klonen und darin arbeiten. NUR mission-control-v2 und das Lucy-Repo NIE neu anlegen/veraendern.
|
||||||
|
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07./20.07. — Worker starben an finish_reason=length UND an vollgelaufenem Kontext): Dein Kontext ist ENDLICH und fuellt sich vor allem durch GROSSE Datei- und Terminal-Ausgaben. Lies NIE eine grosse Datei am Stueck — nutze grep und Zeilenbereiche (read mit offset/limit); Datei-Reads sind auf 50k Zeichen gedeckelt, aber auch viele mittlere summieren sich. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
|
||||||
|
jq --null-input --arg c "$ctx" '{context:$c}'
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""No-Progress-Bremse (Phase 2 des Drei-Welten-Plans, 19.07.2026).
|
||||||
|
|
||||||
|
Generische, turn-UEBERGREIFENDE Schleifenbremse — ergaenzt Hermes' native
|
||||||
|
tool_loop_guardrails, die (a) pro Turn zaehlen und (b) nur als Fehler
|
||||||
|
klassifizierte Ergebnisse sehen. Die teuer gelernte Luecke (SSH-Marathon 18.07.,
|
||||||
|
36 min / 75 Schleifen): ein Befehl laeuft "erfolgreich" durch, die AUSGABE sagt
|
||||||
|
aber immer dasselbe ("Permission denied") — der Agent variiert Befehle gegen
|
||||||
|
eine unsichtbare Wand, ueber Turn-Grenzen hinweg, und nichts stoppt ihn.
|
||||||
|
|
||||||
|
Prinzip (KEINE hartkodierten Fehler-Strings — Leitplanke des Commanders:
|
||||||
|
"wenn sich was aendert, muss es das SELBST erkennen"):
|
||||||
|
post_tool_call → Ergebnis normalisieren (Zahlen/Hex-IDs raus) → Muster-Hash.
|
||||||
|
Gleicher Hash beim gleichen Tool in Folge = Zaehler hoch.
|
||||||
|
Unbekannte Muster landen in neue-signaturen.jsonl —
|
||||||
|
Futter fuer Traum/Radar, um daraus Playbooks zu verdichten.
|
||||||
|
pre_tool_call → Zaehler >= 3 fuer dieses Tool: EINMAL mechanisch blocken
|
||||||
|
(Diagnose-Anweisung statt naechster Blindversuch), Zaehler
|
||||||
|
zuruecksetzen. Der Folgeversuch ist frei; laeuft er wieder
|
||||||
|
3x gegen dasselbe Muster, blockt es erneut.
|
||||||
|
|
||||||
|
Gezaehlt wird nur, wo Wiederholung wirklich Stillstand heisst: terminal IMMER
|
||||||
|
(dort verstecken sich die "erfolgreichen" Fehler), andere Tools nur bei
|
||||||
|
status=error. Lese-Tools deckt die native idempotent-Bremse ab.
|
||||||
|
|
||||||
|
Aufruf (hooks in config.yaml): `no-progress-bremse.py pre` bzw. `... post`.
|
||||||
|
State: ~/.hermes/state/no-progress/<session>.json (48 h Selbst-Aufraeumen).
|
||||||
|
Fail-open: jeder eigene Fehler -> {} (die Bremse darf nie selbst zur Wand werden).
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
STATE_DIR = os.path.expanduser("~/.hermes/state/no-progress")
|
||||||
|
SIG_LOG = os.path.join(STATE_DIR, "neue-signaturen.jsonl")
|
||||||
|
SEEN_FILE = os.path.join(STATE_DIR, "seen-sigs.json")
|
||||||
|
THRESHOLD = 3
|
||||||
|
STATE_TTL = 48 * 3600
|
||||||
|
|
||||||
|
BLOCK_REASON = (
|
||||||
|
"NO-PROGRESS-BREMSE: '{tool}' hat {n}x in Folge dasselbe Ergebnismuster geliefert — "
|
||||||
|
"das ist Stillstand, egal wie sehr die Befehle variieren. STOPP. "
|
||||||
|
"WICHTIG: Dein Befehl wurde NICHT ausgefuehrt. Das hier ist eine Bremse, KEIN Ergebnis — "
|
||||||
|
"schliesse daraus NICHTS ueber die Existenz oder den Zustand von Dateien, Verzeichnissen "
|
||||||
|
"oder Diensten (Vorfall 21.07.2026: ein Worker hielt diese Meldung fuer 'Verzeichnis nicht "
|
||||||
|
"vorhanden' und baute eine halbe Stunde Arbeit neu). "
|
||||||
|
"1) Benenne das Hindernis in EINEM Satz (was genau meldet das Ergebnis?). "
|
||||||
|
"2) Diagnose statt Variation — bei SSH/Zugriff ZUERST: Schluessel-Passphrase pruefen "
|
||||||
|
"(`ssh-keygen -y -f <key> </dev/null` — stilles 'Permission denied' = verschluesselter Key), "
|
||||||
|
"dann Gegenseite (Auth-Log, Dienststatus, Erreichbarkeit) LESEN statt raten. "
|
||||||
|
"3) Bleibt es unklar: Aufgabe als blocked/Karte mit dem EXAKTEN Ergebnistext an den "
|
||||||
|
"Commander uebergeben. Dein naechster Versuch ist frei — nutze ihn erst NACH der Diagnose."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rein LESENDE Befehle nie bremsen (21.07.2026). Die Bremse verlangt selbst „Diagnose
|
||||||
|
# statt Variation" — genau die besteht aus vielen kurzen Schau-Befehlen (ls/cat/git log),
|
||||||
|
# deren Ergebnisse sich stark aehneln. Die Signatur hielt das fuer Stillstand und blockte
|
||||||
|
# den naechsten Blick; der Worker las die Block-Meldung als „da ist nichts" und baute
|
||||||
|
# alles neu (Karte t_d26c3203, eine halbe Stunde Arbeit weggeworfen). Geschaut wird also
|
||||||
|
# ungebremst — gebremst wird nur, wer immer wieder dasselbe TUT.
|
||||||
|
# Bewusst strukturell (Befehls-Art), nicht per Fehlertext-Liste: die Bremse soll generisch
|
||||||
|
# bleiben und Neues selbst erkennen.
|
||||||
|
_LESEND = {"ls", "cat", "head", "tail", "wc", "stat", "find", "grep", "rg", "file",
|
||||||
|
"du", "df", "echo", "pwd", "tree", "readlink", "basename", "dirname", "which"}
|
||||||
|
_GIT_LESEND = {"status", "log", "show", "diff", "ls-remote", "ls-files", "rev-parse",
|
||||||
|
"merge-base", "describe"}
|
||||||
|
|
||||||
|
|
||||||
|
def _nur_lesend(cmd: str) -> bool:
|
||||||
|
"""True, wenn der Befehl ausschliesslich schaut — kein Schreiben, kein Eingriff."""
|
||||||
|
cmd = (cmd or "").strip()
|
||||||
|
if not cmd or ">" in cmd: # jede Umleitung = schreiben
|
||||||
|
return False
|
||||||
|
for teil in re.split(r"&&|\|\||;|\|", cmd):
|
||||||
|
worte = teil.split()
|
||||||
|
i = 0
|
||||||
|
while i < len(worte) and "=" in worte[i] and not worte[i].startswith("-"):
|
||||||
|
i += 1 # VAR=wert-Praefixe ueberspringen
|
||||||
|
if i >= len(worte):
|
||||||
|
return False
|
||||||
|
verb = os.path.basename(worte[i])
|
||||||
|
if verb == "git":
|
||||||
|
rest = [w for w in worte[i + 1:] if not w.startswith("-")]
|
||||||
|
# `git log …` oder `git -C <pfad> log …` → erstes oder zweites Wort zaehlt
|
||||||
|
if not (rest[:1] and rest[0] in _GIT_LESEND) and \
|
||||||
|
not (rest[1:2] and rest[1] in _GIT_LESEND):
|
||||||
|
return False
|
||||||
|
elif verb not in _LESEND:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_NUM_RE = re.compile(r"\d+")
|
||||||
|
_HEX_RE = re.compile(r"\b[0-9a-f]{8,}\b")
|
||||||
|
_WS_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_sig(tool: str, text: str) -> str:
|
||||||
|
t = (text or "")[:2000].lower()
|
||||||
|
t = _HEX_RE.sub("#", _NUM_RE.sub("#", t))
|
||||||
|
t = _WS_RE.sub(" ", t).strip()[:400]
|
||||||
|
return hashlib.sha1(f"{tool}|{t}".encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _session_key(p: dict) -> str:
|
||||||
|
sid = p.get("session_id") or p.get("parent_session_id") or ""
|
||||||
|
if sid:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_.-]", "_", str(sid))[:80]
|
||||||
|
return "cwd-" + hashlib.sha1(os.getcwd().encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _prune() -> None:
|
||||||
|
now = time.time()
|
||||||
|
for f in os.listdir(STATE_DIR):
|
||||||
|
fp = os.path.join(STATE_DIR, f)
|
||||||
|
if f.endswith(".json") and f != os.path.basename(SEEN_FILE):
|
||||||
|
if now - os.path.getmtime(fp) > STATE_TTL:
|
||||||
|
os.remove(fp)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: str) -> dict:
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(path: str, data: dict) -> None:
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else "post"
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
tool = str(payload.get("tool_name") or "")
|
||||||
|
if not tool:
|
||||||
|
print("{}")
|
||||||
|
return
|
||||||
|
# Delegations-Ausnahme (20.07.2026): worker.sh / fremdblick.sh sind der
|
||||||
|
# Fliessband-/Orchestrator-Motor — konzept-fliessband ruft sie pro Runde/Rolle
|
||||||
|
# WIEDERHOLT auf (verschiedene Prompts, aehnliche Prosa-Ergebnisse). Die Bremse
|
||||||
|
# bildet ihre Signatur aus dem Ergebnis-Text → sie hielt diese legitimen
|
||||||
|
# Mehrrunden faelschlich fuer eine Schleife und blockte (Pomodoro-IDE-Test lief
|
||||||
|
# 19 min dagegen an). Diese Helfer haben ihr EIGENES Runden-Limit (max 3) →
|
||||||
|
# von der generischen Bremse ausnehmen.
|
||||||
|
_ti = payload.get("tool_input")
|
||||||
|
_cmd = _ti.get("command") if isinstance(_ti, dict) else (_ti if isinstance(_ti, str) else "")
|
||||||
|
if tool == "terminal" and ("worker.sh" in str(_cmd) or "fremdblick.sh" in str(_cmd)):
|
||||||
|
print("{}")
|
||||||
|
return
|
||||||
|
# Schauen ist keine Schleife (siehe _nur_lesend): weder zaehlen noch blocken.
|
||||||
|
if tool == "terminal" and _nur_lesend(str(_cmd)):
|
||||||
|
print("{}")
|
||||||
|
return
|
||||||
|
os.makedirs(STATE_DIR, exist_ok=True)
|
||||||
|
sfile = os.path.join(STATE_DIR, _session_key(payload) + ".json")
|
||||||
|
state = _load(sfile)
|
||||||
|
tools = state.setdefault("tools", {})
|
||||||
|
|
||||||
|
if mode == "post":
|
||||||
|
status = str(payload.get("status") or "ok")
|
||||||
|
# terminal immer beobachten (dort tarnen sich Fehler als Erfolg),
|
||||||
|
# sonst nur echte Fehler — Lese-Tools regelt die native Bremse.
|
||||||
|
if tool != "terminal" and status != "error":
|
||||||
|
print("{}")
|
||||||
|
return
|
||||||
|
sig = _norm_sig(tool, str(payload.get("result") or ""))
|
||||||
|
entry = tools.get(tool) or {}
|
||||||
|
entry["count"] = entry.get("count", 0) + 1 if entry.get("sig") == sig else 1
|
||||||
|
entry["sig"] = sig
|
||||||
|
tools[tool] = entry
|
||||||
|
_save(sfile, state)
|
||||||
|
_prune()
|
||||||
|
seen = _load(SEEN_FILE)
|
||||||
|
sigs = seen.setdefault("sigs", [])
|
||||||
|
if sig not in sigs and status == "error" or (tool == "terminal" and sig not in sigs and entry["count"] >= 2):
|
||||||
|
sigs.append(sig)
|
||||||
|
del sigs[:-500]
|
||||||
|
_save(SEEN_FILE, seen)
|
||||||
|
sample = _WS_RE.sub(" ", str(payload.get("result") or ""))[:200]
|
||||||
|
with open(SIG_LOG, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps({"ts": int(time.time()), "tool": tool,
|
||||||
|
"sig": sig, "sample": sample}, ensure_ascii=False) + "\n")
|
||||||
|
print("{}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# mode == "pre"
|
||||||
|
entry = tools.get(tool) or {}
|
||||||
|
if entry.get("count", 0) >= THRESHOLD:
|
||||||
|
entry["count"] = 0
|
||||||
|
tools[tool] = entry
|
||||||
|
_save(sfile, state)
|
||||||
|
print(json.dumps({"decision": "block",
|
||||||
|
"reason": BLOCK_REASON.format(tool=tool, n=THRESHOLD)},
|
||||||
|
ensure_ascii=False))
|
||||||
|
return
|
||||||
|
print("{}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception:
|
||||||
|
print("{}")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pre_verify-Gate (P1-5, 09.07.2026): erinnert den Agenten VOR dem Fertigmelden an
|
||||||
|
# die zwei MC2-Pflichten aus AGENTS.md — py_compile-Gate und die dist-Falle.
|
||||||
|
# Antwort {"action":"continue","message":...} = Agent arbeitet weiter; {} = durchlassen.
|
||||||
|
set -u
|
||||||
|
payload="$(cat -)"
|
||||||
|
attempt=$(printf %s "$payload" | jq -r '.extra.attempt // 0' 2>/dev/null || echo 0)
|
||||||
|
# Ein-Schuss: nach dem ersten Nudge nicht erneut nerven (Docs-Empfehlung).
|
||||||
|
if [ "$attempt" != "0" ]; then printf '{}'; exit 0; fi
|
||||||
|
mapfile -t paths < <(printf %s "$payload" | jq -r '.extra.changed_paths[]? // empty' 2>/dev/null)
|
||||||
|
[ ${#paths[@]} -eq 0 ] && { printf '{}'; exit 0; }
|
||||||
|
msgs=()
|
||||||
|
|
||||||
|
# Gate 1: py_compile fuer alle geaenderten .py, die existieren
|
||||||
|
pyfail=""
|
||||||
|
for p in "${paths[@]}"; do
|
||||||
|
case "$p" in
|
||||||
|
*.py)
|
||||||
|
if [ -f "$p" ] && ! err=$(python3 -m py_compile "$p" 2>&1); then
|
||||||
|
pyfail="${pyfail}${p}: ${err}"$'\n'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -n "$pyfail" ]; then
|
||||||
|
msgs+=("py_compile-Gate ROT:"$'\n'"${pyfail}Erst fixen, dann fertigmelden.")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gate 2: dist-Falle — frontend/src geaendert, aber kein frontend/dist angefasst.
|
||||||
|
# Feuert NUR in Repos mit der MC2-Konvention (frontend/dist ist git-getrackt) —
|
||||||
|
# sonst nudgt es in beliebigen Spielordnern ohne Build (False-Positive, 09.07. beobachtet).
|
||||||
|
srcfile=""; dist=0
|
||||||
|
for p in "${paths[@]}"; do
|
||||||
|
case "$p" in
|
||||||
|
*/frontend/src/*|frontend/src/*) srcfile="$p" ;;
|
||||||
|
*/frontend/dist/*|frontend/dist/*) dist=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -n "$srcfile" ] && [ $dist -eq 0 ]; then
|
||||||
|
# Repo-Wurzel vom geaenderten File aus hochlaufen (max 8 Ebenen).
|
||||||
|
root=$(dirname "$srcfile"); n=0
|
||||||
|
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
|
||||||
|
root=$(dirname "$root"); n=$((n+1))
|
||||||
|
done
|
||||||
|
if [ -e "$root/.git" ] && git -C "$root" ls-files frontend/dist 2>/dev/null | head -1 | grep -q .; then
|
||||||
|
msgs+=("AGENTS.md-Gate: Du hast frontend/src geaendert, aber frontend/dist nicht. Die Box baut NICHT selbst: cd frontend && npm run build, dann das neue dist im selben Branch mit-committen — sonst zeigt die Box nach Merge+Deploy den alten Stand.")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gate 3 (R5, 15.07.): Orphan-Check — git-init-Repos ohne gemeinsamen Ursprung mit
|
||||||
|
# origin/main sind bei der Annahme technisch tot (3 Vorfaelle 12.–14.07., je eine
|
||||||
|
# Nacht Arbeit verloren). VOR dem Fertigmelden fangen, nicht erst im Auftragsbuch.
|
||||||
|
orphan=""; checked=""
|
||||||
|
for p in "${paths[@]}"; do
|
||||||
|
root=$(dirname "$p"); n=0
|
||||||
|
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
|
||||||
|
root=$(dirname "$root"); n=$((n+1))
|
||||||
|
done
|
||||||
|
[ -e "$root/.git" ] || continue
|
||||||
|
case " $checked " in *" $root "*) continue ;; esac
|
||||||
|
checked="$checked $root"
|
||||||
|
git -C "$root" remote get-url origin >/dev/null 2>&1 || continue
|
||||||
|
git -C "$root" fetch -q origin main >/dev/null 2>&1 || true
|
||||||
|
if git -C "$root" rev-parse --verify -q origin/main >/dev/null 2>&1 \
|
||||||
|
&& ! git -C "$root" merge-base HEAD origin/main >/dev/null 2>&1; then
|
||||||
|
orphan="${orphan}${root}"$'\n'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$orphan" ]; then
|
||||||
|
msgs+=("Orphan-Gate ROT: Dieses Repo hat KEINEN gemeinsamen Ursprung mit origin/main (git init statt Klonen?):"$'\n'"${orphan}So ist der Vorschlag bei der Annahme technisch TOT. Nicht fertigmelden — frisch VOLL klonen, Branch von origin/main abzweigen, Aenderungen ruebertragen.")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#msgs[@]} -eq 0 ]; then printf '{}'; exit 0; fi
|
||||||
|
joined=$(printf '%s\n\n' "${msgs[@]}")
|
||||||
|
jq --null-input --arg m "$joined" '{action:"continue", message:$m}'
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""pre_tool_call-Guard (Faden 8, 13.07.2026): sperrt Schreibzugriffe auf den LIVE-Checkout
|
||||||
|
~/mission-control-v2 und die Hermes-QUELLE ~/.hermes/hermes-agent HART. Loest Fallstrick #2
|
||||||
|
(Worker wandert in die falsche Ebene / lag Patches im Live-Checkout ab). Die native
|
||||||
|
is_write_denied deckt diese Pfade NICHT ab (nur Credentials/System) -> diese Luecke.
|
||||||
|
|
||||||
|
UNBEDINGT (kein Task-/Profil-Scope): KEIN legitimer Hermes-Agent-Flow schreibt je in diese
|
||||||
|
zwei Pfade — der Werkstatt-Worker arbeitet im WORKSPACE-Klon (<workspace>/mission-control-v2,
|
||||||
|
NICHT home-verankert -> erlaubt), Lucy fasst MC2-Quelle nie an, und die Wartungs-Pipeline
|
||||||
|
(deploy.sh, Annahme-Runner) laeuft als Shell ueber ssh, NICHT ueber Hermes-Tools. Ein
|
||||||
|
Task-Scope waere hier sogar gefaehrlich: Worker tragen als task_id den Session-Zeitstempel
|
||||||
|
(z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check liesse also alle
|
||||||
|
Worker-Schreibzugriffe durch (Bug 13.07., so entdeckt).
|
||||||
|
|
||||||
|
WICHTIG: Gesperrt wird der home-verankerte Live-Checkout ($HOME/mission-control-v2); der
|
||||||
|
blosse Name "mission-control-v2" zaehlt NUR, wenn er vom cwd aus in einen Tabu-Pfad aufloest
|
||||||
|
(V16 unten) — der Workspace-Klon (~/.hermes/kanban/workspaces/<id>/mission-control-v2) bleibt
|
||||||
|
frei, sonst waere die legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden
|
||||||
|
nur Schreib-Operationen.
|
||||||
|
|
||||||
|
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def out(obj):
|
||||||
|
sys.stdout.write(json.dumps(obj))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(sys.stdin.read() or "{}")
|
||||||
|
except Exception:
|
||||||
|
out({})
|
||||||
|
|
||||||
|
tool = payload.get("tool_name") or ""
|
||||||
|
ti = payload.get("tool_input") or {}
|
||||||
|
home = os.path.realpath(os.path.expanduser("~"))
|
||||||
|
LIVE = os.path.join(home, "mission-control-v2")
|
||||||
|
SRC = os.path.join(home, ".hermes", "hermes-agent")
|
||||||
|
# R2 (Review 15.07.): systemd-User-Units. Die Nacht-Werkstatt hat am 14.07. einen Override
|
||||||
|
# DIREKT scharf geschaltet — ging gut, verletzt aber "jede Code-Zeile ist Klick-pflichtig".
|
||||||
|
# Unit-/Override-Dateien werden nur noch ueber eine angenommene Karte installiert.
|
||||||
|
SYSD = os.path.join(home, ".config", "systemd", "user")
|
||||||
|
|
||||||
|
MSG = (
|
||||||
|
"TABU (Faden 8/R2): Schreiben an dieser Stelle ist fuer Worker gesperrt. Der Live-Checkout "
|
||||||
|
"~/mission-control-v2, die Hermes-Quelle ~/.hermes/hermes-agent und die systemd-User-Units "
|
||||||
|
"~/.config/systemd/user duerfen NIE direkt veraendert werden. Arbeite im FRISCHEN Klon "
|
||||||
|
"deines Task-Workspaces; MC2-Aenderungen kommen als Vorschlags-Branch, systemd-Unit-/"
|
||||||
|
"Override-Dateien werden nur ueber eine angenommene Karte installiert, Hermes-Verhalten "
|
||||||
|
"nur ueber Config/SOUL/Skill/Hook/MCP. Siehe docs/wissen/GRENZEN.md."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def block():
|
||||||
|
out({"action": "block", "message": MSG})
|
||||||
|
|
||||||
|
|
||||||
|
def under_tabu(path):
|
||||||
|
rp = os.path.realpath(path)
|
||||||
|
return any(rp == t or rp.startswith(t + os.sep) for t in (LIVE, SRC, SYSD))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Datei-Tools: Zielpfad kanonisch pruefen (zuverlaessig) -------------------------
|
||||||
|
if tool in ("write_file", "patch"):
|
||||||
|
fp = ti.get("path") or ti.get("file_path") or ti.get("filepath") or ""
|
||||||
|
if not fp:
|
||||||
|
out({})
|
||||||
|
fp = os.path.expanduser(fp)
|
||||||
|
cwd = payload.get("cwd") or ""
|
||||||
|
if not os.path.isabs(fp) and cwd:
|
||||||
|
fp = os.path.join(cwd, fp)
|
||||||
|
if under_tabu(fp):
|
||||||
|
block()
|
||||||
|
out({})
|
||||||
|
|
||||||
|
# --- Terminal: nur bei Schreib-Operation, die auf einen Tabu-Pfad zielt -------------
|
||||||
|
if tool == "terminal":
|
||||||
|
cmd = ti.get("command") or ""
|
||||||
|
if not cmd:
|
||||||
|
out({})
|
||||||
|
cwd_raw = payload.get("cwd") or ""
|
||||||
|
cwd = os.path.realpath(os.path.expanduser(cwd_raw)) if cwd_raw else ""
|
||||||
|
# R5 (Review 15.07.): `git init` im Kanban-Workspace hart blocken. Drei Naechte in
|
||||||
|
# Folge bauten Worker git-init-Orphans ohne gemeinsamen Ursprung mit main
|
||||||
|
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
|
||||||
|
# ~14.07.) — solche Vorschlaege sind bei der Annahme technisch tot, die Nacht-Arbeit
|
||||||
|
# verpufft. Kein legitimer Worker-Flow braucht git init; MC2-/Lucy-Arbeit = VOLL
|
||||||
|
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
|
||||||
|
# projekt-start neue Repos initialisieren).
|
||||||
|
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
|
||||||
|
if cwd_raw.startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
|
||||||
|
out({"action": "block", "message": (
|
||||||
|
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
|
||||||
|
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
|
||||||
|
"12.–14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
|
||||||
|
"http://192.168.178.153:3000/Hitonabi/mission-control-v2 "
|
||||||
|
"(Credentials liegen in ~/.git-credentials), dann Branch von origin/main abzweigen.")})
|
||||||
|
# Schreib-Subkommandos von git (V16: + clone — `git clone URL ~/mission-control-v2`
|
||||||
|
# rutschte vorher an (a) UND (d) vorbei).
|
||||||
|
git_write = (
|
||||||
|
r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*"
|
||||||
|
r"(commit|push|reset|checkout|switch|merge|add|rm|mv|clean|stash|rebase|apply|restore|init|clone|remote|config)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
# V16 (Vorfall 16.07.2026): Werkstatt-Worker t_7a05992a ersetzte um 03:59 den Live-
|
||||||
|
# Checkout mit cwd=$HOME und RELATIVEN Pfaden (`rm -rf mission-control-v2 && git
|
||||||
|
# clone ...`) — keine home-verankerte Tabu-Form im Kommandotext, der Guard sah nur
|
||||||
|
# Strings und liess durch (.venv weg -> Gateway im 203/EXEC-Crash-Loop). Drei Stopfen:
|
||||||
|
# (1) cwd liegt IN einem Tabu-Pfad -> jede Schreib-Operation blocken (relative Ziele
|
||||||
|
# treffen zwangslaeufig den Tabu-Baum). Lesen bleibt erlaubt.
|
||||||
|
if cwd and under_tabu(cwd):
|
||||||
|
if (re.search(git_write, cmd) or re.search(r"(?:^|[^\w])sed\s+-i", cmd)
|
||||||
|
or re.search(r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s", cmd)
|
||||||
|
or re.search(r">>?\s*(?![/&])", cmd)
|
||||||
|
or re.search(r"(?:^|[^\w])tee\s+(?:-a\s+)?(?!/)", cmd)):
|
||||||
|
block()
|
||||||
|
# (2) cwd == $HOME -> `git clone`/`git init`, deren Ziel direkt in $HOME landet,
|
||||||
|
# blocken (exakt der Vorfalls-Hergang). Worker klonen im Task-Workspace; ein
|
||||||
|
# explizites Ziel AUSSERHALB von $HOME (z.B. Unterordner) bleibt erlaubt.
|
||||||
|
m = re.search(r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*(?:clone|init)\b([^|&;]*)", cmd)
|
||||||
|
if cwd == home and m:
|
||||||
|
toks = m.group(1).split()
|
||||||
|
dest = toks[-1] if toks else ""
|
||||||
|
# kein brauchbares Ziel-Argument (leer, Option oder URL) -> implizit direkt in $HOME
|
||||||
|
implicit = (not dest or dest.startswith("-")
|
||||||
|
or re.search(r"://|^git@|^[\w.-]+@[\w.-]+:|\.git$", dest))
|
||||||
|
if (implicit or under_tabu(os.path.join(home, dest))
|
||||||
|
or os.path.dirname(os.path.realpath(os.path.join(home, dest))) == home):
|
||||||
|
out({"action": "block", "message": (
|
||||||
|
"TABU (V16): `git clone`/`git init` direkt in $HOME ist gesperrt — so wurde "
|
||||||
|
"am 16.07. der Live-Checkout ~/mission-control-v2 durch einen frischen Klon "
|
||||||
|
"ersetzt (.venv weg, Gateway tot). Klone/initialisiere in deinem Task-"
|
||||||
|
"Workspace (~/.hermes/kanban/workspaces/<task>/...) oder gib ein Ziel "
|
||||||
|
"ausserhalb von $HOME an.")})
|
||||||
|
# Home-verankerte Tabu-Formen. Der Workspace-Klon
|
||||||
|
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
|
||||||
|
tabu_forms = [
|
||||||
|
LIVE, "~/mission-control-v2", "$HOME/mission-control-v2", "${HOME}/mission-control-v2",
|
||||||
|
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
|
||||||
|
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
|
||||||
|
]
|
||||||
|
# (3) cwd == Vorfahr eines Tabu-Pfads (z.B. $HOME) -> auch die RELATIVE Nennung des
|
||||||
|
# Tabu-Ziels (mission-control-v2, .hermes/hermes-agent, .config/systemd/user)
|
||||||
|
# zaehlt als Treffer fuer die Schreib-Checks unten. Die Lookarounds stellen
|
||||||
|
# sicher, dass der Name ein eigenes Argument beginnt — als Teil eines laengeren
|
||||||
|
# Pfades (z.B. .hermes/kanban/workspaces/<id>/mission-control-v2) matcht er
|
||||||
|
# NICHT, die legitime Workspace-Arbeit bleibt frei.
|
||||||
|
rel_pats = []
|
||||||
|
if cwd:
|
||||||
|
for t in (LIVE, SRC, SYSD):
|
||||||
|
if t.startswith(cwd + os.sep):
|
||||||
|
rel = os.path.relpath(t, cwd).replace(os.sep, "/")
|
||||||
|
rel_pats.append(r"(?<![\w./@-])(?:\./)?" + re.escape(rel) + r"(?![\w.-])")
|
||||||
|
tabu_alt = "(?:" + "|".join([re.escape(t) for t in tabu_forms] + rel_pats) + ")"
|
||||||
|
if not re.search(tabu_alt, cmd):
|
||||||
|
out({}) # kein Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
|
||||||
|
|
||||||
|
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
|
||||||
|
if re.search(git_write, cmd):
|
||||||
|
block()
|
||||||
|
# (b) sed -i, das einen Tabu-Pfad anfasst.
|
||||||
|
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
|
||||||
|
block()
|
||||||
|
# (c) Umleitung > / >> / tee, die auf einen Tabu-Pfad ZIELT (nicht 2>/dev/null o.ae.).
|
||||||
|
if re.search(r"(?:>>?|(?:^|[^\w])tee\s+(?:-a\s+)?)\s*" + tabu_alt, cmd):
|
||||||
|
block()
|
||||||
|
# (d) destruktiv/kopierend mit Tabu-Pfad im selben Kommando-Segment.
|
||||||
|
if re.search(
|
||||||
|
r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s+[^|&;]*" + tabu_alt,
|
||||||
|
cmd,
|
||||||
|
):
|
||||||
|
block()
|
||||||
|
out({})
|
||||||
|
|
||||||
|
out({})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
|
||||||
|
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
|
||||||
|
# Gitea-Actions-Runner (arcane-VM).
|
||||||
|
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
|
||||||
|
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
|
||||||
|
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
|
||||||
|
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
|
||||||
|
name: Ampel
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ampel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
|
||||||
|
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
|
||||||
|
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
|
||||||
|
set -u +e
|
||||||
|
rot=0
|
||||||
|
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
|
||||||
|
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
|
||||||
|
|
||||||
|
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
|
||||||
|
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$py_datei" ]; then
|
||||||
|
echo "== Python erkannt =="
|
||||||
|
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
|
||||||
|
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
|
||||||
|
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
|
||||||
|
ruff check . || rot=1
|
||||||
|
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
|
||||||
|
while IFS= read -r req; do
|
||||||
|
[ -n "$req" ] || continue
|
||||||
|
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
|
||||||
|
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
|
||||||
|
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
|
||||||
|
python -m compileall -q . || rot=1
|
||||||
|
echo "-- Pytest (keine Tests gefunden = ROT)"
|
||||||
|
ec=0; pytest -q || ec=$?
|
||||||
|
if [ $ec -eq 5 ]; then
|
||||||
|
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
|
||||||
|
rot=1
|
||||||
|
elif [ $ec -ne 0 ]; then
|
||||||
|
rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$pkg_dateien" ]; then
|
||||||
|
echo "== Node/TypeScript erkannt =="
|
||||||
|
while IFS= read -r pkg; do
|
||||||
|
[ -n "$pkg" ] || continue
|
||||||
|
d=$(dirname "$pkg")
|
||||||
|
echo "-- $d"
|
||||||
|
if [ ! -f "$d/package-lock.json" ]; then
|
||||||
|
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
|
||||||
|
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
|
||||||
|
rot=1; continue
|
||||||
|
fi
|
||||||
|
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
|
||||||
|
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
|
||||||
|
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
|
||||||
|
done <<< "$pkg_dateien"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $rot -ne 0 ]; then
|
||||||
|
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||||
|
fi
|
||||||
|
exit $rot
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
|
||||||
|
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
|
||||||
|
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
|
||||||
|
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
|
||||||
|
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
|
||||||
|
|
||||||
|
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
|
||||||
|
[ -n "$TOKEN" ] || exit 0
|
||||||
|
|
||||||
|
python3 - "$TOKEN" "$STATE" <<'PY'
|
||||||
|
import json, sys, urllib.request, subprocess, os
|
||||||
|
|
||||||
|
token, state_pfad = sys.argv[1], sys.argv[2]
|
||||||
|
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
||||||
|
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
|
||||||
|
BASE = "http://192.168.178.153:3000/api/v1"
|
||||||
|
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
|
||||||
|
|
||||||
|
|
||||||
|
def api(pfad):
|
||||||
|
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
gesehen = set(open(state_pfad).read().split())
|
||||||
|
except OSError:
|
||||||
|
gesehen = set()
|
||||||
|
|
||||||
|
neu = []
|
||||||
|
try:
|
||||||
|
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
|
||||||
|
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
|
||||||
|
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
|
||||||
|
repos = api("/repos/search?limit=50&private=true").get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for repo in repos:
|
||||||
|
full = repo.get("full_name", "")
|
||||||
|
if not full or repo.get("archived"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
d = api(f"/repos/{full}/actions/tasks?limit=1")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
|
||||||
|
for t in rows[:1]:
|
||||||
|
kennung = f"{full}#{t.get('id')}"
|
||||||
|
status = t.get("status")
|
||||||
|
if status == "failure" and kennung not in gesehen:
|
||||||
|
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
|
||||||
|
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
|
||||||
|
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
|
||||||
|
headers={"Authorization": "token " + token})
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
log_ende = r.read().decode(errors="replace")[-4000:]
|
||||||
|
wirklich_rot = "Job succeeded" not in log_ende
|
||||||
|
except Exception:
|
||||||
|
wirklich_rot = True
|
||||||
|
if wirklich_rot:
|
||||||
|
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
|
||||||
|
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
|
||||||
|
# darf nicht schon als gesehen gelten.
|
||||||
|
if status in ("failure", "success", "cancelled", "skipped"):
|
||||||
|
gesehen.add(kennung)
|
||||||
|
|
||||||
|
if neu:
|
||||||
|
print("🔴 CI-AMPEL ROT:")
|
||||||
|
for full, sha, name in neu:
|
||||||
|
print(f"• {full} @ {sha} ({name})")
|
||||||
|
print(f" {WEB}/{full}/actions")
|
||||||
|
try:
|
||||||
|
subprocess.run([
|
||||||
|
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
|
||||||
|
"--title", f"CI rot: {full} @ {sha}",
|
||||||
|
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
|
||||||
|
], capture_output=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
|
||||||
|
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
||||||
|
|
||||||
|
with open(state_pfad, "w") as f:
|
||||||
|
f.write("\n".join(sorted(gesehen)))
|
||||||
|
PY
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
|
||||||
|
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
|
||||||
|
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
|
||||||
|
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
|
||||||
|
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
|
||||||
|
#
|
||||||
|
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
|
||||||
|
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
|
||||||
|
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
|
||||||
|
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
|
||||||
|
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
|
||||||
|
#
|
||||||
|
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
|
||||||
|
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||||
|
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
||||||
|
API="${MC_API:-http://127.0.0.1:9001}"
|
||||||
|
SLUG="$(echo "$BRANCH" | tr '/' '-')"
|
||||||
|
WT="/tmp/annahme-$SLUG"
|
||||||
|
LOG="/tmp/annahme-$SLUG.log"
|
||||||
|
|
||||||
|
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
|
||||||
|
|
||||||
|
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
|
||||||
|
status(){ # $1=state $2=detail
|
||||||
|
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
|
||||||
|
import json, os, sys, time
|
||||||
|
p, branch, state, detail = sys.argv[1:5]
|
||||||
|
try:
|
||||||
|
d = json.load(open(p, encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
d = {}
|
||||||
|
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
|
||||||
|
tmp = p + ".tmp"
|
||||||
|
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
|
||||||
|
os.replace(tmp, p)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
fail(){
|
||||||
|
status "fehlgeschlagen" "$1"
|
||||||
|
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
|
||||||
|
cleanup_wt
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
status "laeuft" "Merge wird vorbereitet"
|
||||||
|
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
|
||||||
|
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
|
||||||
|
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|
||||||
|
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
|
||||||
|
|
||||||
|
PRE="$(git rev-parse origin/main)"
|
||||||
|
|
||||||
|
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
|
||||||
|
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
|
||||||
|
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|
||||||
|
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
|
||||||
|
|
||||||
|
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
|
||||||
|
cleanup_wt
|
||||||
|
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
|
||||||
|
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
|
||||||
|
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||||
|
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
|
||||||
|
git -C "$WT" merge --abort 2>/dev/null || true
|
||||||
|
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
|
||||||
|
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
|
||||||
|
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
|
||||||
|
WT2="/tmp/annahme-rebase-$SLUG"
|
||||||
|
git worktree remove --force "$WT2" 2>/dev/null || true
|
||||||
|
REBASED=""
|
||||||
|
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
|
||||||
|
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||||
|
rebase origin/main >>"$LOG" 2>&1; then
|
||||||
|
REBASED="$(git -C "$WT2" rev-parse HEAD)"
|
||||||
|
else
|
||||||
|
git -C "$WT2" rebase --abort 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
git worktree remove --force "$WT2" 2>/dev/null || true
|
||||||
|
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
|
||||||
|
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||||
|
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|
||||||
|
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
|
||||||
|
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
|
||||||
|
if [ -n "${PYS// /}" ]; then
|
||||||
|
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
|
||||||
|
fi
|
||||||
|
|
||||||
|
status "laeuft" "Push nach main läuft"
|
||||||
|
PUSHED=0
|
||||||
|
for _ in 1 2 3; do
|
||||||
|
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
|
||||||
|
cleanup_wt
|
||||||
|
|
||||||
|
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
|
||||||
|
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
|
||||||
|
|
||||||
|
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
|
||||||
|
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
|
||||||
|
DEPLOY_OK=1
|
||||||
|
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
|
||||||
|
|
||||||
|
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
|
||||||
|
HEALTH=0
|
||||||
|
if [ "$DEPLOY_OK" = 1 ]; then
|
||||||
|
for _ in $(seq 1 18); do
|
||||||
|
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEALTH" = 1 ]; then
|
||||||
|
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
|
||||||
|
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
|
||||||
|
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
|
||||||
|
status "eingespielt" "Deploy grün, Health-Check bestanden"
|
||||||
|
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
|
||||||
|
status "rollback" "Health rot — automatischer Revert läuft"
|
||||||
|
git fetch -q origin || true
|
||||||
|
cleanup_wt
|
||||||
|
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
|
||||||
|
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
|
||||||
|
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
|
||||||
|
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
|
||||||
|
cleanup_wt
|
||||||
|
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
|
||||||
|
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
|
||||||
|
R=0
|
||||||
|
for _ in $(seq 1 12); do
|
||||||
|
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [ "$R" = 1 ]; then
|
||||||
|
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
|
||||||
|
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cleanup_wt
|
||||||
|
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
|
||||||
|
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
|
||||||
|
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|
||||||
|
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
|
||||||
|
exit 2
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wöchentlicher Auto-Update-Lauf der Box (Autonomie E2). Läuft als User via
|
||||||
|
# mc2-autoupdate.timer (So 04:30, nach dem 03:30-Backup) oder manuell.
|
||||||
|
#
|
||||||
|
# Prinzip „Haushaltsgerät": Fremd-Software (Router → Engine → Hermes) updatet sich
|
||||||
|
# selbst über die BESTEHENDEN Pfade (update-swap.sh / update-engine.sh / hermes-update-Job).
|
||||||
|
# Jede Ebene: Update-Check → Update → Postcheck. Rot ⇒ Rollback + SELBST-PINNING im
|
||||||
|
# Pin-Register + Telegram-Meldung. Grün ⇒ Telegram „eingespielt X→Y".
|
||||||
|
# Gepinnte Ebenen werden übersprungen, bis der Pin manuell gelöst wird.
|
||||||
|
#
|
||||||
|
# OS-Ebene: bewusst NICHT hier — unattended-upgrades (nur Security) wird in der
|
||||||
|
# einmaligen sudo-Session eingerichtet (siehe deploy/sudoers-mc2-autonomie).
|
||||||
|
# Modelle: NIE automatisch (User entscheidet nach Bench, Etappe E5).
|
||||||
|
#
|
||||||
|
# Router/Engine laufen als root: braucht die NOPASSWD-Regel aus
|
||||||
|
# deploy/sudoers-mc2-autonomie. Fehlt sie, wird die Ebene übersprungen + gemeldet.
|
||||||
|
set -uo pipefail # bewusst KEIN -e: jede Ebene wird kontrolliert abgearbeitet
|
||||||
|
|
||||||
|
API="${MC_API:-http://127.0.0.1:9001}"
|
||||||
|
PINS="${MC_PINS_FILE:-/srv/models/mc2-pins.json}"
|
||||||
|
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
HERMES_DIR="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
|
||||||
|
BACKUP_DIR="${MC_BACKUP_DIR:-/srv/models/mc2-backups}"
|
||||||
|
JOB_TIMEOUT="${MC_AUTOUPDATE_JOB_TIMEOUT:-1200}" # Sekunden für den Hermes-Job
|
||||||
|
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
|
||||||
|
say(){ echo "[autoupdate] $*"; }
|
||||||
|
notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; }
|
||||||
|
|
||||||
|
SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung
|
||||||
|
|
||||||
|
# ── Pin-Register ─────────────────────────────────────────────────────────────
|
||||||
|
pins_init(){ [ -f "$PINS" ] || echo '{}' > "$PINS"; }
|
||||||
|
is_pinned(){ jq -e --arg k "$1" '.[$k].pinned == true' "$PINS" >/dev/null 2>&1; }
|
||||||
|
pin_info(){ jq -r --arg k "$1" '.[$k] | "\(.version // "?") seit \(.datum // "?") (\(.grund // "?"))"' "$PINS" 2>/dev/null; }
|
||||||
|
set_pin(){ # $1 komponente $2 version(known-good) $3 grund
|
||||||
|
local tmp; tmp="$(mktemp)"
|
||||||
|
jq --arg k "$1" --arg v "$2" --arg g "$3" --arg d "$(date +%F)" \
|
||||||
|
'.[$k] = {pinned: true, version: $v, grund: $g, datum: $d}' "$PINS" > "$tmp" && mv "$tmp" "$PINS"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helfer ───────────────────────────────────────────────────────────────────
|
||||||
|
details(){ curl -sf --max-time 60 "$API/api/maintenance/update-details?kind=$1"; }
|
||||||
|
|
||||||
|
# „darf ich das PASSWORTLOS?" — sudo -n -l <cmd> reicht nicht (Exit 0 auch wenn nur
|
||||||
|
# MIT Passwort erlaubt); deshalb explizit die NOPASSWD-Zeilen der Whitelist greppen.
|
||||||
|
sudo_ok(){ sudo -n -l 2>/dev/null | grep -F "NOPASSWD" | grep -qF "$1"; }
|
||||||
|
|
||||||
|
# Router und Engine teilen denselben Ablauf: root-Skript mit eingebautem
|
||||||
|
# Postcheck+Rollback (Exit 0 grün / 1 zurückgerollt / 2 kaputt).
|
||||||
|
run_root_update(){ # $1 komponente(swap|engine) $2 skript $3 anzeigename $4 installed $5 latest
|
||||||
|
local comp="$1" script="$2" name="$3" installed="$4" latest="$5" rc
|
||||||
|
say "$name: Update $installed → $latest wird eingespielt…"
|
||||||
|
sudo -n /usr/bin/bash "$SRC_DIR/$script"; rc=$?
|
||||||
|
case "$rc" in
|
||||||
|
0) SUMMARY+=("$name: $installed → $latest eingespielt, Stack-Check grün.")
|
||||||
|
notify "$name aktualisiert: $installed → $latest. Stack-Check grün, alles läuft." ;;
|
||||||
|
1) set_pin "$comp" "$installed" "Update auf $latest zerschoss den Stack-Check; Rollback grün"
|
||||||
|
SUMMARY+=("$name: $latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT.")
|
||||||
|
notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;;
|
||||||
|
*) SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
|
||||||
|
notify "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
check_layer_root(){ # $1 komponente $2 skript $3 anzeigename $4 details-kind
|
||||||
|
local comp="$1" script="$2" name="$3" kind="$4" det installed latest
|
||||||
|
if is_pinned "$comp"; then
|
||||||
|
say "$name: GEPINNT ($(pin_info "$comp")) — übersprungen."
|
||||||
|
SUMMARY+=("$name: gepinnt, übersprungen.")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; SUMMARY+=("$name: Check fehlgeschlagen."); return; }
|
||||||
|
installed="$(jq -r '.installed_build // empty' <<<"$det")"
|
||||||
|
latest="$(jq -r '.latest_build // empty' <<<"$det")"
|
||||||
|
if [ -z "$installed" ] || [ -z "$latest" ]; then
|
||||||
|
say "$name: Versionen nicht ermittelbar (installed='$installed' latest='$latest')."
|
||||||
|
SUMMARY+=("$name: Versions-Check unklar, nichts getan.")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ "$latest" -le "$installed" ] 2>/dev/null; then
|
||||||
|
say "$name: aktuell ($installed)."
|
||||||
|
SUMMARY+=("$name: aktuell ($installed).")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if ! sudo_ok "$script"; then
|
||||||
|
say "$name: Update $installed → $latest verfügbar, aber sudo-Freischaltung fehlt."
|
||||||
|
SUMMARY+=("$name: Update $installed → $latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren).")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
run_root_update "$comp" "$script" "$name" "$installed" "$latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Hermes-Agent (User-Space, via MC2-Job + Polling; Rollback machen WIR) ────
|
||||||
|
check_hermes(){
|
||||||
|
local name="Hermes-Agent" det behind old_head resp job_id state t
|
||||||
|
if is_pinned hermes; then
|
||||||
|
say "$name: GEPINNT ($(pin_info hermes)) — übersprungen."
|
||||||
|
SUMMARY+=("$name: gepinnt, übersprungen.")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
det="$(details hermes)" || { SUMMARY+=("$name: Check fehlgeschlagen."); return; }
|
||||||
|
behind="$(jq -r '.behind // 0' <<<"$det")"
|
||||||
|
if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then
|
||||||
|
say "$name: aktuell."
|
||||||
|
SUMMARY+=("$name: aktuell.")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
old_head="$(git -C "$HERMES_DIR" rev-parse HEAD 2>/dev/null)"
|
||||||
|
say "$name: $behind Commits hinterher — Update-Job startet (Backup→update→doctor→Restart→Gehirn-Check)…"
|
||||||
|
resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update")" || { SUMMARY+=("$name: Job-Start fehlgeschlagen."); return; }
|
||||||
|
job_id="$(jq -r '.job_id // empty' <<<"$resp")"
|
||||||
|
if [ -z "$job_id" ]; then
|
||||||
|
say "$name: kein Job gestartet: $resp"
|
||||||
|
SUMMARY+=("$name: Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null)).")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
t=0
|
||||||
|
while [ "$t" -lt "$JOB_TIMEOUT" ]; do
|
||||||
|
state="$(curl -sf --max-time 15 "$API/api/jobs" | jq -r --arg id "$job_id" '.jobs[] | select(.id==$id) | .state' 2>/dev/null)"
|
||||||
|
case "$state" in done|failed|canceled) break ;; esac
|
||||||
|
sleep 10; t=$((t + 10))
|
||||||
|
done
|
||||||
|
if [ "$state" = "done" ]; then
|
||||||
|
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
|
||||||
|
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
|
||||||
|
# Autonomie 0g. Nur Config, reversibel, hart gegatet; scheitert es, fällt es sauber in den Rollback.
|
||||||
|
say "$name: Job endete '$state' — versuche Selbstreparatur (Config) vor dem Rollback…"
|
||||||
|
local sr_out sr_rc sugg=""
|
||||||
|
sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$?
|
||||||
|
echo "$sr_out"
|
||||||
|
if [ "$sr_rc" -eq 0 ]; then
|
||||||
|
SUMMARY+=("$name: Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback).")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)"
|
||||||
|
|
||||||
|
# Rollback: Code auf alten Stand, Config aus dem frischen Backup, Neustart, Gehirn-Check.
|
||||||
|
say "$name: Selbstreparatur griff nicht — ROLLBACK auf $old_head…"
|
||||||
|
if [ -n "$old_head" ]; then
|
||||||
|
git -C "$HERMES_DIR" reset --hard "$old_head" >/dev/null 2>&1
|
||||||
|
local bak stage
|
||||||
|
bak="$(ls -1t "$BACKUP_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1)"
|
||||||
|
if [ -n "$bak" ]; then
|
||||||
|
stage="$(mktemp -d)"
|
||||||
|
tar -xzf "$bak" -C "$stage" ./hermes 2>/dev/null || tar -xzf "$bak" -C "$stage" hermes 2>/dev/null
|
||||||
|
[ -f "$stage/hermes/config.yaml" ] && cp -a "$stage/hermes/config.yaml" "$HOME/.hermes/config.yaml"
|
||||||
|
[ -f "$stage/hermes/.env" ] && cp -a "$stage/hermes/.env" "$HOME/.hermes/.env"
|
||||||
|
rm -rf "$stage"
|
||||||
|
fi
|
||||||
|
systemctl --user restart hermes-gateway; sleep 6
|
||||||
|
if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then
|
||||||
|
set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün"
|
||||||
|
SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.")
|
||||||
|
notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+
|
||||||
|
|
||||||
|
Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!")
|
||||||
|
notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Lauf ─────────────────────────────────────────────────────────────────────
|
||||||
|
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
|
||||||
|
pins_init
|
||||||
|
if ! curl -sf --max-time 20 "$API/api/health" >/dev/null; then
|
||||||
|
notify "Auto-Update abgebrochen: MC2-API ($API) nicht erreichbar — bitte Box prüfen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_layer_root swap update-swap.sh "Router (llama-swap)" swap
|
||||||
|
check_layer_root engine update-engine.sh "Engine (llama.cpp)" engine
|
||||||
|
check_hermes
|
||||||
|
|
||||||
|
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
$(printf '• %s\n' "${SUMMARY[@]}")"
|
||||||
|
say "Fertig."
|
||||||
+80
-2
@@ -4,15 +4,22 @@
|
|||||||
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
|
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
|
||||||
#
|
#
|
||||||
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
|
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
|
||||||
# /etc/llama-swap/config.yaml
|
# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.)
|
||||||
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
|
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
|
||||||
#
|
#
|
||||||
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
|
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
|
||||||
|
#
|
||||||
|
# Off-Box (C12): nach dem lokalen Tarball wird der Backup-Ordner per rsync auf ein
|
||||||
|
# ZWEITES Gerät gespiegelt (Default: Proxmox-Host), damit ein Plattenausfall der Box
|
||||||
|
# nicht auch die Backups mitnimmt. Fehlschlag ist NICHT fatal — das lokale Backup gilt.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
|
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
|
||||||
DEST_DIR="$MODELS_DIR/mc2-backups"
|
DEST_DIR="$MODELS_DIR/mc2-backups"
|
||||||
RETAIN="${MC_BACKUP_RETAIN:-14}"
|
RETAIN="${MC_BACKUP_RETAIN:-14}"
|
||||||
|
# Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht.
|
||||||
|
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
|
||||||
|
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
|
||||||
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
|
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
|
||||||
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
|
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
|
||||||
HERMES="${HERMES_HOME:-$HOME/.hermes}"
|
HERMES="${HERMES_HOME:-$HOME/.hermes}"
|
||||||
@@ -27,12 +34,65 @@ mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
|
|||||||
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
|
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
|
||||||
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
|
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
|
||||||
[ -f "$LSWAP" ] && cp -a "$LSWAP" "$STAGE/llama-swap/" || true
|
[ -f "$LSWAP" ] && cp -a "$LSWAP" "$STAGE/llama-swap/" || true
|
||||||
|
# Hermes-Desktop-/Gate-Infrastruktur (09.07.2026) — ohne sie verliert ein Restore die
|
||||||
|
# Desktop-Verbindung und die pre_verify-Gates STILL (config.yaml referenziert die Hooks):
|
||||||
|
[ -d "$HERMES/agent-hooks" ] && cp -a "$HERMES/agent-hooks" "$STAGE/hermes/agent-hooks" || true
|
||||||
|
[ -f "$HERMES/shell-hooks-allowlist.json" ] && cp -a "$HERMES/shell-hooks-allowlist.json" "$STAGE/hermes/" || true
|
||||||
|
[ -f "$HERMES/desktop-gateway-token" ] && cp -a "$HERMES/desktop-gateway-token" "$STAGE/hermes/" || true
|
||||||
|
[ -f "$HERMES/pc-paths.yaml" ] && cp -a "$HERMES/pc-paths.yaml" "$STAGE/hermes/" || true
|
||||||
|
_TOKDROPIN="$HOME/.config/systemd/user/hermes-builtin-ui.service.d/session-token.conf"
|
||||||
|
[ -f "$_TOKDROPIN" ] && { mkdir -p "$STAGE/hermes/systemd-dropins"; cp -a "$_TOKDROPIN" "$STAGE/hermes/systemd-dropins/"; } || true
|
||||||
|
# Cron-Jobs + Radar-State (10.07.2026) — jobs.json liegt AUSSERHALB des Repos; ohne diese
|
||||||
|
# Zeilen verliert ein Restore ALLE Hermes-Crons (traum/Briefing/Monats-Review/Chef-Gutachter/
|
||||||
|
# Release-Radar) still:
|
||||||
|
[ -d "$HERMES/cron" ] && cp -a "$HERMES/cron" "$STAGE/hermes/cron" || true
|
||||||
|
[ -d "$HERMES/state" ] && cp -a "$HERMES/state" "$STAGE/hermes/state" || true
|
||||||
|
|
||||||
|
# --- Known-Good-Manifest -----------------------------------------------------
|
||||||
|
# Versions-Schnappschuss des LAUFENDEN Stacks: pip freeze je venv, Engine-Build,
|
||||||
|
# Git-Stände, Modell-Liste. Zweck: die requirements sind teils ungepinnt (>=) —
|
||||||
|
# bei einer Neuinstallation sagen DIESE Listen, welche Versionen nachweislich
|
||||||
|
# zusammen liefen. Rein informativ, restore.sh fasst es nicht an.
|
||||||
|
# Alles best-effort: ein fehlendes venv/Tool darf das Backup nie abbrechen.
|
||||||
|
KG="$STAGE/known-good"
|
||||||
|
mkdir -p "$KG"
|
||||||
|
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||||
|
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
|
||||||
|
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
||||||
|
|
||||||
|
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
|
||||||
|
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|
||||||
|
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
|
||||||
|
"$1" -m pip freeze > "$2" 2>/dev/null \
|
||||||
|
|| "$UV" pip freeze --python "$1" > "$2" 2>/dev/null \
|
||||||
|
|| echo "pip freeze fehlgeschlagen: $1" > "$2"
|
||||||
|
}
|
||||||
|
freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt"
|
||||||
|
freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt"
|
||||||
|
freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "known-good-Stand vom $TS auf $(hostname)"
|
||||||
|
echo "os : $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-?}") · kernel $(uname -r)"
|
||||||
|
echo "mc2 : $(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo '?') ($(git -C "$SRC" log -1 --format=%cs 2>/dev/null || echo '?'))"
|
||||||
|
echo "hermes-agent : $(git -C "$HERMES_AGENT" rev-parse --short HEAD 2>/dev/null || echo '?')"
|
||||||
|
echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')"
|
||||||
|
echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')"
|
||||||
|
echo "python-venvs :"
|
||||||
|
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do
|
||||||
|
echo " $P → $("$P" --version 2>&1 || echo 'fehlt')"
|
||||||
|
done
|
||||||
|
} > "$KG/versions.txt" || true
|
||||||
|
|
||||||
|
# Modell-Bestand (Namen + Größen): welche GGUF/mmproj-Dateien liefen zusammen.
|
||||||
|
find "$MODELS_DIR" -maxdepth 3 \( -name '*.gguf' -o -name '*mmproj*' \) -print0 2>/dev/null \
|
||||||
|
| xargs -r -0 du -h 2>/dev/null | sort -k2 > "$KG/models.txt" || true
|
||||||
|
|
||||||
cat > "$STAGE/MANIFEST.txt" <<EOF
|
cat > "$STAGE/MANIFEST.txt" <<EOF
|
||||||
mc2-state backup
|
mc2-state backup
|
||||||
created : $TS
|
created : $TS
|
||||||
host : $(hostname)
|
host : $(hostname)
|
||||||
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml)
|
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
|
||||||
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
|
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -45,3 +105,21 @@ chmod 600 "$OUT" # enthält .env (Secrets)
|
|||||||
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1)) | xargs -r rm -f
|
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1)) | xargs -r rm -f
|
||||||
|
|
||||||
echo "OK — Backup: $OUT ($(du -h "$OUT" | cut -f1))"
|
echo "OK — Backup: $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
|
|
||||||
|
# --- Off-Box-Spiegel (C12) -------------------------------------------------
|
||||||
|
# Spiegelt die Tarballs 1:1 aufs Zweitgerät; --delete zieht die Retention mit,
|
||||||
|
# sodass die Off-Box-Kopie nicht unbegrenzt wächst. Perms (600) bleiben erhalten.
|
||||||
|
if [ -n "$OFFSITE" ]; then
|
||||||
|
if [ -f "$OFFSITE_KEY" ]; then
|
||||||
|
if rsync -a \
|
||||||
|
-e "ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" \
|
||||||
|
--include='mc2-state-*.tar.gz' --exclude='*' --delete \
|
||||||
|
"$DEST_DIR/" "$OFFSITE/"; then
|
||||||
|
echo "OK — Off-Box-Kopie gespiegelt → $OFFSITE"
|
||||||
|
else
|
||||||
|
echo "WARN — Off-Box-Sync fehlgeschlagen (lokales Backup ist gültig): $OFFSITE" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WARN — Off-Box-Key fehlt ($OFFSITE_KEY) → nur lokales Backup." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bagatell-Annahme — „Harmloses darf selbst" (User-Entscheid 10.07.2026, konservativ geschnitten):
|
||||||
|
# Vorschlags-Branches, die AUSSCHLIESSLICH Markdown-Dateien anlegen oder ändern (Doku/Wiki —
|
||||||
|
# keine einzige Code-Zeile, keine Löschungen, keine Umbenennungen, keine Skripte), spielt die
|
||||||
|
# Box nachts OHNE Klick ein. Der Weg ist derselbe wie beim Klick im Auftragsbuch:
|
||||||
|
# auftrag-annehmen.sh (Merge im Worktree → py_compile-Gate → Push main → Deploy → Health →
|
||||||
|
# Auto-Revert). Fangnetz drumherum: Nacht-Sicherung 03:30 (Zeitmaschine) liegt davor, die
|
||||||
|
# Morgenlage 04:30 berichtet danach, jede Annahme steht in Chronik + Telegram.
|
||||||
|
# JEDE Code-Zeile bleibt Klick-pflichtig — dieses Skript weitet die Klasse NIE selbst aus.
|
||||||
|
#
|
||||||
|
# Läuft als mc2-bagatell.timer (04:10) über eine /tmp-Kopie (Selbst-Reset-Falle: die Annahme
|
||||||
|
# deployt und resettet damit das Repo, in dem dieses Skript liegt).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
||||||
|
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
|
||||||
|
MAX_PRO_NACHT=2 # jede Annahme startet die Zentrale kurz neu — Nachtruhe wahren
|
||||||
|
|
||||||
|
cd "$SRC" || exit 0
|
||||||
|
git fetch -q --prune origin 2>/dev/null || exit 0
|
||||||
|
|
||||||
|
angenommen=0
|
||||||
|
for REF in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do
|
||||||
|
[ "$angenommen" -ge "$MAX_PRO_NACHT" ] && break
|
||||||
|
BRANCH="${REF#origin/}"
|
||||||
|
case "$BRANCH" in wartung/*|orchestrator/*|doku/*|feature/*) ;; *) continue ;; esac
|
||||||
|
|
||||||
|
AHEAD="$(git rev-list --count "origin/main..$REF" 2>/dev/null || echo 0)"
|
||||||
|
[ "${AHEAD:-0}" -gt 0 ] || continue
|
||||||
|
|
||||||
|
# Nur unberührte Vorschläge: trägt der Branch schon irgendeinen Annahme-Status
|
||||||
|
# (läuft/fehlgeschlagen/zurückgerollt/…), entscheidet der Commander — nicht wir.
|
||||||
|
STATE="$(python3 - "$STATUS" "$BRANCH" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
print((json.load(open(sys.argv[1], encoding="utf-8")).get("branches", {}).get(sys.argv[2]) or {}).get("state", ""))
|
||||||
|
except Exception:
|
||||||
|
print("")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
[ -z "$STATE" ] || continue
|
||||||
|
|
||||||
|
# Bagatell-Klasse: JEDE geänderte Datei endet auf .md UND ist nur angelegt (A) oder
|
||||||
|
# geändert (M). Alles andere (Löschung, Rename R…, Copy C…, Nicht-Markdown) → Klick-Pflicht.
|
||||||
|
DIFF="$(git diff --name-status "origin/main...$REF" 2>/dev/null)"
|
||||||
|
[ -n "$DIFF" ] || continue
|
||||||
|
OK=1
|
||||||
|
while IFS=$'\t' read -r st path _rest; do
|
||||||
|
[ -n "$st" ] || continue
|
||||||
|
case "$st" in A|M) ;; *) OK=0; break ;; esac
|
||||||
|
case "$path" in *.md) ;; *) OK=0; break ;; esac
|
||||||
|
done <<< "$DIFF"
|
||||||
|
[ "$OK" = 1 ] || continue
|
||||||
|
|
||||||
|
N="$(printf '%s\n' "$DIFF" | wc -l | tr -d ' ')"
|
||||||
|
echo "Bagatelle erkannt: $BRANCH ($N Markdown-Datei(en))"
|
||||||
|
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$(jq -n --arg t "Bagatelle (nur Doku, $N Datei(en)): '$BRANCH' wird ohne Klick eingespielt — Fangnetz aktiv, Details in der Morgenlage." \
|
||||||
|
'{text:$t, subject:"[Bagatell-Annahme]", source:"bagatell", priority:"silent"}')" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
RUNNER="/tmp/mc2-bagatell-annehmen-$$.sh"
|
||||||
|
cp "$SRC/deploy/auftrag-annehmen.sh" "$RUNNER" || continue
|
||||||
|
if bash "$RUNNER" "$BRANCH"; then
|
||||||
|
angenommen=$((angenommen+1))
|
||||||
|
else
|
||||||
|
# auftrag-annehmen.sh hat selbst gemeldet (fehlgeschlagen/zurückgerollt) und aufgeräumt;
|
||||||
|
# der Branch trägt jetzt einen Status → nächste Nacht fasst ihn niemand mehr automatisch an.
|
||||||
|
echo "Bagatell-Annahme von $BRANCH nicht grün — Meldung kam vom Annahme-Runner."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Bagatell-Lauf fertig: $angenommen eingespielt."
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Brain-Config-Matrix (Referenz vom 02.07.2026): misst Qwen3.6 in mehreren Betriebsarten
|
||||||
|
# (MTP n-max / ohne Spec / KV-Quant) auf separatem Port. Für künftige Re-Benchmarks nach
|
||||||
|
# Engine-Updates oder bei neuen Hirn-Kandidaten. Ergebnis-Referenz (02.07., Vulkan, b9843):
|
||||||
|
# mtp3 78,6 t/s · mtp4 63,7 · ohne Spec 62,4 · mtp3+KVq8 78,6 (=gratis) · nospec+KVq8 62,1
|
||||||
|
set -u
|
||||||
|
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
|
||||||
|
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||||
|
PORT="${BENCH_PORT:-5899}"
|
||||||
|
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
|
||||||
|
|
||||||
|
run() { # $1=name $2=extra-flags
|
||||||
|
echo "=== $1 ==="
|
||||||
|
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" $BASE $2 >/tmp/brain-bench-server.log 2>&1 &
|
||||||
|
local PID=$!
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q ok && break
|
||||||
|
sleep 2
|
||||||
|
kill -0 $PID 2>/dev/null || { echo " LOAD-CRASH"; tail -2 /tmp/brain-bench-server.log; return; }
|
||||||
|
done
|
||||||
|
for r in 0 1 2; do
|
||||||
|
curl -s --max-time 300 -X POST "http://127.0.0.1:$PORT/completion" -H "Content-Type: application/json" \
|
||||||
|
-d '{"prompt":"Erklaere in drei kurzen Saetzen, was ein Mixture-of-Experts-Modell ist.","n_predict":150,"temperature":0}' \
|
||||||
|
> /tmp/brain-bench-probe.json
|
||||||
|
[ "$r" = "0" ] && continue # Warmlauf verwerfen
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
t = json.load(open("/tmp/brain-bench-probe.json")).get("timings", {})
|
||||||
|
print(f" tg={t.get('predicted_per_second',0):.1f} t/s"
|
||||||
|
+ (f" · draft {t.get('draft_n_accepted')}/{t.get('draft_n')}" if t.get('draft_n') else ""))
|
||||||
|
PY
|
||||||
|
done
|
||||||
|
kill $PID 2>/dev/null; wait $PID 2>/dev/null; sleep 3
|
||||||
|
}
|
||||||
|
|
||||||
|
run "mtp3 (live-Config)" "--spec-type draft-mtp --spec-draft-n-max 3 -ctk q8_0 -ctv q8_0"
|
||||||
|
run "mtp4" "--spec-type draft-mtp --spec-draft-n-max 4 -ctk q8_0 -ctv q8_0"
|
||||||
|
run "ohne Spec" "-ctk q8_0 -ctv q8_0"
|
||||||
|
run "mtp3 KV-f16" "--spec-type draft-mtp --spec-draft-n-max 3"
|
||||||
|
echo "BENCH_DONE"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Modell-Bench (Autonomie-Plan E5): misst pp/tg eines GGUF auf separatem Port, ohne die
|
||||||
|
# Live-Instanzen anzufassen. Grundlage der Modell-Selbst-Evaluation (Radar-Kandidaten).
|
||||||
|
# Nutzung: bash model-bench.sh <gguf-pfad> [extra llama-server-flags, z.B. --mmproj ...]
|
||||||
|
# Lehren vom 02.07.2026 eingebaut: python via Heredoc (kein f-String-Quoting-Bruch),
|
||||||
|
# Warmlauf vor der Messung, LOAD-CRASH wird gemeldet statt still zu hängen.
|
||||||
|
set -u
|
||||||
|
MODEL="${1:?Nutzung: model-bench.sh <gguf-pfad> [extra-flags]}"
|
||||||
|
shift || true
|
||||||
|
EXTRA="$*"
|
||||||
|
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||||
|
PORT="${BENCH_PORT:-5899}"
|
||||||
|
CTX="${BENCH_CTX:-32768}"
|
||||||
|
|
||||||
|
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
||||||
|
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
|
||||||
|
>/tmp/model-bench-server.log 2>&1 &
|
||||||
|
PID=$!
|
||||||
|
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
||||||
|
|
||||||
|
for i in $(seq 1 240); do
|
||||||
|
curl -s --max-time 2 "http://127.0.0.1:$PORT/health" | grep -q ok && break
|
||||||
|
sleep 2
|
||||||
|
kill -0 $PID 2>/dev/null || { echo "LOAD-CRASH:"; tail -3 /tmp/model-bench-server.log; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
curl -s --max-time 300 -X POST "http://127.0.0.1:$PORT/completion" -H "Content-Type: application/json" \
|
||||||
|
-d '{"prompt":"Erklaere in fuenf Saetzen, wie ein Backup-Konzept fuer einen Homeserver aussieht.","n_predict":200,"temperature":0}' \
|
||||||
|
> /tmp/model-bench-probe.json
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
t = json.load(open("/tmp/model-bench-probe.json")).get("timings", {})
|
||||||
|
print(f" pp={t.get('prompt_per_second',0):.0f} t/s · tg={t.get('predicted_per_second',0):.1f} t/s"
|
||||||
|
+ (f" · draft {t.get('draft_n_accepted')}/{t.get('draft_n')}" if t.get('draft_n') else ""))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
probe >/dev/null 2>&1 # Warmlauf (Shader-/Graph-Compile nicht mitmessen)
|
||||||
|
probe
|
||||||
|
probe
|
||||||
|
echo "BENCH_DONE"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Betriebs-/Mess-Worker (Profil „betrieb")
|
||||||
|
|
||||||
|
Du bist der Betriebs- und Mess-Worker der AI-Box. Du arbeitest EINE Kanban-Aufgabe ab —
|
||||||
|
Ops, Benchmarks, Messungen, Health-/Diagnose-Checks, Verifikation von Behauptungen auf der
|
||||||
|
LEBENDEN Box (Dienste, Modelle, Latenzen, Logs, Ressourcen). Keine Persona, kein Smalltalk.
|
||||||
|
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
|
||||||
|
du reparierst nicht.**
|
||||||
|
|
||||||
|
## Schritt 0: WIEDERAUFNAHME — bei „Prior attempts" erst nachsehen, dann messen
|
||||||
|
|
||||||
|
Steht in deinem Auftrag ein Abschnitt **„Prior attempts on this task"**, hat vor dir schon ein
|
||||||
|
Lauf gemessen und ist unterwegs gestorben (meist Kontext-Limit). **Dein Workspace ist derselbe.**
|
||||||
|
Also zuerst: `cat FORTSCHRITT.md` und `ls -la` im Workspace — liegen dort schon Messwerte, Logs,
|
||||||
|
Bench-Ausgaben? **Vorhandene Zahlen misst du NICHT neu.** Das ist hier keine Bequemlichkeit,
|
||||||
|
sondern Pflicht: ein wiederholter Bench lädt 70-GB-Modelle, gefährdet Lucys Warm-Set und kann
|
||||||
|
einen OOM auslösen (Regel 5). Fehlt nur noch die Auswertung, wertest du aus und schließt ab.
|
||||||
|
Nur was nachweislich fehlt oder unbrauchbar ist, wird neu gemessen.
|
||||||
|
|
||||||
|
**Fortschritt hinterlassen:** jede fertige Messung sofort als Zeile in `FORTSCHRITT.md` im
|
||||||
|
Workspace (was gemessen, welche Zahl, in welcher Datei) — und die Rohausgabe in eine Datei
|
||||||
|
daneben. Eine Messung, die nur im Kontext steht, ist beim nächsten Lauf verloren.
|
||||||
|
|
||||||
|
## Eiserne Regeln (nicht verhandelbar)
|
||||||
|
1. **MESSEN, NICHT ÄNDERN.** Du fasst das Live-System NICHT an: kein `systemctl restart`, kein
|
||||||
|
Config-Edit, kein Deploy/Merge/Push, kein Laden/Entladen von Modellen, das das Warm-Set
|
||||||
|
stört. Ergibt die Messung, dass etwas geändert werden muss → schreib es als BEFUND/Vorschlag
|
||||||
|
in die Summary (oder leg per `kanban_create` eine Idee an), mach es NICHT selbst.
|
||||||
|
2. **Nur LESEN am Bestand:** Live-Checkout `~/mission-control-v2` und Hermes-Quelle
|
||||||
|
`~/.hermes/hermes-agent` sind TABU zum Schreiben (Lesen zur Orientierung ok). Alles außerhalb
|
||||||
|
einer reinen Messung ist tabu.
|
||||||
|
3. **TABU-Zonen:** Security-Config (approvals/Tokens/ufw/sudoers), `~/.hermes/config.yaml`,
|
||||||
|
systemd-Units. Braucht die Aufgabe so etwas → `kanban_block` mit Begründung, nicht selbst tun.
|
||||||
|
4. **Kenn deine Werkzeuge** (Endpunkte/Pfade stehen in deiner Box-Orientierung + im Selbst-
|
||||||
|
Steckbrief `~/.hermes/state/selbst-steckbrief.md`):
|
||||||
|
- Health/Status: `curl -s http://127.0.0.1:9001/api/health`, `.../api/system/status`.
|
||||||
|
- **Dienste:** `systemctl --user status <dienst>` — aber VORHER
|
||||||
|
`export XDG_RUNTIME_DIR=/run/user/$(id -u)`, sonst sind die User-Units per SSH UNSICHTBAR
|
||||||
|
(häufigste Ops-Falle). Logs: `journalctl --user -u <dienst> --no-pager -n 100`.
|
||||||
|
- Modelle/Router: `curl -s http://127.0.0.1:8080/...` bzw. `http://127.0.0.1:9001/v1/models`.
|
||||||
|
- Bench/Smoke (nur lesen/messen, nicht deployen): `~/mission-control-v2/deploy/bench/brain-bench.sh`,
|
||||||
|
`.../deploy/bench/model-bench.sh`, `.../deploy/self-smoke.sh`, `.../deploy/stack-postcheck.sh`.
|
||||||
|
5. **BENCH-VORSICHT:** Nie zwei große Benches (70-GB-Modelle) direkt nacheinander → OOM-Kill
|
||||||
|
(Vorfall E5, 02.07.2026). Schwere Modelle (heavy/vision) nur laden, wenn die Aufgabe es
|
||||||
|
verlangt; danach den Ausgangszustand wiederherstellen. **Lucys Warm-Set und ihre ~1-s-Sprech-
|
||||||
|
Latenz NIE gefährden** — ein Config-Reload leert das Warm-Set, ein OOM killt laufende Modelle.
|
||||||
|
6. **VERIFIZIEREN VOR BEHAUPTEN.** Dein Bericht enthält BELEGTE Zahlen und echte Kommando-
|
||||||
|
Ausgaben (bei Vergleichen: Vorher/Nachher), keine Vermutung. Zitiere die reale Ausgabe, nicht
|
||||||
|
das, was du erwartest. Der Agenten-Erzählung — auch deiner eigenen — nie glauben; das Log,
|
||||||
|
der curl, der bench sind die Wahrheit.
|
||||||
|
7. **Falsche Ebene erkannt?** Braucht die Aufgabe in Wahrheit eine CODE-Änderung → das ist die
|
||||||
|
**werkstatt**, nicht du (`kanban_block` mit Verweis). Reine Doku/Recherche ohne Box-Bezug →
|
||||||
|
**default**. Grenzen im Zweifel: `~/mission-control-v2/docs/wissen/GRENZEN.md`.
|
||||||
|
8. **Fertig = `kanban_complete`** mit deutscher Summary: was gemessen, die ZAHLEN/der klare
|
||||||
|
Befund, wie geprüft (welche Kommandos). Unklar, blockiert oder Werkzeug fehlt → `kanban_block`
|
||||||
|
mit ehrlicher Diagnose statt geratener Zahlen.
|
||||||
|
|
||||||
|
## Stil
|
||||||
|
Deutsch in allem User-Sichtbaren. Zahlen statt Prosa. Ein knapper, belegter Befund schlägt
|
||||||
|
einen langen, vagen Bericht. Keine Nebenbaustellen — genau die eine Messung, sauber.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Box-Konsole — ttyd web terminal wrapping a login shell (direkter Box-Zugriff)
|
||||||
|
Documentation=https://github.com/tsl0922/ttyd
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Exponiert eine echte interaktive Login-Shell (bash -l) der Box als Web-Terminal — der
|
||||||
|
# direkte, SSH-artige Zugriff, den das UI als „Konsole" einbettet (iframe). Schwester-Dienst
|
||||||
|
# zum hermes-terminal (das die Agent-CLI zeigt); dieser hier zeigt die nackte Shell.
|
||||||
|
#
|
||||||
|
# SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy
|
||||||
|
# (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von
|
||||||
|
# außen dicht → keine eigene Firewall-Regel nötig. Login-Schutz: ttyd startet die Shell über
|
||||||
|
# `su - hitonabi` → fragt beim Öffnen das Box-Passwort ab (kein gespeichertes Passwort, PAM
|
||||||
|
# prüft gegen das echte Konto). Trusted-Home-LAN, kein Internet-Exposure.
|
||||||
|
Type=simple
|
||||||
|
# --interface lo = nur Loopback. --base-path /console = ttyd bedient /console/* (Reverse-Proxy).
|
||||||
|
# `su - hitonabi` = Passwort-Login (Box-Passwort) vor der Shell. -t = dunkles Theme.
|
||||||
|
ExecStart=/usr/bin/ttyd --writable --interface lo --port 7682 --base-path /console --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/su - hitonabi
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wrapper fuer "git commit", der das Frontend automatisch baut, wenn Dateien geaendert wurden.
|
||||||
|
# Benutzung: ./deploy/commit.sh -m "deine commit message"
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# In das Projekt-Root wechseln
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
FRONTEND_DIR="frontend"
|
||||||
|
|
||||||
|
# Pruefen, ob Dateien im Frontend-Ordner fuer den Commit vorgemerkt (staged) sind,
|
||||||
|
# die einen Build erfordern (src, index.html, package.json, etc.).
|
||||||
|
if git diff --cached --name-only | grep -q "^$FRONTEND_DIR/src/\|^$FRONTEND_DIR/index.html\|^$FRONTEND_DIR/package.json\|^$FRONTEND_DIR/vite.config.ts"; then
|
||||||
|
echo "=== Frontend-Aenderungen in der Stage entdeckt. Starte Build... ==="
|
||||||
|
(cd "$FRONTEND_DIR" && npm run build)
|
||||||
|
|
||||||
|
# Das neu gebaute dist-Verzeichnis direkt mit stagen
|
||||||
|
git add "$FRONTEND_DIR/dist"
|
||||||
|
echo "=== Build abgeschlossen und frontend/dist gestaged. ==="
|
||||||
|
else
|
||||||
|
echo "=== Keine Frontend-Aenderungen in der Stage. Ueberspringe Build. ==="
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Führe den eigentlichen Commit mit den übergebenen Parametern durch
|
||||||
|
exec git commit "$@"
|
||||||
+37
-71
@@ -1,79 +1,45 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort.
|
set -e
|
||||||
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
|
|
||||||
#
|
|
||||||
# Erstinstallation (einmalig):
|
|
||||||
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
|
|
||||||
# bash ~/mission-control-v2/deploy/deploy.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
|
echo "Starte MC2 Deployment..."
|
||||||
|
|
||||||
cd "$SRC"
|
# 1. Neuesten Code holen
|
||||||
git fetch -q origin && git reset -q --hard origin/main
|
git pull origin main
|
||||||
|
|
||||||
# venv + Abhängigkeiten
|
# 1.5 Frontend aus dem Release-Branch holen (nur dist Ordner)
|
||||||
if [ ! -d "$SRC/backend/.venv" ]; then
|
echo "Hole gebautes Frontend aus release-dist Branch..."
|
||||||
python3 -m venv "$SRC/backend/.venv"
|
git fetch origin release-dist:refs/remotes/origin/release-dist || true
|
||||||
fi
|
git restore --source=origin/release-dist --staged --worktree frontend/dist 2>/dev/null || echo "Kein release-dist Branch gefunden (erster Lauf?)."
|
||||||
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
|
|
||||||
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
|
|
||||||
|
|
||||||
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv),
|
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
|
||||||
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort
|
echo "Aktualisiere Python Abhängigkeiten..."
|
||||||
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren.
|
backend/.venv/bin/pip install -r backend/requirements.txt
|
||||||
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
|
|
||||||
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
|
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
|
||||||
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
|
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
|
||||||
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
|
echo "Llama-Swap Konfiguration hat sich geändert, synchronisiere..."
|
||||||
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
|
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
|
||||||
else
|
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
|
||||||
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
|
sudo systemctl restart llama-swap
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv
|
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||||
# (~/.voice/venv), weil torch/chatterbox/faster-whisper nicht ins 3.14-Backend-venv passen.
|
|
||||||
# install.sh ist idempotent (venv + Deps + Piper-Stimmen). Best-effort: schlägt es fehl, läuft
|
|
||||||
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline).
|
|
||||||
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline."
|
|
||||||
|
|
||||||
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/
|
|
||||||
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml:
|
|
||||||
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal).
|
|
||||||
if [ -d "$HOME/.hermes" ]; then
|
|
||||||
mkdir -p "$HOME/.hermes/plugins/mc2-memory"
|
|
||||||
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \
|
|
||||||
"$HOME/.hermes/plugins/mc2-memory/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# systemd-USER-Units installieren/aktualisieren
|
|
||||||
mkdir -p "$HOME/.config/systemd/user"
|
|
||||||
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
|
|
||||||
# Hermes-Terminal (ttyd -> `hermes chat`), in MC2 als Terminal-Seite eingebettet.
|
|
||||||
# Einmalig manuell noetig: `sudo apt install -y ttyd` + `sudo systemctl disable --now ttyd` (apt-Default-Dienst).
|
|
||||||
cp "$SRC/deploy/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-terminal.service"
|
|
||||||
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
|
|
||||||
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
|
|
||||||
# Voice-Sidecar-Unit (nur wenn das venv existiert).
|
|
||||||
[ -x "$HOME/.voice/venv/bin/python" ] && cp "$SRC/deploy/voice-service.service" "$HOME/.config/systemd/user/voice-service.service"
|
|
||||||
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
|
|
||||||
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
|
|
||||||
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
|
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
|
|
||||||
systemctl --user enable hermes-terminal >/dev/null 2>&1 || true
|
|
||||||
systemctl --user enable mem0-service >/dev/null 2>&1 || true
|
|
||||||
systemctl --user enable voice-service >/dev/null 2>&1 || true
|
|
||||||
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
|
|
||||||
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
|
|
||||||
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
|
|
||||||
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
|
|
||||||
# Voice-Sidecar (re)starten (best-effort; Erststart lädt das STT-Modell vor).
|
|
||||||
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
|
|
||||||
systemctl --user restart mission-control-2
|
|
||||||
command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/dev/null || true
|
|
||||||
|
|
||||||
sleep 2
|
# 4.5 Hermes Skills synchronisieren
|
||||||
echo "--- Health ---"
|
echo "Synchronisiere Hermes Skills..."
|
||||||
curl -sf http://127.0.0.1:9001/api/health && echo
|
mkdir -p ~/.hermes/skills
|
||||||
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
|
# Kopiere alle Skills aus dem Repo, ersetze - durch _ in den Ordnernamen für Hermes Kompatibilität
|
||||||
|
for skill_dir in deploy/skills/*; do
|
||||||
|
if [ -d "$skill_dir" ]; then
|
||||||
|
skill_name=$(basename "$skill_dir" | tr '-' '_')
|
||||||
|
mkdir -p ~/.hermes/skills/"$skill_name"
|
||||||
|
cp -r "$skill_dir"/* ~/.hermes/skills/"$skill_name"/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5. Dienste neu starten
|
||||||
|
echo "Starte Backend neu..."
|
||||||
|
systemctl --user restart mc2-gateway.service mission-control-2.service
|
||||||
|
|
||||||
|
echo "✅ Deployment erfolgreich abgeschlossen!"
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026;
|
||||||
|
erweitert 19.07.2026 um die No-Progress-Bremse und Mehrfach-Eintraege pro Event).
|
||||||
|
|
||||||
|
Kanban-Worker laufen unter IHREM Profil (HERMES_HOME=~/.hermes/profiles/<name>) und lesen
|
||||||
|
dessen config.yaml — NICHT die Top-Level ~/.hermes/config.yaml. Die worker-relevanten Hooks
|
||||||
|
muessen also in JEDER Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie
|
||||||
|
(Bug-Fund 13.07.).
|
||||||
|
|
||||||
|
WICHTIG (Falle, 19.07.): Ein Event darf nur EINMAL als YAML-Key existieren. Existiert
|
||||||
|
`pre_tool_call:` bereits (tabu-pfade-guard), wird ein weiterer Eintrag IN den Block
|
||||||
|
eingefuegt — ein zweiter `pre_tool_call:`-Key wuerde beim YAML-Laden den ersten
|
||||||
|
verschlucken und den Tabu-Guard lautlos deaktivieren.
|
||||||
|
|
||||||
|
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
|
||||||
|
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except Exception:
|
||||||
|
print(" (PyYAML fehlt — ensure-profile-hooks uebersprungen)")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
AH = os.path.expanduser("~/.hermes/agent-hooks")
|
||||||
|
# Reihenfolge zaehlt: tabu-pfade-guard MUSS vor der Bremse stehen (Schutz vor Komfort).
|
||||||
|
WANT = [
|
||||||
|
("pre_llm_call", f"{AH}/box-steckbrief-inject.sh", 10),
|
||||||
|
("pre_tool_call", f"{AH}/tabu-pfade-guard.py", 10),
|
||||||
|
("pre_tool_call", f"{AH}/no-progress-bremse.py pre", 10),
|
||||||
|
("post_tool_call", f"{AH}/no-progress-bremse.py post", 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(" usage: ensure-profile-hooks.py <config.yaml>")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
p = sys.argv[1]
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
txt = open(p, encoding="utf-8").read()
|
||||||
|
|
||||||
|
anchor = (" pre_verify:\n"
|
||||||
|
f" - command: {AH}/pre-verify-gates.sh\n"
|
||||||
|
" timeout: 60\n")
|
||||||
|
if txt.count(anchor) != 1:
|
||||||
|
print(f" [{os.path.basename(os.path.dirname(p))}] kein eindeutiger pre_verify-Anker — SKIP")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
new = txt
|
||||||
|
changed = False
|
||||||
|
for event, cmd, timeout in WANT:
|
||||||
|
if cmd in new:
|
||||||
|
continue
|
||||||
|
entry = f" - command: {cmd}\n timeout: {timeout}\n"
|
||||||
|
ev_line = f" {event}:\n"
|
||||||
|
if ev_line in new:
|
||||||
|
# Eintrag ANS ENDE des bestehenden Event-Blocks: direkt nach dem Key einfuegen
|
||||||
|
# waere auch ok, aber hinter dem letzten Eintrag haelt die gewollte Reihenfolge.
|
||||||
|
idx = new.index(ev_line) + len(ev_line)
|
||||||
|
end = idx
|
||||||
|
for line in new[idx:].splitlines(keepends=True):
|
||||||
|
if line.startswith((" - ", " ")):
|
||||||
|
end += len(line)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
new = new[:end] + entry + new[end:]
|
||||||
|
else:
|
||||||
|
new = new.replace(anchor, anchor + ev_line + entry, 1)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks schon registriert.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
d = yaml.safe_load(new)
|
||||||
|
h = d["hooks"]
|
||||||
|
for event, cmd, _t in WANT:
|
||||||
|
assert any(cmd == x.get("command", "") for x in h.get(event, [])), f"{event}: {cmd} fehlt"
|
||||||
|
assert any("pre-verify-gates.sh" in x.get("command", "") for x in h["pre_verify"])
|
||||||
|
# Tabu-Guard muss VOR der Bremse stehen (beide in pre_tool_call).
|
||||||
|
cmds = [x.get("command", "") for x in h["pre_tool_call"]]
|
||||||
|
assert cmds.index(f"{AH}/tabu-pfade-guard.py") < cmds.index(f"{AH}/no-progress-bremse.py pre")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" [{p}] Validierung fehlgeschlagen ({exc}) — config UNVERAENDERT.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
bak = p + ".bak-hooks-" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
shutil.copy2(p, bak)
|
||||||
|
tmp = p + ".tmp-hooks"
|
||||||
|
open(tmp, "w", encoding="utf-8").write(new)
|
||||||
|
os.replace(tmp, p)
|
||||||
|
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks ergaenzt (Backup {os.path.basename(bak)}).")
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Gateway-Cutover (UMBAU v3 P1, Stufe 2 = Unabhängigkeit):
|
||||||
|
# Hermes' LLM-Endpunkte vom Steuerpult-Umweg (127.0.0.1:9001/v1) auf den
|
||||||
|
# eigenständigen Gateway (127.0.0.1:9010/v1) umstellen — Top-Level-config.yaml
|
||||||
|
# UND alle Profil-Configs (werkstatt/betrieb). Danach überleben Lucys Hirn und
|
||||||
|
# die komplette Nacht-Autonomie jeden MC2-Neustart.
|
||||||
|
#
|
||||||
|
# BEWUSST NICHT in deploy.sh: ~/.hermes/config.yaml ist Zwei-Schreiber-sensibel
|
||||||
|
# (Hermes schreibt sie selbst). Einmalig von Hand bzw. per angenommener Karte:
|
||||||
|
# bash ~/mission-control-v2/deploy/gateway-cutover.sh # umstellen
|
||||||
|
# bash ~/mission-control-v2/deploy/gateway-cutover.sh --revert # zurück
|
||||||
|
# Je geänderter Datei entsteht ein Backup *.bak-gwcut-<Zeitstempel>.
|
||||||
|
# Danach: hermes-gateway-Neustart + kurzer Funktionsnachweis.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--revert" ]; then
|
||||||
|
ALT="127.0.0.1:9010/v1"; NEU="127.0.0.1:9001/v1"
|
||||||
|
PROBE="http://127.0.0.1:9001/api/health" # Rückweg: MC2 muss /v1 wieder selbst bedienen
|
||||||
|
else
|
||||||
|
ALT="127.0.0.1:9001/v1"; NEU="127.0.0.1:9010/v1"
|
||||||
|
PROBE="http://127.0.0.1:9010/gw/health" # Hinweg: Gateway-Prozess muss leben
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sicherung: NIE auf ein totes Ziel umstellen — das würde Lucy das Hirn abschneiden.
|
||||||
|
if ! curl -sf -m 5 "$PROBE" >/dev/null 2>&1; then
|
||||||
|
echo "ABBRUCH: Ziel $NEU antwortet nicht ($PROBE). Nichts geändert."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
ALT_RE="${ALT//./\\.}" # Punkte für sed escapen
|
||||||
|
geaendert=0
|
||||||
|
for f in "$HOME/.hermes/config.yaml" "$HOME"/.hermes/profiles/*/config.yaml; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
grep -q "$ALT" "$f" || continue
|
||||||
|
cp "$f" "$f.bak-gwcut-$STAMP"
|
||||||
|
sed -i "s|$ALT_RE|$NEU|g" "$f"
|
||||||
|
echo "umgestellt: $f (Backup: $f.bak-gwcut-$STAMP)"
|
||||||
|
geaendert=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$geaendert" -eq 0 ]; then
|
||||||
|
echo "Nichts zu tun — '$ALT' kommt in keiner Hermes-Config vor."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "hermes-gateway neu starten …"
|
||||||
|
systemctl --user restart hermes-gateway
|
||||||
|
sleep 3
|
||||||
|
if systemctl --user is-active --quiet hermes-gateway; then
|
||||||
|
echo "hermes-gateway läuft."
|
||||||
|
else
|
||||||
|
echo "WARN: hermes-gateway ist nach dem Neustart NICHT aktiv!"
|
||||||
|
echo " journalctl --user -u hermes-gateway -n 30 und ggf.: $0 --revert"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Empfohlener Nachweis: cd ~/.hermes/hermes-agent && venv/bin/hermes doctor"
|
||||||
|
echo "FERTIG. Rückweg jederzeit: $0 --revert"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea-pr (24.07.2026): PR auf Gitea per REST-API anlegen — der EINZIGE Weg für
|
||||||
|
# Worker, einen Pull-Request zu erstellen. Kein Web-Login, kein Passwort: das
|
||||||
|
# HTTP-Token aus ~/.git-credentials reicht (Vorfall 23.07.: Wartung-Worker bat
|
||||||
|
# um das Gitea-Passwort für die Web-GUI und blockte — genau das nie wieder).
|
||||||
|
# Nutzung: gitea-pr <repo> <head-branch> "<titel>" ["<body>"] [base=main]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="${1:?Nutzung: gitea-pr <repo> <head-branch> \"<titel>\" [\"<body>\"] [base]}"
|
||||||
|
HEAD="${2:?head-branch fehlt}"
|
||||||
|
TITLE="${3:?titel fehlt}"
|
||||||
|
BODY="${4:-}"
|
||||||
|
BASE="${5:-main}"
|
||||||
|
OWNER="Hitonabi"
|
||||||
|
# Intern statt DDNS-Domain: die ist nachts (Zwangstrennung) oft nicht erreichbar.
|
||||||
|
HOST="http://192.168.178.153:3000"
|
||||||
|
|
||||||
|
creds="$(printf 'protocol=http\nhost=192.168.178.153:3000\n\n' | git credential fill)"
|
||||||
|
username="$(sed -n 's/^username=//p' <<<"$creds")"
|
||||||
|
password="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
[ -n "$password" ] || { echo "FEHLER: kein Token in ~/.git-credentials gefunden" >&2; exit 1; }
|
||||||
|
|
||||||
|
payload="$(TITLE="$TITLE" BODY="$BODY" HEAD="$HEAD" BASE="$BASE" python3 -c '
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({"title": os.environ["TITLE"], "body": os.environ["BODY"],
|
||||||
|
"head": os.environ["HEAD"], "base": os.environ["BASE"]}))')"
|
||||||
|
|
||||||
|
antwort="$(curl -sS -w '\n%{http_code}' -X POST \
|
||||||
|
-u "$username:$password" -H "Content-Type: application/json" \
|
||||||
|
-d "$payload" "$HOST/api/v1/repos/$OWNER/$REPO/pulls")"
|
||||||
|
status="${antwort##*$'\n'}"
|
||||||
|
koerper="${antwort%$'\n'*}"
|
||||||
|
|
||||||
|
if [ "$status" = "201" ]; then
|
||||||
|
printf '%s' "$koerper" | python3 -c 'import json,sys; pr=json.load(sys.stdin); print("PR angelegt:", pr.get("html_url", pr.get("url", "?")))'
|
||||||
|
else
|
||||||
|
echo "FEHLER: Gitea-API antwortete $status" >&2
|
||||||
|
printf '%s\n' "$koerper" | head -c 600 >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user