Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d2d7c74b | |||
| 7638f0244d | |||
| 4985977492 | |||
| f65ff48599 | |||
| 87f446c5ae | |||
| f524ef4375 | |||
| 605c9d7dd1 | |||
| 9791f44644 | |||
| 1e3e0066af | |||
| 2365f7aac6 | |||
| 56b374a0aa | |||
| eef7fdb4df | |||
| cd60974ff9 | |||
| 20d4f40ed9 | |||
| cb649aac34 | |||
| d18aac0e1b | |||
| 6c14705798 | |||
| 991c9a2f82 | |||
| ad49dd65ca | |||
| c28aa4a6a2 | |||
| 8b76c0f749 | |||
| a934deee41 | |||
| 543eee95d0 | |||
| 59d6be8c4a | |||
| 7fac17ed9a | |||
| 00f2b71a63 | |||
| 2cecd06c5b | |||
| 775e862652 | |||
| 47f7a85510 | |||
| e6502f676a | |||
| 554c87aaee | |||
| 798de0be8f | |||
| c13cfd2bd0 | |||
| 080eaa5ff5 | |||
| 7dd44a3630 | |||
| 554db10a6f | |||
| ce030d6817 | |||
| 8996c0d012 | |||
| a1de1ec2ac | |||
| 8e735df32d | |||
| 057cc2bb47 | |||
| e686f95545 | |||
| 8a90b7507e | |||
| 59b09e4a9a | |||
| 5894c24703 | |||
| 3f279fc7f2 | |||
| af28a75338 | |||
| a121ca7dc0 | |||
| cce32810fc | |||
| 682be9b63b | |||
| 96c4e417ec | |||
| 90ab873924 | |||
| 6d3a0bf3a6 | |||
| c3fe6dbe8d | |||
| 584f257853 | |||
| 73513f5e5b | |||
| 18f5522612 | |||
| 0cea01c699 | |||
| 94dc3fba9b | |||
| 3cdd5cdd10 | |||
| 9307c8b752 | |||
| 204238925a | |||
| a16cff39ce | |||
| 11c3416571 | |||
| 7389386791 | |||
| 40c8ed4754 | |||
| 656c5045da | |||
| 828dbe13b7 | |||
| 82a3bc3c59 | |||
| d5da853db2 | |||
| 7b9752261a | |||
| f77c09295a | |||
| cfcb5d2db5 | |||
| efca31c21f | |||
| a57e971607 | |||
| bf9c22ab78 | |||
| ff8125d2ac | |||
| 4bb6b28297 | |||
| e3b3725b6f | |||
| 77ae504b58 | |||
| 8153d14d9f | |||
| a91d70d9b7 | |||
| eb1f333bba | |||
| 24e2017d5b | |||
| 2383dd26fa | |||
| aebe10306d | |||
| 6b03a2c3cf | |||
| a1d7a4b6b9 | |||
| 903f3bc074 | |||
| 331aafa25d | |||
| cab748f32e | |||
| d91bd5a139 | |||
| f21fe90090 | |||
| 7b826f66c4 | |||
| e556dcc853 | |||
| b26fb40cbc | |||
| bc60c6fefc | |||
| de21e1ac68 | |||
| 9c03316755 | |||
| f5e362e902 | |||
| 2be599bfe9 | |||
| b0dce954e9 | |||
| 8579fe9934 | |||
| 32c24d7b60 | |||
| 547851f1c3 | |||
| 1daacea468 | |||
| 5aded967ef | |||
| a22f27bc60 | |||
| 31fde2a68e | |||
| 0890698750 | |||
| 77dfac79ee | |||
| fa22435d7c | |||
| 4df6a3ecf9 | |||
| d697f3cc0d | |||
| fd241aca51 | |||
| d2d726c105 | |||
| 71192b3db0 | |||
| 26224804ed | |||
| bc60f9254b | |||
| 0c69c491e5 | |||
| b36561990e | |||
| 41b0edba53 | |||
| b7e6cf4acc | |||
| 78bff28acf | |||
| ff6ef3eb1f |
@@ -11,6 +11,10 @@
|
|||||||
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
||||||
deploy/agent-hooks/*.py text eol=lf
|
deploy/agent-hooks/*.py text eol=lf
|
||||||
deploy/*.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
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
|
||||||
|
#
|
||||||
|
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
|
||||||
|
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
|
||||||
|
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
|
||||||
|
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
|
||||||
|
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
|
||||||
|
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
|
||||||
|
#
|
||||||
|
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||||
|
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||||
|
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
||||||
|
name: Ampel
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ampel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
|
||||||
|
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
|
||||||
|
set -u +e
|
||||||
|
rot=0
|
||||||
|
|
||||||
|
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
|
||||||
|
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
|
||||||
|
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
|
||||||
|
ruff check . || rot=1
|
||||||
|
|
||||||
|
echo "== Python: Import/Syntax (compileall) =="
|
||||||
|
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
|
||||||
|
|
||||||
|
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
|
||||||
|
if [ -f frontend/package.json ]; then
|
||||||
|
if [ ! -f frontend/package-lock.json ]; then
|
||||||
|
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
|
||||||
|
rot=1
|
||||||
|
else
|
||||||
|
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $rot -ne 0 ]; then
|
||||||
|
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||||
|
else
|
||||||
|
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
|
||||||
|
fi
|
||||||
|
exit $rot
|
||||||
@@ -20,3 +20,6 @@ frontend/dist/avatar.vrm
|
|||||||
box_recon*
|
box_recon*
|
||||||
gemma_swap*
|
gemma_swap*
|
||||||
|
|
||||||
|
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,150 @@
|
|||||||
# Mission Control 2.0
|
# Mission Control 2.0
|
||||||
|
|
||||||
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
|
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
||||||
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren** —
|
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
|
||||||
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
|
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
|
||||||
„Werkstatt"), nicht mehr manuelle Releases.
|
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
||||||
|
|
||||||
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
|
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
||||||
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
|
|
||||||
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
|
|
||||||
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
|
|
||||||
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
|
|
||||||
|
|
||||||
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
|
## Die drei Bahnen
|
||||||
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
|
|
||||||
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
|
|
||||||
|
|
||||||
## Status: **live + eingefroren (MC2 Final)**
|
| Bahn | Was sie tut | Wo |
|
||||||
|
|---|---|---|
|
||||||
|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
||||||
|
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
|
||||||
|
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
||||||
|
|
||||||
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
|
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
|
||||||
ohne Betreuung monatelang weiter):
|
Bau-Sitzung und wird nie unterbrochen.
|
||||||
|
|
||||||
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
|
## Ports & Dienste
|
||||||
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
|
|
||||||
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
|
|
||||||
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
|
|
||||||
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
|
|
||||||
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
|
|
||||||
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
|
|
||||||
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
|
|
||||||
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
|
|
||||||
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
|
|
||||||
Branch → Gate → Deploy selbst).
|
|
||||||
|
|
||||||
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
|
| Port | Dienst | Erreichbar |
|
||||||
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
|
|---|---|---|
|
||||||
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
||||||
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
|
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
|
||||||
|
| `8080` | **llama-swap** — Modell-Router | LAN |
|
||||||
|
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
|
||||||
|
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
|
||||||
|
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
|
||||||
|
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
|
||||||
|
|
||||||
|
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
|
||||||
|
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||||
|
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
||||||
|
|
||||||
|
## Der Weg einer Idee
|
||||||
|
|
||||||
|
```
|
||||||
|
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Zed bauen → Ampel → main
|
||||||
|
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
||||||
|
Wächtern geboren**:
|
||||||
|
|
||||||
|
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
||||||
|
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
|
||||||
|
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
|
||||||
|
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||||
|
|
||||||
|
## Modelle & Rollen
|
||||||
|
|
||||||
|
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
|
||||||
|
|
||||||
|
| Rolle | Modell | Tempo | Aufgabe |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
|
||||||
|
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
|
||||||
|
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
|
||||||
|
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
|
||||||
|
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
|
||||||
|
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
||||||
|
|
||||||
|
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
||||||
|
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
||||||
|
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
||||||
|
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
||||||
|
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
||||||
|
|
||||||
|
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
|
||||||
|
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
|
||||||
|
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
||||||
|
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
||||||
|
|
||||||
|
## Qualitäts-Tore
|
||||||
|
|
||||||
|
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
|
||||||
|
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
|
||||||
|
2. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
|
||||||
|
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
|
||||||
|
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
|
||||||
|
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
|
||||||
|
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
|
||||||
|
|
||||||
|
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
||||||
|
Kontext-Komprimierung löscht still die Regeln mit.
|
||||||
|
|
||||||
|
## Selbsterhaltung
|
||||||
|
|
||||||
|
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
|
||||||
|
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
|
||||||
|
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
|
||||||
|
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
|
||||||
|
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
|
||||||
|
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
|
||||||
|
|
||||||
|
## API (Auswahl)
|
||||||
|
|
||||||
|
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
||||||
|
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
||||||
|
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
||||||
|
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||||
|
|
||||||
## Entwickeln
|
## 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_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
|
||||||
| `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 |
|
||||||
|
|
||||||
|
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
|
||||||
|
[`deploy/governor/README.md`](deploy/governor/README.md).
|
||||||
|
|
||||||
|
## Weiterlesen
|
||||||
|
|
||||||
|
| Dokument | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
||||||
|
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
||||||
|
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
||||||
|
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
||||||
|
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
||||||
|
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
||||||
|
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
|
||||||
|
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
|
||||||
|
|||||||
+12
-5
@@ -9,17 +9,16 @@ 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, AsyncGenerator, Awaitable, Callable
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
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 starlette.requests import Request
|
|
||||||
|
|
||||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
|
||||||
from routers import (
|
from routers import (
|
||||||
agent,
|
agent,
|
||||||
auftragsbuch,
|
auftragsbuch,
|
||||||
@@ -29,6 +28,7 @@ from routers import (
|
|||||||
eigenleben,
|
eigenleben,
|
||||||
events,
|
events,
|
||||||
gateway_proxy,
|
gateway_proxy,
|
||||||
|
governor,
|
||||||
health,
|
health,
|
||||||
hermes_ui,
|
hermes_ui,
|
||||||
ideen,
|
ideen,
|
||||||
@@ -42,8 +42,9 @@ from routers import (
|
|||||||
zeitmaschine,
|
zeitmaschine,
|
||||||
)
|
)
|
||||||
from routers import reminders as reminders_router
|
from routers import reminders as reminders_router
|
||||||
|
from services import ketten_digest, metrics_history, reminders, sentry, warmer
|
||||||
from services import memory as memory_svc
|
from services import memory as memory_svc
|
||||||
from services import reminders, sentry, warmer
|
from starlette.requests import Request
|
||||||
|
|
||||||
# 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__)).
|
||||||
@@ -68,6 +69,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
if sentry.ENABLED:
|
if sentry.ENABLED:
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
|
if ketten_digest.ENABLED:
|
||||||
|
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
|
||||||
|
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
|
||||||
|
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||||
|
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
if memory_svc.AUTO_DEDUPE_ENABLED:
|
||||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
||||||
log.info(
|
log.info(
|
||||||
@@ -136,6 +142,7 @@ app.include_router(maintenance.router)
|
|||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
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(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(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
||||||
|
app.include_router(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
|
||||||
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
||||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
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(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL, VERSION
|
from config import LLAMA_SWAP_URL, VERSION
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
from routers import gateway_proxy
|
from routers import gateway_proxy
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ 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():
|
def migrate():
|
||||||
print(f"Reading config from {CONFIG_PATH}...")
|
print(f"Reading config from {CONFIG_PATH}...")
|
||||||
|
|||||||
@@ -55,6 +55,17 @@
|
|||||||
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
||||||
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
|
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
|
||||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
"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,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")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import auftragsbuch
|
from services import auftragsbuch
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wäc
|
|||||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
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."""
|
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import announce
|
from services import announce
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import logging
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
|
from config import BOX_CONSOLE_UPSTREAM
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from config import BOX_CONSOLE_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ def _register(base: str, upstream: str) -> None:
|
|||||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
||||||
|
|
||||||
@router.websocket(f"/{base}/ws")
|
@router.websocket(f"/{base}/ws")
|
||||||
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
|
async def _proxy_ws(ws: WebSocket) -> None:
|
||||||
await ws.accept(subprotocol="tty")
|
await ws.accept(subprotocol="tty")
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import eigenleben
|
from services import eigenleben
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import MODELS_DIR
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ entfernen → app.py bindet wieder den lokalen Gateway ein.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from config import V1_UPSTREAM
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from config import V1_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1")
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import logging
|
|||||||
import os
|
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 config import LLAMA_SWAP_URL
|
|
||||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||||
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
||||||
from services.routing_policy import load_policy
|
from services.routing_policy import load_policy
|
||||||
@@ -33,6 +31,38 @@ _BILD_BESCHREIB_PROMPT = os.environ.get(
|
|||||||
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
|
"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:
|
def _ist_coder_alias(alias: str) -> bool:
|
||||||
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
|
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
|
||||||
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
|
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
|
||||||
@@ -158,6 +188,7 @@ async def models(request: Request):
|
|||||||
|
|
||||||
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()
|
||||||
@@ -206,7 +237,7 @@ async def _proxy(path: str, request: Request):
|
|||||||
async def gen():
|
async def gen():
|
||||||
try:
|
try:
|
||||||
async for chunk in r.aiter_raw():
|
async for chunk in r.aiter_raw():
|
||||||
record_stream_chunk(chunk, alias)
|
record_stream_chunk(chunk, alias, body.get("max_tokens"))
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
await r.aclose()
|
await r.aclose()
|
||||||
@@ -218,7 +249,7 @@ async def _proxy(path: str, request: Request):
|
|||||||
record_usage(resp_json.get("usage"), alias)
|
record_usage(resp_json.get("usage"), alias)
|
||||||
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
||||||
for c in resp_json.get("choices") or []):
|
for c in resp_json.get("choices") or []):
|
||||||
warn_truncation(alias)
|
warn_truncation(alias, body.get("max_tokens"))
|
||||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Governor — Fenster auf den Token-Waechter (:8100).
|
||||||
|
|
||||||
|
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
|
||||||
|
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
|
||||||
|
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
|
||||||
|
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
|
||||||
|
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
|
||||||
|
zweiten Port ausweichen muss.
|
||||||
|
|
||||||
|
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
|
||||||
|
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/governor")
|
||||||
|
async def governor_status() -> dict:
|
||||||
|
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as c:
|
||||||
|
r = await c.get(f"{GOVERNOR_URL}/governor/status")
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
data["reachable"] = True
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reachable": False,
|
||||||
|
"error": f"{type(exc).__name__}: {exc}",
|
||||||
|
"url": GOVERNOR_URL,
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
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")
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ import re
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
|
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import RedirectResponse, Response
|
from starlette.responses import RedirectResponse, Response
|
||||||
|
|
||||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuc
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import ideen
|
from services import ideen
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -12,6 +11,10 @@ router = APIRouter(prefix="/api")
|
|||||||
class IdeeIn(BaseModel):
|
class IdeeIn(BaseModel):
|
||||||
titel: str
|
titel: str
|
||||||
notiz: str = ""
|
notiz: str = ""
|
||||||
|
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Zed
|
||||||
|
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
||||||
|
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
|
||||||
|
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
|
||||||
|
|
||||||
|
|
||||||
class TaskIn(BaseModel):
|
class TaskIn(BaseModel):
|
||||||
@@ -23,6 +26,15 @@ class AntwortIn(BaseModel):
|
|||||||
antwort: 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")
|
@router.get("/ideen")
|
||||||
def list_queue() -> dict:
|
def list_queue() -> dict:
|
||||||
return ideen.list_queue()
|
return ideen.list_queue()
|
||||||
@@ -30,7 +42,8 @@ def list_queue() -> dict:
|
|||||||
|
|
||||||
@router.post("/ideen")
|
@router.post("/ideen")
|
||||||
def add_idea(body: IdeeIn) -> dict:
|
def add_idea(body: IdeeIn) -> dict:
|
||||||
res = ideen.add_idea(body.titel, body.notiz)
|
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"):
|
if not res.get("ok"):
|
||||||
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
|
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
|
||||||
return res
|
return res
|
||||||
@@ -44,6 +57,64 @@ def answer(body: AntwortIn) -> dict:
|
|||||||
return res
|
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")
|
@router.post("/ideen/archivieren")
|
||||||
def archive(body: TaskIn) -> dict:
|
def archive(body: TaskIn) -> dict:
|
||||||
res = ideen.archive_task(body.id)
|
res = ideen.archive_task(body.id)
|
||||||
@@ -52,6 +123,22 @@ def archive(body: TaskIn) -> dict:
|
|||||||
return res
|
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")
|
@router.get("/ideen/{id}/log")
|
||||||
def get_log(id: str) -> dict:
|
def get_log(id: str) -> dict:
|
||||||
return ideen.log_of(id)
|
return ideen.log_of(id)
|
||||||
@@ -60,3 +147,8 @@ def get_log(id: str) -> dict:
|
|||||||
@router.get("/ideen/{id}/ergebnis")
|
@router.get("/ideen/{id}/ergebnis")
|
||||||
def get_ergebnis(id: str) -> dict:
|
def get_ergebnis(id: str) -> dict:
|
||||||
return ideen.ergebnis_of(id)
|
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")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import time
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import memory
|
from services import memory
|
||||||
from services.voice_metrics import park, record_stage
|
from services.voice_metrics import park, record_stage
|
||||||
|
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
|
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
|
||||||
LAN-only wie alle MC2-Endpoints."""
|
LAN-only wie alle MC2-Endpoints."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import reminders
|
from services import reminders
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+59
-14
@@ -9,12 +9,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
|
|
||||||
from services import backup as backup_svc
|
from services import backup as backup_svc
|
||||||
from services import maintenance
|
from services import maintenance
|
||||||
from services.agent import agent_status
|
from services.agent import agent_status
|
||||||
@@ -37,6 +35,13 @@ def status() -> dict:
|
|||||||
return system_status()
|
return system_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/history")
|
||||||
|
def history(minutes: int = 60) -> dict:
|
||||||
|
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
||||||
|
from services import metrics_history
|
||||||
|
return metrics_history.history(minutes)
|
||||||
|
|
||||||
|
|
||||||
def _mem0_reachable() -> bool:
|
def _mem0_reachable() -> bool:
|
||||||
try:
|
try:
|
||||||
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||||
@@ -51,20 +56,60 @@ def _voice_reachable() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _user_unit_active(unit: str) -> bool:
|
||||||
|
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
|
||||||
|
Auf Windows/Dev schlägt das harmlos fehl (False)."""
|
||||||
|
if os.name != "posix":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
||||||
|
capture_output=True, text=True, timeout=3)
|
||||||
|
return r.stdout.strip() == "active"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system/services")
|
@router.get("/system/services")
|
||||||
def services() -> dict:
|
def services() -> dict:
|
||||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
|
"""Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel,
|
||||||
|
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
|
||||||
|
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
|
||||||
a = agent_status()
|
a = agent_status()
|
||||||
gw_url = f"{GATEWAY_URL}/v1"
|
gw_url = f"{GATEWAY_URL}/v1"
|
||||||
|
rows = [
|
||||||
|
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
||||||
|
"ok": engine_reachable(), "scope": "system"},
|
||||||
|
]
|
||||||
|
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
||||||
|
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
||||||
|
if V1_UPSTREAM:
|
||||||
|
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
||||||
|
"ok": gateway_reachable(), "scope": "user"})
|
||||||
|
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||||
|
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||||
|
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||||
|
"ok": True, "scope": "user"})
|
||||||
|
else:
|
||||||
|
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||||
|
"ok": gateway_reachable(), "scope": "user"})
|
||||||
|
rows += [
|
||||||
|
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
||||||
|
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
||||||
|
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||||
|
"ok": a["gateway_reachable"], "scope": "user"},
|
||||||
|
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||||
|
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||||
|
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
|
||||||
|
"ok": _mem0_reachable(), "scope": "user"},
|
||||||
|
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||||
|
"ok": _voice_reachable(), "scope": "user"},
|
||||||
|
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
||||||
|
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
||||||
|
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
||||||
|
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"services": [
|
"services": rows,
|
||||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
|
|
||||||
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
|
|
||||||
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
|
|
||||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"]},
|
|
||||||
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
|
|
||||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
|
|
||||||
],
|
|
||||||
"links": {
|
"links": {
|
||||||
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
||||||
"gateway": gw_url,
|
"gateway": gw_url,
|
||||||
@@ -88,7 +133,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
|||||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||||
|
import sys as _sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
|
||||||
from services import announce
|
from services import announce
|
||||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||||
Timer,
|
Timer,
|
||||||
@@ -29,8 +31,6 @@ from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (inter
|
|||||||
record_stage,
|
record_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
|
||||||
import sys as _sys
|
|
||||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||||
if _GUARD_DIR not in _sys.path:
|
if _GUARD_DIR not in _sys.path:
|
||||||
_sys.path.insert(0, _GUARD_DIR)
|
_sys.path.insert(0, _GUARD_DIR)
|
||||||
@@ -155,7 +155,7 @@ def voice_health() -> dict:
|
|||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
out["sidecar"] = r.status_code == 200
|
out["sidecar"] = r.status_code == 200
|
||||||
out["detail"] = r.json() if r.status_code == 200 else None
|
out["detail"] = r.json() if r.status_code == 200 else None
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
out["error"] = str(exc)
|
out["error"] = str(exc)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ def voice_voices() -> dict:
|
|||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ def voice_get_reference() -> dict:
|
|||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"active": False, "error": str(exc)}
|
return {"active": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import backup as backup_svc
|
from services import backup as backup_svc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -9,11 +9,15 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
from config import (
|
||||||
|
BOX_CONSOLE_PATH,
|
||||||
from config import (BOX_CONSOLE_UPSTREAM,
|
BOX_CONSOLE_UPSTREAM,
|
||||||
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
|
HERMES_API_URL,
|
||||||
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
|
HERMES_BUILTIN_UI_PATH,
|
||||||
|
HERMES_BUILTIN_UI_UPSTREAM,
|
||||||
|
HERMES_HOME,
|
||||||
|
PC_EXECUTOR_URL,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -161,6 +165,8 @@ def agent_status() -> dict:
|
|||||||
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
||||||
"mcp_server_count": _count_enabled_mcp_servers(),
|
"mcp_server_count": _count_enabled_mcp_servers(),
|
||||||
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
||||||
|
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
|
||||||
|
"pc_executor_url": PC_EXECUTOR_URL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -192,7 +198,7 @@ def set_agent_brain(model_id: str) -> dict:
|
|||||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||||
from services.llamaswap import set_ttl, DEFAULT_TTL
|
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||||
set_ttl(model_id, 0)
|
set_ttl(model_id, 0)
|
||||||
if old:
|
if old:
|
||||||
set_ttl(old, DEFAULT_TTL)
|
set_ttl(old, DEFAULT_TTL)
|
||||||
@@ -251,7 +257,7 @@ def update_brain_model(new_model: str) -> bool:
|
|||||||
|
|
||||||
# Restart the user-space service to apply changes
|
# Restart the user-space service to apply changes
|
||||||
try:
|
try:
|
||||||
import services.maintenance as maintenance
|
from services import maintenance
|
||||||
maintenance.restart_service("hermes-gateway")
|
maintenance.restart_service("hermes-gateway")
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -258,8 +258,12 @@ def list_proposals() -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
|
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
|
||||||
|
|
||||||
# Alle aktuellen Branches als gemeldet speichern
|
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
|
||||||
_save_announce_branches(aktuelle_branches)
|
# 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()
|
kandidaten = list_skill_kandidaten()
|
||||||
open_count = sum(1 for i in items
|
open_count = sum(1 for i in items
|
||||||
|
|||||||
@@ -51,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()
|
||||||
|
|||||||
+12
-10
@@ -10,8 +10,7 @@ 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"
|
||||||
|
|
||||||
@@ -36,7 +35,8 @@ IDE_MODELS = [
|
|||||||
|
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +113,8 @@ def build_snippets(host: str = DEFAULT_HOST,
|
|||||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
||||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
||||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
||||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · Ask/Debug→chat."},
|
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
||||||
|
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
||||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
||||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
|
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
|
||||||
},
|
},
|
||||||
@@ -125,18 +126,19 @@ def build_snippets(host: str = DEFAULT_HOST,
|
|||||||
|
|
||||||
def check_health() -> dict:
|
def check_health() -> dict:
|
||||||
"""Live-Erreichbarkeit der drei 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)."""
|
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"}
|
||||||
@@ -145,16 +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
|
||||||
|
|
||||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
desktop_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("http://127.0.0.1:9119/api/status")
|
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
desktop_gateway = ({"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": desktop_gateway}
|
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -22,8 +22,16 @@ log = logging.getLogger(__name__)
|
|||||||
_trunc_last: dict[str, float] = {}
|
_trunc_last: dict[str, float] = {}
|
||||||
_TRUNC_EVERY = 600.0 # s
|
_TRUNC_EVERY = 600.0 # s
|
||||||
|
|
||||||
|
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
|
||||||
|
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
|
||||||
|
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
|
||||||
|
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
|
||||||
|
_PING_MAXTOK = 16
|
||||||
|
|
||||||
def warn_truncation(model: str) -> None:
|
|
||||||
|
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
||||||
|
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
|
||||||
|
return
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
|
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
|
||||||
return
|
return
|
||||||
@@ -50,11 +58,11 @@ def record_usage(usage: dict | None, model: str) -> None:
|
|||||||
increment_tokens(prompt, completion, model=model)
|
increment_tokens(prompt, completion, model=model)
|
||||||
|
|
||||||
|
|
||||||
def record_stream_chunk(chunk: bytes, model: str) -> None:
|
def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
|
||||||
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
||||||
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
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:
|
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
|
||||||
warn_truncation(model)
|
warn_truncation(model, max_tokens)
|
||||||
if b'"usage"' not in chunk:
|
if b'"usage"' not in chunk:
|
||||||
return
|
return
|
||||||
text = chunk.decode("utf-8", errors="ignore")
|
text = chunk.decode("utf-8", errors="ignore")
|
||||||
|
|||||||
+934
-9
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"
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
|
||||||
|
|
||||||
|
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
|
||||||
|
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
|
||||||
|
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
|
||||||
|
|
||||||
|
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
|
||||||
|
einmal pro Karte, mit der Frage im Wortlaut,
|
||||||
|
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
|
||||||
|
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
|
||||||
|
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
|
||||||
|
|
||||||
|
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
|
||||||
|
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
|
||||||
|
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
|
||||||
|
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
|
||||||
|
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
|
||||||
|
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
|
||||||
|
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
|
||||||
|
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
|
||||||
|
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
|
||||||
|
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
|
||||||
|
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
|
||||||
|
|
||||||
|
|
||||||
|
def _kontext_druck(task_id: str) -> int:
|
||||||
|
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
|
||||||
|
mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster."""
|
||||||
|
try:
|
||||||
|
p = KANBAN_LOGS / f"{task_id}.log"
|
||||||
|
with p.open("rb") as f:
|
||||||
|
f.seek(max(0, p.stat().st_size - 200_000))
|
||||||
|
tail = f.read().decode("utf-8", errors="replace")
|
||||||
|
return tail.count("Compacting context")
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> dict:
|
||||||
|
try:
|
||||||
|
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||||
|
return d if isinstance(d, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict) -> None:
|
||||||
|
try:
|
||||||
|
tmp = STATE_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(STATE_PATH)
|
||||||
|
except OSError:
|
||||||
|
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _melden(subject: str, text: str) -> None:
|
||||||
|
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
|
||||||
|
from services import announce
|
||||||
|
announce.notify_telegram(subject, text)
|
||||||
|
try:
|
||||||
|
announce.add(text, subject, "ketten-digest", "silent")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _tick() -> None:
|
||||||
|
from services import ideen
|
||||||
|
|
||||||
|
data = ideen.list_queue()
|
||||||
|
if not data.get("available"):
|
||||||
|
return
|
||||||
|
items = data.get("items") or []
|
||||||
|
projekte = data.get("projekte") or []
|
||||||
|
state = _load_state()
|
||||||
|
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
||||||
|
projekt_state: dict = state.setdefault("projekte", {})
|
||||||
|
dirty = False
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
|
||||||
|
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
|
||||||
|
for it in items:
|
||||||
|
if it.get("status") != "blocked":
|
||||||
|
continue
|
||||||
|
frage = (it.get("frage") or "").strip()
|
||||||
|
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
|
||||||
|
if key in gemeldete_fragen:
|
||||||
|
continue
|
||||||
|
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}“\n"
|
||||||
|
+ (f"Frage: {frage[:500]}\n" if frage else "")
|
||||||
|
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
|
||||||
|
_melden("[Werkstatt]", text)
|
||||||
|
gemeldete_fragen[key] = now
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
|
||||||
|
gemeldete_haenger: dict = state.setdefault("haenger", {})
|
||||||
|
for it in items:
|
||||||
|
if it.get("status") != "running":
|
||||||
|
continue
|
||||||
|
puls = it.get("puls_alter")
|
||||||
|
if puls is None or puls < HANG_SEKUNDEN:
|
||||||
|
continue
|
||||||
|
key = f"{it['id']}:{it.get('gestartet') or 0}"
|
||||||
|
if key in gemeldete_haenger:
|
||||||
|
continue
|
||||||
|
minuten = int(puls / 60)
|
||||||
|
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
|
||||||
|
f"seit {minuten} min kein Lebenszeichen vom Worker.")
|
||||||
|
druck = _kontext_druck(it["id"])
|
||||||
|
if druck >= 2:
|
||||||
|
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
|
||||||
|
"die Aufgabe ist womöglich zu groß geschnitten.")
|
||||||
|
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
|
||||||
|
_melden("[Werkstatt]", text)
|
||||||
|
gemeldete_haenger[key] = now
|
||||||
|
dirty = True
|
||||||
|
if len(gemeldete_haenger) > 200:
|
||||||
|
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
|
||||||
|
gemeldete_haenger.pop(k, None)
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
|
||||||
|
if len(gemeldete_fragen) > 200:
|
||||||
|
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
|
||||||
|
gemeldete_fragen.pop(k, None)
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
|
||||||
|
for p in projekte:
|
||||||
|
ps = projekt_state.setdefault(p["key"], {})
|
||||||
|
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
|
||||||
|
|
||||||
|
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
|
||||||
|
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
|
||||||
|
"Ergebnisse liegen im Auftragsbuch.")
|
||||||
|
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
|
||||||
|
dirty = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
aktiv = p.get("laeuft", 0) > 0
|
||||||
|
if not aktiv:
|
||||||
|
continue
|
||||||
|
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
|
||||||
|
continue
|
||||||
|
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
|
||||||
|
naechste = [i for i in items if i.get("projekt") == p["key"]
|
||||||
|
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
|
||||||
|
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
|
||||||
|
for l in laufende[:2]:
|
||||||
|
note = f" — {l['notiz']}" if l.get("notiz") else ""
|
||||||
|
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
|
||||||
|
if naechste:
|
||||||
|
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
|
||||||
|
haengt = p.get("haengt", 0)
|
||||||
|
if haengt:
|
||||||
|
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
|
||||||
|
_melden("[Werkstatt]", "\n".join(zeilen))
|
||||||
|
ps.update(letzter_puls=now, letzter_stand=fertig)
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_loop() -> None:
|
||||||
|
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
|
||||||
|
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_tick)
|
||||||
|
except Exception:
|
||||||
|
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
|
||||||
|
await asyncio.sleep(INTERVAL)
|
||||||
@@ -11,12 +11,17 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
|
CMD_TEMPLATE,
|
||||||
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
|
CONFIG_PATH,
|
||||||
|
DEFAULT_TTL,
|
||||||
|
DRAFTS_DIR,
|
||||||
|
LLAMA_SWAP_URL,
|
||||||
|
SPEC_DRAFT_MODEL_PATH,
|
||||||
|
SPEC_DRAFT_N_MAX,
|
||||||
|
SPEC_TYPE,
|
||||||
)
|
)
|
||||||
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -143,19 +148,36 @@ def model_id_from_path(model_path: str) -> str:
|
|||||||
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
||||||
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
||||||
d = os.path.basename(os.path.dirname(model_path))
|
d = os.path.basename(os.path.dirname(model_path))
|
||||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
|
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
|
||||||
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
||||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
|
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
|
||||||
if not name:
|
if not name:
|
||||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
|
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
|
||||||
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
||||||
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
|
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
|
||||||
return name or "modell"
|
return name or "modell"
|
||||||
|
|
||||||
|
|
||||||
|
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
|
||||||
|
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
|
||||||
|
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
|
||||||
|
PROTECTED_ALIASES = {"hermes", "embed"}
|
||||||
|
|
||||||
|
|
||||||
|
def protected_alias_of(model_id: str) -> str | None:
|
||||||
|
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
|
||||||
|
spec = (read_config().get("models") or {}).get(model_id)
|
||||||
|
if isinstance(spec, dict):
|
||||||
|
for a in spec.get("aliases") or []:
|
||||||
|
if str(a).lower() in PROTECTED_ALIASES:
|
||||||
|
return str(a).lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
||||||
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
|
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
|
||||||
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
|
entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
|
||||||
|
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
|
||||||
models = cfg.get("models") or {}
|
models = cfg.get("models") or {}
|
||||||
role = (role or "").strip().lower()
|
role = (role or "").strip().lower()
|
||||||
if role:
|
if role:
|
||||||
@@ -169,8 +191,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
|||||||
spec.pop("aliases", None)
|
spec.pop("aliases", None)
|
||||||
spec = models.get(model_id)
|
spec = models.get(model_id)
|
||||||
if isinstance(spec, dict):
|
if isinstance(spec, dict):
|
||||||
if role and role != model_id.lower():
|
keep = [a for a in (spec.get("aliases") or [])
|
||||||
spec["aliases"] = [role]
|
if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
|
||||||
|
new = keep + ([role] if role and role != model_id.lower() else [])
|
||||||
|
if new:
|
||||||
|
spec["aliases"] = new
|
||||||
else:
|
else:
|
||||||
spec.pop("aliases", None)
|
spec.pop("aliases", None)
|
||||||
|
|
||||||
@@ -202,7 +227,7 @@ def write_config(cfg: dict) -> None:
|
|||||||
try:
|
try:
|
||||||
from services import warmer
|
from services import warmer
|
||||||
warmer.nudge()
|
warmer.nudge()
|
||||||
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
@@ -214,9 +239,12 @@ def write_config(cfg: dict) -> None:
|
|||||||
|
|
||||||
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||||
ttl: int | None = None, mmproj_path: str | None = None,
|
ttl: int | None = None, mmproj_path: str | None = None,
|
||||||
jinja: bool = False) -> str:
|
jinja: bool = False, set_alias: bool = True) -> str:
|
||||||
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
|
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
|
||||||
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
|
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
|
||||||
|
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
|
||||||
|
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
|
||||||
|
minutenlang auf eine Datei, die noch gar nicht existiert)."""
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
model_id = model_id_from_path(model_path)
|
model_id = model_id_from_path(model_path)
|
||||||
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
|
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
|
||||||
@@ -242,11 +270,19 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
|||||||
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
|
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
|
||||||
cmd += spec_draft_flags(model_path)
|
cmd += spec_draft_flags(model_path)
|
||||||
|
|
||||||
cfg.setdefault("models", {})[model_id] = {
|
# Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
|
||||||
|
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
|
||||||
|
old_aliases = (cfg.get("models") or {}).get(model_id, {})
|
||||||
|
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
|
||||||
|
entry: dict = {
|
||||||
"cmd": LiteralScalarString(cmd + "\n"),
|
"cmd": LiteralScalarString(cmd + "\n"),
|
||||||
"ttl": ttl if ttl is not None else DEFAULT_TTL,
|
"ttl": ttl if ttl is not None else DEFAULT_TTL,
|
||||||
}
|
}
|
||||||
set_role_alias(cfg, model_id, role)
|
if old_aliases:
|
||||||
|
entry["aliases"] = old_aliases
|
||||||
|
cfg.setdefault("models", {})[model_id] = entry
|
||||||
|
if set_alias:
|
||||||
|
set_role_alias(cfg, model_id, role)
|
||||||
write_config(cfg)
|
write_config(cfg)
|
||||||
return model_id
|
return model_id
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ from services import catalog, discover, jobengine, llamaswap, system
|
|||||||
|
|
||||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||||
SYSTEM_SERVICES = {"llama-swap"}
|
SYSTEM_SERVICES = {"llama-swap"}
|
||||||
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
|
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||||
|
"hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
|
||||||
|
|
||||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
@@ -34,7 +35,7 @@ _engine_cache = {"ts": 0.0, "avail": False}
|
|||||||
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
|
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
|
||||||
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
|
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
|
||||||
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
|
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
|
||||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
|
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _latest_engine_asset_release() -> dict | None:
|
def _latest_engine_asset_release() -> dict | None:
|
||||||
@@ -288,7 +289,7 @@ def model_upgrades() -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
base = rec.split("/")[-1].lower()
|
base = rec.split("/")[-1].lower()
|
||||||
stem = base[:-5] if base.endswith("-gguf") else base
|
stem = base.removesuffix("-gguf")
|
||||||
if base in cmds or (stem and stem in cmds):
|
if base in cmds or (stem and stem in cmds):
|
||||||
continue # schon installiert
|
continue # schon installiert
|
||||||
out.append({"role": role, "title": c["title"], "repo": rec})
|
out.append({"role": role, "title": c["title"], "repo": rec})
|
||||||
@@ -342,7 +343,7 @@ def _os_held_back() -> list[dict]:
|
|||||||
held.append({"name": name, "reason": reason})
|
held.append({"name": name, "reason": reason})
|
||||||
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
||||||
reason = None
|
reason = None
|
||||||
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
|
except Exception:
|
||||||
pass
|
pass
|
||||||
held.sort(key=lambda p: p["name"])
|
held.sort(key=lambda p: p["name"])
|
||||||
return held
|
return held
|
||||||
@@ -367,7 +368,7 @@ def os_update_details() -> dict:
|
|||||||
out_pkgs.sort(key=lambda p: p["name"])
|
out_pkgs.sort(key=lambda p: p["name"])
|
||||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
||||||
"held_back": _os_held_back()}
|
"held_back": _os_held_back()}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
@@ -392,7 +393,7 @@ def engine_update_details() -> dict:
|
|||||||
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
||||||
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
||||||
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -417,7 +418,7 @@ def swap_update_details() -> dict:
|
|||||||
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
||||||
"verteilt und Sprachmodelle heiß nachlädt).")
|
"verteilt und Sprachmodelle heiß nachlädt).")
|
||||||
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -495,7 +496,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
|
|||||||
if text:
|
if text:
|
||||||
cache.update(key=key, data=data)
|
cache.update(key=key, data=data)
|
||||||
return data
|
return data
|
||||||
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
|
except Exception as exc:
|
||||||
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
||||||
"action_needed": None, "action_text": ""}
|
"action_needed": None, "action_text": ""}
|
||||||
|
|
||||||
@@ -533,7 +534,7 @@ def hermes_update_details() -> dict:
|
|||||||
info["behind"] = len(commits)
|
info["behind"] = len(commits)
|
||||||
if commits:
|
if commits:
|
||||||
info.update(_summarize_hermes_commits(commits))
|
info.update(_summarize_hermes_commits(commits))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -574,7 +575,7 @@ def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
|||||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
||||||
|
|
||||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"ok": False, "out": "", "err": str(exc)}
|
return {"ok": False, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import re
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import MEM0_SERVICE_URL
|
from config import MEM0_SERVICE_URL
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ from pathlib import Path
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
from services import announce
|
from services import announce
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import time
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
|
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||||
|
|
||||||
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
|
||||||
from services import announce, llamaswap
|
from services import announce, llamaswap
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -100,7 +100,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
|||||||
|
|
||||||
|
|
||||||
class _Watch:
|
class _Watch:
|
||||||
__slots__ = ("fails", "alerted", "alert_ts")
|
__slots__ = ("alert_ts", "alerted", "fails")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.fails = 0 # Fehl-Ticks in Folge
|
self.fails = 0 # Fehl-Ticks in Folge
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL
|
from config import LLAMA_SWAP_URL
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -74,7 +73,18 @@ def _run_warmup() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _warmset_members() -> set[str]:
|
def _warmset_members() -> set[str]:
|
||||||
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
|
"""Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
|
||||||
|
|
||||||
|
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
|
||||||
|
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
|
||||||
|
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
|
||||||
|
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
|
||||||
|
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
|
||||||
|
nur die erste ausdrücken.
|
||||||
|
"""
|
||||||
|
explizit = os.environ.get("MC_WARMSET", "").split()
|
||||||
|
if explizit:
|
||||||
|
return set(explizit)
|
||||||
try:
|
try:
|
||||||
from services import llamaswap
|
from services import llamaswap
|
||||||
groups = llamaswap.list_groups() or {}
|
groups = llamaswap.list_groups() or {}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async def volume_control(req: dict):
|
|||||||
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)
|
||||||
|
|||||||
@@ -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`*
|
||||||
@@ -29,10 +29,13 @@ hint=""
|
|||||||
ctx="$(cat <<EOF
|
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.]
|
[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}
|
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.
|
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/.
|
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).
|
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).
|
||||||
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07. — vier Worker starben an finish_reason=length): Dein Kontext ist ENDLICH. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte; grosse Dateien nur in Ausschnitten. 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.
|
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
|
EOF
|
||||||
)"
|
)"
|
||||||
|
|
||||||
|
|||||||
@@ -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("{}")
|
||||||
@@ -12,13 +12,18 @@ Task-Scope waere hier sogar gefaehrlich: Worker tragen als task_id den Session-Z
|
|||||||
(z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check liesse also alle
|
(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).
|
Worker-Schreibzugriffe durch (Bug 13.07., so entdeckt).
|
||||||
|
|
||||||
WICHTIG: Gesperrt wird NUR der home-verankerte Live-Checkout ($HOME/mission-control-v2), NIE
|
WICHTIG: Gesperrt wird der home-verankerte Live-Checkout ($HOME/mission-control-v2); der
|
||||||
der blosse Name "mission-control-v2" (der Workspace-Klon enthaelt ihn) — sonst waere die
|
blosse Name "mission-control-v2" zaehlt NUR, wenn er vom cwd aus in einen Tabu-Pfad aufloest
|
||||||
legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden nur Schreib-Operationen.
|
(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.
|
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
||||||
"""
|
"""
|
||||||
import sys, json, os, re
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def out(obj):
|
def out(obj):
|
||||||
@@ -78,6 +83,8 @@ if tool == "terminal":
|
|||||||
cmd = ti.get("command") or ""
|
cmd = ti.get("command") or ""
|
||||||
if not cmd:
|
if not cmd:
|
||||||
out({})
|
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
|
# 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
|
# Folge bauten Worker git-init-Orphans ohne gemeinsamen Ursprung mit main
|
||||||
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
|
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
|
||||||
@@ -86,13 +93,50 @@ if tool == "terminal":
|
|||||||
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
|
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
|
||||||
# projekt-start neue Repos initialisieren).
|
# projekt-start neue Repos initialisieren).
|
||||||
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
|
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
|
||||||
if (payload.get("cwd") or "").startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
|
if cwd_raw.startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
|
||||||
out({"action": "block", "message": (
|
out({"action": "block", "message": (
|
||||||
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
|
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
|
||||||
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
|
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
|
||||||
"12.–14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
|
"12.–14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
|
||||||
"https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 "
|
"http://192.168.178.153:3000/Hitonabi/mission-control-v2 "
|
||||||
"(Credentials liegen in ~/.git-credentials), dann Branch von origin/main abzweigen.")})
|
"(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-verankerte Tabu-Formen. Der Workspace-Klon
|
||||||
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
|
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
|
||||||
tabu_forms = [
|
tabu_forms = [
|
||||||
@@ -100,16 +144,24 @@ if tool == "terminal":
|
|||||||
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
|
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
|
||||||
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
|
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
|
||||||
]
|
]
|
||||||
tabu_alt = "(?:" + "|".join(re.escape(t) for t in tabu_forms) + ")"
|
# (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):
|
if not re.search(tabu_alt, cmd):
|
||||||
out({}) # kein home-verankerter Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
|
out({}) # kein Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
|
||||||
|
|
||||||
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
|
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
|
||||||
if re.search(
|
if re.search(git_write, cmd):
|
||||||
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|remote|config)\b",
|
|
||||||
cmd,
|
|
||||||
):
|
|
||||||
block()
|
block()
|
||||||
# (b) sed -i, das einen Tabu-Pfad anfasst.
|
# (b) sed -i, das einen Tabu-Pfad anfasst.
|
||||||
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
|
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
|
||||||
|
|||||||
@@ -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,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
|
||||||
|
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
|
||||||
|
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
|
||||||
|
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
|
||||||
|
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
|
||||||
|
|
||||||
|
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
|
||||||
|
[ -n "$TOKEN" ] || exit 0
|
||||||
|
|
||||||
|
python3 - "$TOKEN" "$STATE" <<'PY'
|
||||||
|
import json, sys, urllib.request
|
||||||
|
|
||||||
|
token, state_pfad = sys.argv[1], sys.argv[2]
|
||||||
|
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
||||||
|
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
|
||||||
|
BASE = "http://192.168.178.153:3000/api/v1"
|
||||||
|
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
|
||||||
|
|
||||||
|
|
||||||
|
def api(pfad):
|
||||||
|
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
gesehen = set(open(state_pfad).read().split())
|
||||||
|
except OSError:
|
||||||
|
gesehen = set()
|
||||||
|
|
||||||
|
neu = []
|
||||||
|
try:
|
||||||
|
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
|
||||||
|
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
|
||||||
|
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
|
||||||
|
repos = api("/repos/search?limit=50&private=true").get("data", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for repo in repos:
|
||||||
|
full = repo.get("full_name", "")
|
||||||
|
if not full or repo.get("archived"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
d = api(f"/repos/{full}/actions/tasks?limit=1")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
|
||||||
|
for t in rows[:1]:
|
||||||
|
kennung = f"{full}#{t.get('id')}"
|
||||||
|
status = t.get("status")
|
||||||
|
if status == "failure" and kennung not in gesehen:
|
||||||
|
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
|
||||||
|
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
|
||||||
|
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
|
||||||
|
headers={"Authorization": "token " + token})
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
log_ende = r.read().decode(errors="replace")[-4000:]
|
||||||
|
wirklich_rot = "Job succeeded" not in log_ende
|
||||||
|
except Exception:
|
||||||
|
wirklich_rot = True
|
||||||
|
if wirklich_rot:
|
||||||
|
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
|
||||||
|
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
|
||||||
|
# darf nicht schon als gesehen gelten.
|
||||||
|
if status in ("failure", "success", "cancelled", "skipped"):
|
||||||
|
gesehen.add(kennung)
|
||||||
|
|
||||||
|
if neu:
|
||||||
|
print("🔴 CI-AMPEL ROT:")
|
||||||
|
for full, sha, name in neu:
|
||||||
|
print(f"• {full} @ {sha} ({name})")
|
||||||
|
print(f" {WEB}/{full}/actions")
|
||||||
|
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
||||||
|
|
||||||
|
with open(state_pfad, "w") as f:
|
||||||
|
f.write("\n".join(sorted(gesehen)))
|
||||||
|
PY
|
||||||
@@ -6,6 +6,20 @@ LEBENDEN Box (Dienste, Modelle, Latenzen, Logs, Ressourcen). Keine Persona, kein
|
|||||||
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
|
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
|
||||||
du reparierst nicht.**
|
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)
|
## Eiserne Regeln (nicht verhandelbar)
|
||||||
1. **MESSEN, NICHT ÄNDERN.** Du fasst das Live-System NICHT an: kein `systemctl restart`, kein
|
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
|
Config-Edit, kein Deploy/Merge/Push, kein Laden/Entladen von Modellen, das das Warm-Set
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# curator-archive-watchdog.sh
|
||||||
|
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
|
||||||
|
|
||||||
|
# Python-Skript ausführen
|
||||||
|
python3 "$PYTHON_SCRIPT"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# curator-idle-watchdog.sh
|
||||||
|
# Prüft den Curator-Idle-Status und triggert Reaktivierung nach 14 Tagen
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
|
||||||
|
LOG_FILE="$LOG_DIR/idle-watchdog-$(date +%Y%m%d).log"
|
||||||
|
THRESHOLD_DAYS=14
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Curator-Status auslesen
|
||||||
|
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
|
||||||
|
log "FEHLER: hermes curator status fehlgeschlagen"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prüfen, ob Curator ENABLED ist
|
||||||
|
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
|
||||||
|
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Letzten Lauf extrahieren (Format: "last run: 17d ago")
|
||||||
|
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
|
||||||
|
if [[ -z "$LAST_RUN_LINE" ]]; then
|
||||||
|
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
|
||||||
|
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
|
||||||
|
|
||||||
|
# Prüfen, ob "d ago" vorhanden ist
|
||||||
|
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
|
||||||
|
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen) → überspringe"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
|
||||||
|
|
||||||
|
# Threshold prüfen
|
||||||
|
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
|
||||||
|
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage) → kein Trigger nötig"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reaktivierung triggern
|
||||||
|
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS) → triggere 'hermes curator run'"
|
||||||
|
if hermes curator run 2>&1 | tee -a "$LOG_FILE"; then
|
||||||
|
log "ERFOLG: Curator-Reaktivierung gestartet"
|
||||||
|
else
|
||||||
|
log "FEHLER: Curator-Reaktivierung fehlgeschlagen"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# curator-monitor.sh
|
||||||
|
# Prüft den Curator-Idle-Status mit 7-Tage-Schwellenwert.
|
||||||
|
# GIBT AUS: PASS oder FAIL mit Detaillierung.
|
||||||
|
# EXIT 0 = OK (Curator aktiv innerhalb von 7 Tagen)
|
||||||
|
# EXIT 1 = FAIL (Curator >7 Tage idle) –Monitoring-Skript erkannt Problem
|
||||||
|
#
|
||||||
|
# --dry-run DAYS Simuliert eine >DAYS-alte Curator-Auszeit (für Tests)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
|
||||||
|
LOG_FILE="$LOG_DIR/monitor-$(date +%Y%m%d).log"
|
||||||
|
THRESHOLD_DAYS=7
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Argumente parsen ---
|
||||||
|
DRY_RUN=false
|
||||||
|
DRY_RUN_DAYS=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
|
||||||
|
DRY_RUN_DAYS="$2"
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
DRY_RUN_DAYS=8 # Default-Testwert
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- LIVE-PRÜFUNG oder DRY-RUN ---
|
||||||
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
|
log "DRY-RUN: Simuliere Curator-Auszeit von $DRY_RUN_DAYS Tagen"
|
||||||
|
DAYS_SINCE_LAST_RUN="$DRY_RUN_DAYS"
|
||||||
|
else
|
||||||
|
# Live-Status abfragen
|
||||||
|
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
|
||||||
|
log "FEHLER: hermes curator status fehlgeschlagen"
|
||||||
|
echo "FAIL: hermes curator status fehlgeschlagen"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prüfen, ob Curator ENABLED ist
|
||||||
|
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
|
||||||
|
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
|
||||||
|
echo "PASS: Curator ist nicht ENABLED (keine Aktivität nötig)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Letzten Lauf extrahieren
|
||||||
|
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
|
||||||
|
if [[ -z "$LAST_RUN_LINE" ]]; then
|
||||||
|
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
|
||||||
|
echo "FAIL: Konnte 'last run' nicht extrahieren"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
|
||||||
|
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
|
||||||
|
|
||||||
|
# Tage extrahieren
|
||||||
|
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
|
||||||
|
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen)"
|
||||||
|
echo "PASS: Curator ist aktuell aktiv oder <1 Tag idle"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
|
||||||
|
|
||||||
|
# --- MONITORING-LOGIK (keine Reaktivierung!) ---
|
||||||
|
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
|
||||||
|
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage)"
|
||||||
|
echo "PASS: Curator ist aktiv ($DAYS_SINCE_LAST_RUN Tage idle)"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS)"
|
||||||
|
echo "FAIL: Curator-Cron ist für $DAYS_SINCE_LAST_RUN Tage stumm (>= $THRESHOLD_DAYS-Tage-Schwellenwert)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Curator Auto-Archivierung: Archiviert Skills nach 14 Tagen Inaktivität.
|
||||||
|
|
||||||
|
Liest `.curator_state` und `.usage.json` aus ~/.hermes/skills/, prüft
|
||||||
|
auf Inaktivität (>14 Tage last_used_at), schließt aus (pinned, system)
|
||||||
|
und verschiebt in .archive/<Kategorie>/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
|
||||||
|
CURATOR_STATE_FILE = HERMES_SKILLS_DIR / ".curator_state"
|
||||||
|
USAGE_JSON_FILE = HERMES_SKILLS_DIR / ".usage.json"
|
||||||
|
ARCHIVE_DIR = HERMES_SKILLS_DIR / ".archive"
|
||||||
|
LOG_DIR = Path.home() / ".hermes" / "logs" / "curator"
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict:
|
||||||
|
"""JSON-Datei laden."""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, data: dict) -> None:
|
||||||
|
"""JSON-Datei speichern (mit Pretty-Print)."""
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def is_stale(skill_usage: dict, threshold_days: int = 14) -> bool:
|
||||||
|
"""Prüft, ob ein Skill älter als threshold_days ist."""
|
||||||
|
last_used = skill_usage.get("last_used_at")
|
||||||
|
if not last_used:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ISO-Format mit optionaler Zeitzone
|
||||||
|
last_used_str = last_used.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
last_used_dt = datetime.fromisoformat(last_used_str)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
delta = now - last_used_dt
|
||||||
|
return delta.days >= threshold_days
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded(skill_name: str, excluded: list) -> bool:
|
||||||
|
"""Prüft, ob ein Skill ausgeschlossen ist."""
|
||||||
|
return skill_name in excluded
|
||||||
|
|
||||||
|
|
||||||
|
def is_pinned(skill_usage: dict) -> bool:
|
||||||
|
"""Prüft, ob ein Skill angepinnt ist."""
|
||||||
|
return skill_usage.get("pinned", False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_category(skill_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Ermittelt die Kategorie eines Skills basierend auf dem Verzeichnisnamen.
|
||||||
|
Gibt 'unknown' zurück, wenn keine Kategorie gefunden wird.
|
||||||
|
"""
|
||||||
|
# Versuche, die Kategorie aus dem Verzeichnis zu ermitteln
|
||||||
|
# z.B. 'betrieb-playbook' → 'betrieb-playbook' (direkt unter skills/)
|
||||||
|
# 'apple' (in .archive/apple/) → 'apple'
|
||||||
|
# Falls der Skill direkt in skills/ liegt ohne Unterverzeichnis:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_archive_category(category: str) -> Path:
|
||||||
|
"""Erstellt das Archiv-Verzeichnis für eine Kategorie, falls nötig."""
|
||||||
|
category_path = ARCHIVE_DIR / category
|
||||||
|
category_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return category_path
|
||||||
|
|
||||||
|
|
||||||
|
def move_skill_to_archive(skill_name: str, category: str) -> Path:
|
||||||
|
"""
|
||||||
|
Verschiebt ein Skill-Verzeichnis in das Archiv.
|
||||||
|
Gibt den neuen Pfad zurück.
|
||||||
|
"""
|
||||||
|
source = HERMES_SKILLS_DIR / skill_name
|
||||||
|
target_category = ensure_archive_category(category)
|
||||||
|
target = target_category / skill_name
|
||||||
|
|
||||||
|
if source.exists():
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def archive_skill(skill_name: str, usage_data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Archiviert einen Skill:
|
||||||
|
- Setzt state auf 'archived'
|
||||||
|
- Setzt archived_at auf aktuelle Zeit
|
||||||
|
- Verschiebt das Verzeichnis nach .archive/
|
||||||
|
Gibt das aktualisierte Usage-Dict zurück.
|
||||||
|
"""
|
||||||
|
usage_data[skill_name]["state"] = "archived"
|
||||||
|
usage_data[skill_name]["archived_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
return usage_data
|
||||||
|
|
||||||
|
|
||||||
|
def log_archive_action(skill_name: str, category: str, success: bool, message: str) -> None:
|
||||||
|
"""Protokolliert eine Archivierungsaktion."""
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = LOG_DIR / f"archive-{datetime.now(timezone.utc).date().isoformat()}.log"
|
||||||
|
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
status = "SUCCESS" if success else "FAILED"
|
||||||
|
log_line = f"[{timestamp}] {status} | {skill_name} ({category}) | {message}\n"
|
||||||
|
|
||||||
|
with open(log_file, "a", encoding="utf-8") as f:
|
||||||
|
f.write(log_line)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Hauptfunktion: Archiviert stale Skills."""
|
||||||
|
# 1. Config laden
|
||||||
|
if not CURATOR_STATE_FILE.exists():
|
||||||
|
print(f"Fehler: {CURATOR_STATE_FILE} nicht gefunden.")
|
||||||
|
return
|
||||||
|
|
||||||
|
curator_state = load_json(CURATOR_STATE_FILE)
|
||||||
|
archivierung = curator_state.get("archivierung", {})
|
||||||
|
if not archivierung.get("enabled", False):
|
||||||
|
print("Archivierung ist nicht aktiviert.")
|
||||||
|
return
|
||||||
|
|
||||||
|
threshold_days = archivierung.get("threshold_days", 14)
|
||||||
|
excluded_skills = archivierung.get("excluded_skills", [])
|
||||||
|
|
||||||
|
# 2. Usage-Daten laden
|
||||||
|
if not USAGE_JSON_FILE.exists():
|
||||||
|
print(f"Fehler: {USAGE_JSON_FILE} nicht gefunden.")
|
||||||
|
return
|
||||||
|
|
||||||
|
usage_data = load_json(USAGE_JSON_FILE)
|
||||||
|
|
||||||
|
# 3. Skills prüfen
|
||||||
|
archived_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for skill_name, skill_usage in usage_data.items():
|
||||||
|
# Ausschlusskriterien prüfen
|
||||||
|
if is_excluded(skill_name, excluded_skills):
|
||||||
|
print(f"Übersprungen (Exkludiert): {skill_name}")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_pinned(skill_usage):
|
||||||
|
print(f"Übersprungen (Gepinnt): {skill_name}")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inaktivität prüfen
|
||||||
|
if not is_stale(skill_usage, threshold_days):
|
||||||
|
print(f"Übersprungen (Aktiv): {skill_name}")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Archivierung durchführen
|
||||||
|
category = get_skill_category(skill_name) or "unknown"
|
||||||
|
print(f"Archiviere: {skill_name} → {category}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Usage aktualisieren
|
||||||
|
usage_data = archive_skill(skill_name, usage_data)
|
||||||
|
|
||||||
|
# Verzeichnis verschieben
|
||||||
|
move_skill_to_archive(skill_name, category)
|
||||||
|
|
||||||
|
# Erfolg protokollieren
|
||||||
|
log_archive_action(skill_name, category, True, "Skill archiviert")
|
||||||
|
|
||||||
|
archived_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
log_archive_action(skill_name, category, False, str(e))
|
||||||
|
print(f"Fehler beim Archivieren von {skill_name}: {e}")
|
||||||
|
|
||||||
|
# 4. Usage-Daten speichern
|
||||||
|
save_json(USAGE_JSON_FILE, usage_data)
|
||||||
|
|
||||||
|
# 5. Archivierungs-Zeitstempel aktualisieren
|
||||||
|
curator_state["archivierung"]["last_archive_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
save_json(CURATOR_STATE_FILE, curator_state)
|
||||||
|
|
||||||
|
print(f"\nArchivierung abgeschlossen: {archived_count} archiviert, {skipped_count} übersprungen.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+56
-2
@@ -89,6 +89,9 @@ cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.t
|
|||||||
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
|
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
|
||||||
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
|
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
|
||||||
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
|
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
|
||||||
|
# Morgen-Digest (Nächtliche Telegram-Zusammenfassung, 26.07.2026): fasst alle Meldungen zwischen 0 und 7 Uhr zusammen.
|
||||||
|
cp "$SRC/deploy/mc2-morgen-digest.service" "$HOME/.config/systemd/user/mc2-morgen-digest.service"
|
||||||
|
cp "$SRC/deploy/mc2-morgen-digest.timer" "$HOME/.config/systemd/user/mc2-morgen-digest.timer"
|
||||||
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
|
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
|
||||||
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
|
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
|
||||||
mkdir -p "$HOME/.hermes/scripts"
|
mkdir -p "$HOME/.hermes/scripts"
|
||||||
@@ -105,6 +108,8 @@ cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
|
|||||||
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
|
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
|
||||||
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
|
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
|
||||||
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
|
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
|
||||||
|
# Wrapper für nächtliche Crons, um Telegram-Spam in den Morgen-Digest zu leiten.
|
||||||
|
cp "$SRC/deploy/night-cron-wrapper.sh" "$HOME/.hermes/scripts/night-cron-wrapper.sh"
|
||||||
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
|
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
|
||||||
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
|
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
|
||||||
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
|
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
|
||||||
@@ -115,6 +120,24 @@ cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh"
|
|||||||
# eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung.
|
# eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung.
|
||||||
cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
|
cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
|
||||||
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
|
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
|
||||||
|
# Trend-Radar (17.07.2026, Sa 05:15): Live-Puls aller Rollen + Engine-A/B + Watch-Liste +
|
||||||
|
# HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche. Prüfstand (So 01:00, --no-agent):
|
||||||
|
# volles Programm (Coding/Agent/Recherche/Deutsch/Vision) gegen die Amtsinhaber-Baseline;
|
||||||
|
# Harness + Suiten liegen im Repo ($SRC/deploy/pruefstand/), nur der Runner wird kopiert.
|
||||||
|
cp "$SRC/deploy/trend-radar-feed.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh"
|
||||||
|
cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh"
|
||||||
|
# Restore-Probe + Venv-Audit (17.07.2026, monatliche --no-agent-Crons): echte
|
||||||
|
# Wiederherstellungs-Drills (PBS-Canary + Tarball) und CVE-/Outdated-Audit der
|
||||||
|
# Python-Nebendienst-venvs (mem0, voice) — melden nur, ändern nichts.
|
||||||
|
cp "$SRC/deploy/restore-probe.sh" "$HOME/.hermes/scripts/restore-probe.sh"
|
||||||
|
cp "$SRC/deploy/venv-audit.sh" "$HOME/.hermes/scripts/venv-audit.sh"
|
||||||
|
# mem0-Konsolidierung (monatlich am 3., 19.07.2026): legt aehnliche Fakten zusammen,
|
||||||
|
# loest Widersprueche (neuester gewinnt) — mem0 v2 ist ADD-only und tut das nie selbst.
|
||||||
|
cp "$SRC/deploy/mem0-konsolidierung.py" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
|
||||||
|
cp "$SRC/deploy/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh"
|
||||||
|
# Gitea-Repo-Anlage (17.07.2026): schließt Lucys „Projekte selbst starten"-Lücke —
|
||||||
|
# legt PRIVATE Projekt-Repos an (Token aus ~/.git-credentials, braucht write:user-Scope).
|
||||||
|
cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh"
|
||||||
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
|
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
|
||||||
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
|
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
|
||||||
[ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md"
|
[ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md"
|
||||||
@@ -122,7 +145,12 @@ cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
|
|||||||
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
|
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
|
||||||
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
|
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
|
||||||
[ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md"
|
[ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md"
|
||||||
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
|
# IDE-Vorbereiter (20.07.2026): haertet ein Konzept (konzept-fliessband), legt das Repo an und
|
||||||
|
# fuellt es NUR mit Doku — der Commander baut dann in Zed. Sonderfall „IDEE PRUEFEN": nur Konzept,
|
||||||
|
# kein Geruest, kein Bau-Druck. Profil einmalig per `hermes profile create projektstart
|
||||||
|
# --clone-from werkstatt` angelegt (model coder→hermes, damit heavy warm bleibt).
|
||||||
|
[ -d "$HOME/.hermes/profiles/projektstart" ] && cp "$SRC/deploy/projektstart-SOUL.md" "$HOME/.hermes/profiles/projektstart/SOUL.md"
|
||||||
|
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
|
||||||
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
|
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
|
||||||
mkdir -p "$HOME/.hermes/skills/wartung"
|
mkdir -p "$HOME/.hermes/skills/wartung"
|
||||||
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
|
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
|
||||||
@@ -146,6 +174,19 @@ cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-sta
|
|||||||
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
|
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
|
||||||
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
|
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
|
||||||
cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md"
|
cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md"
|
||||||
|
# ...auch in Profile spiegeln, die den Skill schon haben (projektstart = IDE-Vorbereiter
|
||||||
|
# nutzt ihn als Motor; Profile teilen KEINE Skills, Lehre 18.07.). Nur vorhandene updaten.
|
||||||
|
for _psd in "$HOME"/.hermes/profiles/*/skills/konzept-fliessband; do
|
||||||
|
[ -d "$_psd" ] && cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$_psd/SKILL.md"
|
||||||
|
done
|
||||||
|
# Betrieb-Playbook (Phase 2 Drei-Welten-Plan, 19.07.2026): Diagnose-Checklisten der
|
||||||
|
# Betrieb-Bahn (SSH/Deploy) — Gegenstueck zur No-Progress-Bremse. Auch in die Profile,
|
||||||
|
# damit betrieb-Worker es laden koennen (Profile teilen KEINE Skills, Lehre 18.07.).
|
||||||
|
for _sd in "$HOME/.hermes/skills" "$HOME"/.hermes/profiles/*/skills; do
|
||||||
|
[ -d "$(dirname "$_sd")" ] || continue
|
||||||
|
mkdir -p "$_sd/betrieb-playbook"
|
||||||
|
cp "$SRC/deploy/skills/betrieb-playbook/SKILL.md" "$_sd/betrieb-playbook/SKILL.md"
|
||||||
|
done
|
||||||
# Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt
|
# Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt
|
||||||
# Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker
|
# Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker
|
||||||
# (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das
|
# (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das
|
||||||
@@ -155,7 +196,7 @@ mkdir -p "$HOME/.hermes/agent-hooks"
|
|||||||
# Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die
|
# Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die
|
||||||
# mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose
|
# mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose
|
||||||
# "script modified since approval"-Warnung von `hermes hooks doctor` aus.
|
# "script modified since approval"-Warnung von `hermes hooks doctor` aus.
|
||||||
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py; do
|
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py no-progress-bremse.py; do
|
||||||
_src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h"
|
_src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h"
|
||||||
cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; }
|
cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; }
|
||||||
done
|
done
|
||||||
@@ -165,6 +206,18 @@ done
|
|||||||
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
|
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
|
||||||
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
|
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
|
||||||
done
|
done
|
||||||
|
# Hermes-Runtime-Patch-Traeger (20.07.2026): MC2-eigene Fixes im Hermes-Quellcode
|
||||||
|
# (needs_input-wartet-auf-Commander, Decompose-erst-bei-Startreife, Orphan-Guard,
|
||||||
|
# Projekt-Kontext-Vererbung, Etappen-Auto-Kette) idempotent (re-)eintragen — so
|
||||||
|
# ueberleben sie ein `hermes update`, das den Quellbaum sonst zuruecksetzt.
|
||||||
|
python3 "$SRC/deploy/hermes-patches/apply.py"; _patch_rc=$?
|
||||||
|
if [ "$_patch_rc" -eq 10 ]; then
|
||||||
|
# Patches haben den Quellcode geaendert → Gateway muss die neue Laufzeit laden.
|
||||||
|
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
|
||||||
|
echo "[deploy] Hermes-Runtime-Patches (neu) angewandt → hermes-gateway neu gestartet."
|
||||||
|
elif [ "$_patch_rc" -ne 0 ]; then
|
||||||
|
echo "[deploy] ‼ WARNUNG: hermes-patches/apply.py meldete Fehler — ein Patch passt nicht mehr (Update hat den Kontext geaendert?). Fixes pruefen!"
|
||||||
|
fi
|
||||||
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
|
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
|
||||||
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
|
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
|
||||||
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
|
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
|
||||||
@@ -200,6 +253,7 @@ systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
|
|||||||
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
|
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
|
||||||
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
|
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
|
||||||
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
|
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
|
||||||
|
systemctl --user enable --now mc2-morgen-digest.timer >/dev/null 2>&1 || true
|
||||||
loginctl enable-linger "$USER" >/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.
|
# 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
|
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
|
||||||
|
|||||||
@@ -32,7 +32,17 @@ Sessions, Skills, Gedächtnis, wiederkehrende Muster.
|
|||||||
das akkumulierte Projektwissen.
|
das akkumulierte Projektwissen.
|
||||||
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
|
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
|
||||||
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
|
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
|
||||||
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **Entwirf ihn gleich
|
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **ABER ZUERST prüfen:**
|
||||||
|
```
|
||||||
|
ls ~/wissens-vault/skill-kandidaten/*.md ~/wissens-vault/skill-kandidaten/beauftragt/*.md 2>/dev/null
|
||||||
|
ls ~/.hermes/skills/ 2>/dev/null
|
||||||
|
```
|
||||||
|
Wenn ein thematisch passender Kandidat schon existiert → **NICHTS anlegen**, stattdessen
|
||||||
|
im Bericht erwähnen: „Kandidat `[[slug]]` besteht bereits, seit <Datum>, kein neuer
|
||||||
|
Vorschlag nötig."
|
||||||
|
**WICHTIG:** Prüfe auch, ob es bereits einen fertig gebauten Skill gibt.
|
||||||
|
Wenn ja: kein Kandidat nötig, der Skill existiert bereits.
|
||||||
|
**Entwirf ihn gleich
|
||||||
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
|
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
|
||||||
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
|
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
|
||||||
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
|
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026).
|
"""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
|
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
|
dessen config.yaml — NICHT die Top-Level ~/.hermes/config.yaml. Die worker-relevanten Hooks
|
||||||
(box-steckbrief-inject = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen also in JEDER
|
muessen also in JEDER Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie
|
||||||
Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.).
|
(Bug-Fund 13.07.).
|
||||||
|
|
||||||
Idempotent + validiert + Backup. Setzt einen bestehenden `pre_verify`-Hook-Block als Anker
|
WICHTIG (Falle, 19.07.): Ein Event darf nur EINMAL als YAML-Key existieren. Existiert
|
||||||
voraus (haben alle von default/werkstatt geklonten Profile); fehlt er, wird sauber
|
`pre_tool_call:` bereits (tabu-pfade-guard), wird ein weiterer Eintrag IN den Block
|
||||||
uebersprungen (exit 0), damit deploy.sh nicht bricht. Arg: Pfad zur Profil-config.yaml.
|
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 sys
|
|
||||||
import os
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -22,10 +27,13 @@ except Exception:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
AH = os.path.expanduser("~/.hermes/agent-hooks")
|
AH = os.path.expanduser("~/.hermes/agent-hooks")
|
||||||
WANT = {
|
# Reihenfolge zaehlt: tabu-pfade-guard MUSS vor der Bremse stehen (Schutz vor Komfort).
|
||||||
"pre_llm_call": f"{AH}/box-steckbrief-inject.sh",
|
WANT = [
|
||||||
"pre_tool_call": f"{AH}/tabu-pfade-guard.py",
|
("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:
|
if len(sys.argv) < 2:
|
||||||
print(" usage: ensure-profile-hooks.py <config.yaml>")
|
print(" usage: ensure-profile-hooks.py <config.yaml>")
|
||||||
@@ -44,22 +52,41 @@ if txt.count(anchor) != 1:
|
|||||||
print(f" [{os.path.basename(os.path.dirname(p))}] kein eindeutiger pre_verify-Anker — SKIP")
|
print(f" [{os.path.basename(os.path.dirname(p))}] kein eindeutiger pre_verify-Anker — SKIP")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
add = ""
|
new = txt
|
||||||
for event, cmd in WANT.items():
|
changed = False
|
||||||
if cmd not in txt:
|
for event, cmd, timeout in WANT:
|
||||||
add += f" {event}:\n - command: {cmd}\n timeout: 10\n"
|
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 add:
|
if not changed:
|
||||||
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks schon registriert.")
|
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks schon registriert.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
new = txt.replace(anchor, anchor + add, 1)
|
|
||||||
try:
|
try:
|
||||||
d = yaml.safe_load(new)
|
d = yaml.safe_load(new)
|
||||||
h = d["hooks"]
|
h = d["hooks"]
|
||||||
for event, cmd in WANT.items():
|
for event, cmd, _t in WANT:
|
||||||
assert any(cmd in x.get("command", "") for x in h.get(event, []))
|
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"])
|
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:
|
except Exception as exc:
|
||||||
print(f" [{p}] Validierung fehlgeschlagen ({exc}) — config UNVERAENDERT.")
|
print(f" [{p}] Validierung fehlgeschlagen ({exc}) — config UNVERAENDERT.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Alte Crons löschen
|
||||||
|
hermes cron remove traum || true
|
||||||
|
hermes cron remove chef-gutachter || true
|
||||||
|
hermes cron remove release-radar || true
|
||||||
|
hermes cron remove idle-radar || true
|
||||||
|
hermes cron remove selbst-inventur || true
|
||||||
|
hermes cron remove karten-gutachter || true
|
||||||
|
hermes cron remove pruefstand || true
|
||||||
|
hermes cron remove trend-radar || true
|
||||||
|
|
||||||
|
# Neue Crons registrieren mit Wrapper und --deliver origin.
|
||||||
|
# Dadurch schweigt Hermes cron selbst, und der Wrapper nutzt notify.sh für den Morgen-Digest.
|
||||||
|
hermes cron create --name traum --deliver origin "15 3 * * *" "Traum dreaming-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name chef-gutachter --deliver origin "30 4 * * *" "Chef-Gutachter chef-gutachter-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name release-radar --deliver origin "15 5 * * 1" "Release-Radar hermes-release-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name idle-radar --deliver origin "45 3 * * *" "Idle-Radar idle-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name selbst-inventur --deliver origin "35 3 * * *" "Selbst-Inventur selbst-inventur.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name karten-gutachter --deliver origin "0 4 * * *" "Karten-Gutachter karten-gutachter.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name pruefstand --deliver origin "0 2 * * *" "Prüfstand pruefstand.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
hermes cron create --name trend-radar --deliver origin "15 5 * * 6" "Trend-Radar trend-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||||
|
|
||||||
|
echo "Crons erfolgreich gefixt!"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Gitea-Repo-Anlage für Lucy (17.07.2026): schließt die Lücke im „Projekte selbst
|
||||||
|
# starten"-Weg — bislang konnte die Werkstatt nur Branches in BESTEHENDE Repos
|
||||||
|
# proposen, aber kein neues Projekt-Repo anlegen.
|
||||||
|
#
|
||||||
|
# Nutzung: gitea-repo-create.sh <name> ["Beschreibung"]
|
||||||
|
# Ergebnis (stdout, letzte Zeile): CLONE <https-clone-url> → Aufrufer kann direkt klonen.
|
||||||
|
#
|
||||||
|
# HARTE Sicherheits-Leitplanken (Gitea ist öffentlich erreichbar!):
|
||||||
|
# • Repos werden IMMER PRIVAT angelegt (kein öffentliches Repo aus Versehen).
|
||||||
|
# • Token wird aus ~/.git-credentials gelesen, NIE ausgegeben/geloggt.
|
||||||
|
# • Jede Anlage wird protokolliert (Audit-Spur) + still an die Chronik gespiegelt.
|
||||||
|
# Braucht ein Token mit Scope `write:user` (zusätzlich zu `write:repository`); ohne das
|
||||||
|
# meldet Gitea 403 — das Skript sagt dann genau, was zu tun ist (Token in Gitea neu
|
||||||
|
# generieren), statt kryptisch zu scheitern.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NAME="${1:-}"
|
||||||
|
BESCHREIBUNG="${2:-Von Lucy angelegtes Projekt-Repo.}"
|
||||||
|
# Worker-Subprozesse laufen evtl. mit abweichendem $HOME (Profil-Home) — das dedizierte
|
||||||
|
# Anlage-Token (write:user) UND .git-credentials liegen aber nur im ECHTEN User-Home.
|
||||||
|
# Darum den echten Home aus /etc/passwd aufloesen, sonst faellt das Skript aufs falsche
|
||||||
|
# Token zurueck (Fan-out-Nachlese 20.07.: projektstart-Worker blockte an genau dieser
|
||||||
|
# Stelle mit "Token hat nur write:repository").
|
||||||
|
REALHOME="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f6)"; REALHOME="${REALHOME:-$HOME}"
|
||||||
|
CRED="$REALHOME/.git-credentials"
|
||||||
|
# Dediziertes Anlage-Token (Scope write:user) — GETRENNT vom Push-Token in
|
||||||
|
# ~/.git-credentials, damit ein Überschreiben nie Push/PR bricht. Priorität:
|
||||||
|
# ENV GITEA_CREATE_TOKEN → <echter-Home>/.config/gitea/create-token → Fallback .git-credentials.
|
||||||
|
CREATE_TOKEN_FILE="$REALHOME/.config/gitea/create-token"
|
||||||
|
LOG="$REALHOME/.hermes/state/gitea-repos-created.log"
|
||||||
|
|
||||||
|
if [ -z "$NAME" ]; then
|
||||||
|
echo "Nutzung: gitea-repo-create.sh <name> [\"Beschreibung\"]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# Repo-Name defensiv prüfen (Gitea erlaubt Buchstaben/Ziffern/-/_/.)
|
||||||
|
if ! printf '%s' "$NAME" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$'; then
|
||||||
|
echo "Ungültiger Repo-Name '$NAME' (erlaubt: Buchstaben/Ziffern/.-_, Beginn alphanumerisch)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# User + Host sind NICHT geheim: aus .git-credentials ableiten, sonst Standardwerte.
|
||||||
|
LINE="$(grep -m1 'git\.tobisniceshomelab\.ddnsfree\.com' "$CRED" 2>/dev/null || head -1 "$CRED" 2>/dev/null || true)"
|
||||||
|
USER="$(printf '%s' "$LINE" | sed -nE 's#https://([^:]+):.*#\1#p')"; USER="${USER:-Hitonabi}"
|
||||||
|
HOST="$(printf '%s' "$LINE" | sed -nE 's#https://[^@]+@([^/]+).*#\1#p')"; HOST="${HOST:-git.tobisniceshomelab.ddnsfree.com}"
|
||||||
|
BASE="https://${HOST}"
|
||||||
|
|
||||||
|
# Anlage-Token bestimmen (dediziert bevorzugt, damit Push-Token unberührt bleibt)
|
||||||
|
if [ -n "${GITEA_CREATE_TOKEN:-}" ]; then
|
||||||
|
TOKEN="$GITEA_CREATE_TOKEN"
|
||||||
|
elif [ -r "$CREATE_TOKEN_FILE" ]; then
|
||||||
|
TOKEN="$(tr -d '[:space:]' < "$CREATE_TOKEN_FILE")"
|
||||||
|
else
|
||||||
|
TOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"
|
||||||
|
fi
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Kein Gitea-Token gefunden (weder \$GITEA_CREATE_TOKEN noch $CREATE_TOKEN_FILE noch ~/.git-credentials)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESP="$(mktemp)"; trap 'rm -f "$RESP"' EXIT
|
||||||
|
CODE="$(curl -s -o "$RESP" -w '%{http_code}' -X POST "$BASE/api/v1/user/repos" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"description":sys.argv[2],"private":True,"auto_init":True,"default_branch":"main"}))' "$NAME" "$BESCHREIBUNG")")"
|
||||||
|
|
||||||
|
case "$CODE" in
|
||||||
|
201)
|
||||||
|
CLONE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("clone_url",""))' "$RESP" 2>/dev/null)"
|
||||||
|
FULL="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("full_name",""))' "$RESP" 2>/dev/null)"
|
||||||
|
mkdir -p "$(dirname "$LOG")"
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M') angelegt (privat): $FULL" >> "$LOG"
|
||||||
|
# Stiller Chronik-Spiegel (Audit-Spur, wie die Radar-Meldungen)
|
||||||
|
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$(python3 -c 'import json,sys; print(json.dumps({"text":"Neues PRIVATES Projekt-Repo angelegt: "+sys.argv[1],"subject":"Gitea","source":"gitea-repo-create","priority":"silent"}))' "$FULL")" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
# CI-Ampel einpflanzen (Wasserdicht-Runde 22.07.): JEDES neue Repo wird mit der
|
||||||
|
# universellen Actions-Ampel geboren (deploy/ampel-ci.yml). Defensiv: fehlt die
|
||||||
|
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
|
||||||
|
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
|
||||||
|
# dedizierte Anlage-Token hat u.U. nur write:user).
|
||||||
|
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
|
||||||
|
# saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
|
||||||
|
saat () {
|
||||||
|
local QUELLE="$1" ZIEL="$2" MSG="$3" NAME="$4" CODE
|
||||||
|
if [ ! -r "$QUELLE" ]; then
|
||||||
|
echo "WARNUNG: Vorlage fehlt ($QUELLE) — Repo ohne $NAME angelegt." >&2
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/$ZIEL" \
|
||||||
|
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
|
||||||
|
--data "$(python3 - "$QUELLE" "$MSG" <<'PY'
|
||||||
|
import base64, json, sys
|
||||||
|
inhalt = open(sys.argv[1], "rb").read()
|
||||||
|
print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
|
||||||
|
PY
|
||||||
|
)")"
|
||||||
|
if [ "$CODE" = "201" ]; then
|
||||||
|
echo "$NAME eingepflanzt ($ZIEL)."
|
||||||
|
else
|
||||||
|
echo "WARNUNG: $NAME-Seed antwortete HTTP $CODE (Repo ist trotzdem da)." >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# JEDES neue Repo wird mit beiden Wächtern geboren:
|
||||||
|
# ci.yml = die AUSSEN-Prüfung (Gitea Actions nach dem Push, Wasserdicht-Runde 22.07.)
|
||||||
|
# VERIFY = die INNEN-Prüfung (das OpenCode-Plugin führt sie nach jeder Etappe aus und
|
||||||
|
# gibt rote Tests dem Agenten sofort zurück, statt sie erst der CI zu zeigen)
|
||||||
|
# Defensiv: scheitert ein Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
|
||||||
|
saat "$REALHOME/mission-control-v2/deploy/ampel-ci.yml" ".gitea/workflows/ci.yml" \
|
||||||
|
"CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)" "CI-Ampel"
|
||||||
|
saat "$REALHOME/mission-control-v2/deploy/opencode/VERIFY.template" "VERIFY" \
|
||||||
|
"Pruef-Tor (automatisch bei Repo-Anlage eingepflanzt)" "Pruef-Tor"
|
||||||
|
echo "Repo '$FULL' angelegt (privat, main initialisiert)."
|
||||||
|
echo "CLONE ${CLONE}"
|
||||||
|
exit 0 ;;
|
||||||
|
403)
|
||||||
|
if grep -q 'write:user' "$RESP"; then
|
||||||
|
echo "FEHLT: Das benutzte Gitea-Token darf keine Repos anlegen (Scope 'write:user' fehlt)." >&2
|
||||||
|
echo "So wird es freigeschaltet (einmalig, durch den Commander):" >&2
|
||||||
|
echo " 1) In Gitea ein Token mit Scope 'write:user' erzeugen (nur DAS reicht fürs Anlegen)." >&2
|
||||||
|
echo " 2) Den Token-Wert in ~/.config/gitea/create-token legen (chmod 600) —" >&2
|
||||||
|
echo " getrennt vom Push-Token, das in ~/.git-credentials unberührt bleibt." >&2
|
||||||
|
else
|
||||||
|
echo "403 von Gitea:" >&2; head -c 300 "$RESP" >&2; echo >&2
|
||||||
|
fi
|
||||||
|
exit 3 ;;
|
||||||
|
409)
|
||||||
|
echo "Ein Repo namens '$NAME' existiert bereits." >&2
|
||||||
|
exit 4 ;;
|
||||||
|
*)
|
||||||
|
echo "Gitea antwortete mit HTTP $CODE:" >&2; head -c 300 "$RESP" >&2; echo >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Gitea app.ini - Beispielkonfiguration mit DISABLE_REGISTRATION
|
||||||
|
# Speichere diese Datei auf dem Server unter /etc/gitea/app.ini
|
||||||
|
# und setze DISABLE_REGISTRATION=true im Abschnitt [security]
|
||||||
|
|
||||||
|
APP_NAME = TobisNicesGitTea
|
||||||
|
RUN_USER = git
|
||||||
|
RUN_MODE = prod
|
||||||
|
|
||||||
|
[server]
|
||||||
|
DOMAIN = git.tobisniceshomelab.ddnsfree.com
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
APP_DATA_PATH = /var/lib/gitea
|
||||||
|
PROTOCOL = http
|
||||||
|
ROOT_URL = https://git.tobisniceshomelab.ddnsfree.com/
|
||||||
|
DISABLE_SSH = false
|
||||||
|
SSH_PORT = 22
|
||||||
|
LFS_START_SERVER = true
|
||||||
|
LFS_CONTENT_PATH = /var/lib/gitea/data/lfs
|
||||||
|
OFFLINE_MODE = false
|
||||||
|
|
||||||
|
[database]
|
||||||
|
DB_TYPE = sqlite3
|
||||||
|
PATH = /var/lib/gitea/gitea.db
|
||||||
|
MAX_OPEN_CONNS = 10
|
||||||
|
MAX_IDLE_CONNS = 5
|
||||||
|
MAX_CONN_LIFETIME = 30m
|
||||||
|
DSN = /var/lib/gitea/gitea.db
|
||||||
|
|
||||||
|
[security]
|
||||||
|
INSTALL_LOCK = true
|
||||||
|
SECRET_KEY = CHANGE_THIS_TO_A_RANDOM_SECRET_KEY
|
||||||
|
PASSWORD_HASH_ALGO = pbkdf2
|
||||||
|
DISABLE_REGISTRATION = true
|
||||||
|
REQUIRE_SIGNIN_VIEW = false
|
||||||
|
|
||||||
|
[service]
|
||||||
|
ENABLE_CAPTCHA = false
|
||||||
|
ENABLE_REGISTER_EMAIL_CONFIRM = false
|
||||||
|
ENABLE_NOTIFY_MAIL = false
|
||||||
|
ALLOW_ONLY_EXTERNAL_REGISTER_API = false
|
||||||
|
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||||
|
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||||
|
DEFAULT_ENABLE_TIMETRACKING = true
|
||||||
|
NO_REPLY_ADDRESS = noreply.example.org
|
||||||
|
|
||||||
|
[openid]
|
||||||
|
ENABLE_OPENID_SIGNIN = false
|
||||||
|
ENABLE_OPENID_SIGNUP = false
|
||||||
|
|
||||||
|
[session]
|
||||||
|
PROVIDER = file
|
||||||
|
COOKIE_NAME = gitea sessionId
|
||||||
|
COOKIE_SECURE = false
|
||||||
|
|
||||||
|
[mailer]
|
||||||
|
ENABLED = false
|
||||||
|
HOST =
|
||||||
|
FROM = gitea@example.com
|
||||||
|
|
||||||
|
[openid]
|
||||||
|
ENABLE_OPENID_SIGNIN = false
|
||||||
|
ENABLE_OPENID_SIGNUP = false
|
||||||
|
|
||||||
|
[security]
|
||||||
|
DISABLE_REGISTRATION = true
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
|
||||||
|
|
||||||
|
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
|
||||||
|
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
|
||||||
|
|
||||||
|
## 1. Zu Beginn: erst SAVEPOINT.md lesen
|
||||||
|
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
|
||||||
|
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
|
||||||
|
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
|
||||||
|
ist, sag es, statt es zu erfinden.
|
||||||
|
|
||||||
|
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
|
||||||
|
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
|
||||||
|
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
|
||||||
|
|
||||||
|
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
|
||||||
|
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
|
||||||
|
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
|
||||||
|
- **Offene Fragen** — Entscheidungen, die noch anstehen.
|
||||||
|
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
|
||||||
|
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
|
||||||
|
|
||||||
|
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
|
||||||
|
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
|
||||||
|
|
||||||
|
## 3. Kleine, überprüfbare Schritte
|
||||||
|
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
|
||||||
|
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
|
||||||
|
|
||||||
|
## 4. Wenn der Governor das Sitzungs-Limit meldet
|
||||||
|
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
|
||||||
|
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
|
||||||
|
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Governor — Phase 0
|
||||||
|
|
||||||
|
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
|
||||||
|
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
|
||||||
|
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
|
||||||
|
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
|
||||||
|
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
|
||||||
|
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
|
||||||
|
|
||||||
|
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
|
||||||
|
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
|
||||||
|
|
||||||
|
## Bausteine (dieses Verzeichnis)
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
|
||||||
|
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
|
||||||
|
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
|
||||||
|
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
|
||||||
|
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
|
||||||
|
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
|
||||||
|
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
|
||||||
|
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
|
||||||
|
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
|
||||||
|
|
||||||
|
## Verhalten des Governors
|
||||||
|
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
|
||||||
|
kalibriert auf CPT **3.5** → `est ≈ echte prompt_tokens` auf ~1–3 % bei echten Sessions):
|
||||||
|
|
||||||
|
- **est < Soft** → unverändert durchreichen.
|
||||||
|
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
|
||||||
|
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
|
||||||
|
ehrlichen Abschluss-Savepoint.
|
||||||
|
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
|
||||||
|
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
|
||||||
|
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
|
||||||
|
|
||||||
|
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
|
||||||
|
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
|
||||||
|
mitgeloggt.
|
||||||
|
|
||||||
|
### Sprach-Signal an Lucy (Phase 2)
|
||||||
|
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
|
||||||
|
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
|
||||||
|
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
|
||||||
|
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
|
||||||
|
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
|
||||||
|
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
|
||||||
|
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
|
||||||
|
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
|
||||||
|
|
||||||
|
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
|
||||||
|
```bash
|
||||||
|
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
|
||||||
|
pipx install uv && uv tool install --python 3.12 aider-chat
|
||||||
|
|
||||||
|
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
|
||||||
|
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
|
||||||
|
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
|
||||||
|
|
||||||
|
# Aider-Sitzung durch den Governor:
|
||||||
|
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
|
||||||
|
```
|
||||||
|
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
|
||||||
|
`openai/Qwen3-Coder-Next` auf den Governor.
|
||||||
|
|
||||||
|
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
|
||||||
|
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
|
||||||
|
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
|
||||||
|
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
|
||||||
|
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
|
||||||
|
|
||||||
|
## Ergebnisse & Befunde (24.07.2026)
|
||||||
|
|
||||||
|
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
|
||||||
|
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
|
||||||
|
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
|
||||||
|
→ `HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
|
||||||
|
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
|
||||||
|
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
|
||||||
|
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
|
||||||
|
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
|
||||||
|
Schritt) und verweigerte neuen Code.
|
||||||
|
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
|
||||||
|
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
|
||||||
|
Tests laufen grün** gegen die echte API. Kein Erfinden.
|
||||||
|
|
||||||
|
### Kalibrierung
|
||||||
|
`CHARS_PER_TOKEN = 3.5` → `est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
|
||||||
|
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
|
||||||
|
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
|
||||||
|
|
||||||
|
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
|
||||||
|
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
|
||||||
|
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
|
||||||
|
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
|
||||||
|
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
|
||||||
|
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
|
||||||
|
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
|
||||||
|
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
|
||||||
|
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
|
||||||
|
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
|
||||||
|
|
||||||
|
### Weitere Befunde / Fallen
|
||||||
|
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
|
||||||
|
Aider selbst und der Governor greift nie. Siehe oben.
|
||||||
|
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
|
||||||
|
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
|
||||||
|
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
|
||||||
|
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
|
||||||
|
Python 3.12 installiert.
|
||||||
|
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
|
||||||
|
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
|
||||||
|
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
|
||||||
|
|
||||||
|
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
|
||||||
|
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
|
||||||
|
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
|
||||||
|
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
|
||||||
|
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
|
||||||
|
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
|
||||||
|
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
|
||||||
|
lokalen `:8080`-Endpoint irrelevant.
|
||||||
|
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
|
||||||
|
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
|
||||||
|
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
|
||||||
|
|
||||||
|
## Nächste Schritte (Phase 1+)
|
||||||
|
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
|
||||||
|
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
|
||||||
|
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# SAVEPOINT
|
||||||
|
|
||||||
|
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
|
||||||
|
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
|
||||||
|
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
_(noch nichts — von der ersten Sitzung zu füllen)_
|
||||||
|
|
||||||
|
## Erledigt
|
||||||
|
- _(noch nichts)_
|
||||||
|
|
||||||
|
## Nächster Schritt
|
||||||
|
- Auftrag des Nutzers entgegennehmen und beginnen.
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
- _(keine)_
|
||||||
|
|
||||||
|
## Stolpersteine
|
||||||
|
- _(keine bekannt)_
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
- `SAVEPOINT.md` — diese Übergabe-Datei.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
|
||||||
|
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
|
||||||
|
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
|
||||||
|
set -u
|
||||||
|
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||||
|
|
||||||
|
python3 - <<'PY' > /tmp/gov_fire.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
|
||||||
|
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
|
||||||
|
|
||||||
|
sleep 1.5 # Announce-Thread durchlassen
|
||||||
|
echo "=== Governor-Log (letzte 4 Zeilen) ==="
|
||||||
|
tail -4 ~/governor-p0/governor.log
|
||||||
|
|
||||||
|
echo "=== Announce-Queue: Governor-Eintraege ==="
|
||||||
|
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
|
||||||
|
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
|
||||||
|
|
||||||
|
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
|
||||||
|
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was
|
||||||
|
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
|
||||||
|
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
|
||||||
|
|
||||||
|
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
|
||||||
|
.../aider-chat/bin/python driver.py <messages-file>
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aider.coders import Coder
|
||||||
|
from aider.io import InputOutput
|
||||||
|
from aider.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit).
|
||||||
|
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
|
||||||
|
os.environ.setdefault("OPENAI_API_KEY", "dummy")
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("usage: driver.py <messages-file>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||||
|
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
|
||||||
|
|
||||||
|
model = Model("openai/Qwen3-Coder-Next")
|
||||||
|
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
|
||||||
|
coder = Coder.create(
|
||||||
|
main_model=model,
|
||||||
|
io=io,
|
||||||
|
fnames=["SAVEPOINT.md"], # editierbar
|
||||||
|
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
|
||||||
|
auto_commits=True, # jede Etappe -> git-Commit
|
||||||
|
stream=False, # deterministische Logs fuer P0
|
||||||
|
map_tokens=512,
|
||||||
|
use_git=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
|
||||||
|
try:
|
||||||
|
coder.summarizer.max_tokens = 10 ** 9
|
||||||
|
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
|
||||||
|
|
||||||
|
for i, msg in enumerate(messages, 1):
|
||||||
|
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
|
||||||
|
print(f">> {msg[:140]}", flush=True)
|
||||||
|
try:
|
||||||
|
coder.run(with_message=msg)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
|
||||||
|
break
|
||||||
|
sent = getattr(coder, "total_tokens_sent", "?")
|
||||||
|
recv = getattr(coder, "total_tokens_received", "?")
|
||||||
|
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
|
||||||
|
|
||||||
|
print("\n===== SESSION ENDE =====", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
|
||||||
|
# Nutzung:
|
||||||
|
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
|
||||||
|
# gov-ctl.sh stop
|
||||||
|
# gov-ctl.sh status
|
||||||
|
set -u
|
||||||
|
DIR="$HOME/governor-p0"
|
||||||
|
PIDF="$DIR/governor.pid"
|
||||||
|
LOG="$DIR/governor.log"
|
||||||
|
OUT="$DIR/governor.stdout"
|
||||||
|
|
||||||
|
start() {
|
||||||
|
stop
|
||||||
|
local thr="${1:-25000}"
|
||||||
|
cd "$DIR"
|
||||||
|
: > "$LOG"
|
||||||
|
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
|
||||||
|
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
|
||||||
|
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
|
||||||
|
local hardenv=()
|
||||||
|
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
|
||||||
|
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
|
||||||
|
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
|
||||||
|
GOV_LOG="$LOG" GOV_PORT=8100 \
|
||||||
|
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
|
||||||
|
echo $! > "$PIDF"
|
||||||
|
sleep 1.2
|
||||||
|
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
|
||||||
|
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
pkill -f "$DIR/governor.py" 2>/dev/null || true
|
||||||
|
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
|
||||||
|
sleep 0.6
|
||||||
|
rm -f "$PIDF"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
if pgrep -f "$DIR/governor.py" >/dev/null; then
|
||||||
|
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
|
||||||
|
ss -tlnp 2>/dev/null | grep ":8100" || true
|
||||||
|
else
|
||||||
|
echo "not running"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
start) shift; start "${1:-25000}" "${2:-}" ;;
|
||||||
|
stop) stop; echo stopped ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
|
||||||
|
|
||||||
|
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
|
||||||
|
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
|
||||||
|
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
|
||||||
|
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
|
||||||
|
|
||||||
|
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
|
||||||
|
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
|
||||||
|
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
|
||||||
|
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
|
||||||
|
|
||||||
|
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
|
||||||
|
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
|
||||||
|
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
|
||||||
|
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
|
||||||
|
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
|
||||||
|
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
|
||||||
|
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
|
||||||
|
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
|
||||||
|
einem einmal geratenen Wert festzuhaengen.
|
||||||
|
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
|
||||||
|
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
|
||||||
|
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
|
||||||
|
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
|
||||||
|
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
|
||||||
|
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
|
||||||
|
und Lucys `loop_status`.
|
||||||
|
|
||||||
|
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
|
||||||
|
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
|
||||||
|
|
||||||
|
Konfiguration per Umgebungsvariablen (alle optional):
|
||||||
|
GOV_PORT Listen-Port (Default 8100)
|
||||||
|
GOV_HOST Listen-Adresse (Default 0.0.0.0)
|
||||||
|
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
|
||||||
|
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
|
||||||
|
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
|
||||||
|
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
|
||||||
|
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
|
||||||
|
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
|
||||||
|
GOV_DIRECTIVE Text des Soft-Einschubs
|
||||||
|
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
|
||||||
|
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
|
||||||
|
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
|
||||||
|
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
|
||||||
|
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
|
||||||
|
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# ---- Konfiguration ---------------------------------------------------------
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("GOV_PORT", "8100"))
|
||||||
|
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
|
||||||
|
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
|
||||||
|
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
|
||||||
|
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
|
||||||
|
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
|
||||||
|
_hard_env = os.environ.get("GOV_HARD_CEILING")
|
||||||
|
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
|
||||||
|
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
|
||||||
|
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
|
||||||
|
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
|
||||||
|
|
||||||
|
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
|
||||||
|
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
|
||||||
|
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
|
||||||
|
EXEMPT_MODELS = {m.strip().lower() for m in
|
||||||
|
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
|
||||||
|
|
||||||
|
DEFAULT_DIRECTIVE = (
|
||||||
|
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
|
||||||
|
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
|
||||||
|
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
|
||||||
|
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
|
||||||
|
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
|
||||||
|
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
|
||||||
|
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
|
||||||
|
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
|
||||||
|
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
|
||||||
|
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
|
||||||
|
)
|
||||||
|
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
|
||||||
|
|
||||||
|
DEFAULT_HARDSTOP = (
|
||||||
|
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
|
||||||
|
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
|
||||||
|
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
|
||||||
|
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
|
||||||
|
)
|
||||||
|
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
|
||||||
|
|
||||||
|
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
|
||||||
|
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
|
||||||
|
DEFAULT_ANNOUNCE = (
|
||||||
|
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
|
||||||
|
"Stand im Savepoint; am besten fangen wir gleich frisch an."
|
||||||
|
)
|
||||||
|
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
|
||||||
|
|
||||||
|
up = urlparse(UPSTREAM)
|
||||||
|
UP_HOST = up.hostname or "127.0.0.1"
|
||||||
|
UP_PORT = up.port or 80
|
||||||
|
|
||||||
|
HOP_BY_HOP = {
|
||||||
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||||
|
"te", "trailers", "transfer-encoding", "upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
_log_lock = threading.Lock()
|
||||||
|
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
|
||||||
|
_announce_lock = threading.Lock()
|
||||||
|
_last_announce = 0.0
|
||||||
|
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
|
||||||
|
|
||||||
|
# ---- Zustand (klein, im Speicher) ------------------------------------------
|
||||||
|
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
|
||||||
|
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
|
||||||
|
_state_lock = threading.Lock()
|
||||||
|
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
|
||||||
|
_cpt_n: dict = {} # modell -> Anzahl Messungen
|
||||||
|
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
|
||||||
|
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
|
||||||
|
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
|
||||||
|
|
||||||
|
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
|
||||||
|
|
||||||
|
|
||||||
|
def log(line: str) -> None:
|
||||||
|
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
|
||||||
|
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
msg = f"{stamp} {line}"
|
||||||
|
with _log_lock:
|
||||||
|
print(msg, flush=True)
|
||||||
|
try:
|
||||||
|
with open(LOG_PATH, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(msg + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cpt_for(model: str) -> float:
|
||||||
|
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
|
||||||
|
with _state_lock:
|
||||||
|
return _cpt.get(model, CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate(model: str, chars: int, exact: int) -> None:
|
||||||
|
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
|
||||||
|
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
|
||||||
|
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
|
||||||
|
return
|
||||||
|
ratio = chars / exact
|
||||||
|
if not (CPT_MIN <= ratio <= CPT_MAX):
|
||||||
|
return
|
||||||
|
with _state_lock:
|
||||||
|
n = _cpt_n.get(model, 0)
|
||||||
|
old = _cpt.get(model, CHARS_PER_TOKEN)
|
||||||
|
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
|
||||||
|
w = max(0.08, 1.0 / (n + 2))
|
||||||
|
_cpt[model] = old * (1 - w) + ratio * w
|
||||||
|
_cpt_n[model] = n + 1
|
||||||
|
|
||||||
|
|
||||||
|
def body_chars(data: dict) -> int:
|
||||||
|
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
|
||||||
|
|
||||||
|
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
|
||||||
|
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
|
||||||
|
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
|
||||||
|
Wir serialisieren einfach alles, was ans Modell geht.
|
||||||
|
"""
|
||||||
|
payload = {k: v for k, v in data.items()
|
||||||
|
if k in ("messages", "tools", "tool_choice", "system", "functions")}
|
||||||
|
try:
|
||||||
|
return len(json.dumps(payload, ensure_ascii=False))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
|
||||||
|
chars = 0
|
||||||
|
for m in data.get("messages") or []:
|
||||||
|
c = m.get("content") if isinstance(m, dict) else None
|
||||||
|
if isinstance(c, str):
|
||||||
|
chars += len(c) + 4
|
||||||
|
elif isinstance(c, list):
|
||||||
|
for p in c:
|
||||||
|
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
||||||
|
chars += len(p["text"])
|
||||||
|
return chars
|
||||||
|
|
||||||
|
|
||||||
|
def safe_to_append(messages) -> bool:
|
||||||
|
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
|
||||||
|
|
||||||
|
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
|
||||||
|
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
|
||||||
|
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
|
||||||
|
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
|
||||||
|
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
|
||||||
|
"""
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return False
|
||||||
|
last = messages[-1]
|
||||||
|
if not isinstance(last, dict):
|
||||||
|
return False
|
||||||
|
role = last.get("role")
|
||||||
|
if role == "tool":
|
||||||
|
return False
|
||||||
|
return not (role == "assistant" and last.get("tool_calls"))
|
||||||
|
|
||||||
|
|
||||||
|
def _post_announce(est) -> None:
|
||||||
|
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
|
||||||
|
try:
|
||||||
|
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
||||||
|
.replace("{threshold}", str(THRESHOLD)))
|
||||||
|
body = json.dumps({"text": text, "subject": "[Governor]",
|
||||||
|
"source": "governor", "priority": "normal"}).encode("utf-8")
|
||||||
|
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
|
||||||
|
_an.port or 80, timeout=4)
|
||||||
|
conn.request("POST", _an.path or "/api/voice/announce", body=body,
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Content-Length": str(len(body))})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
resp.read()
|
||||||
|
conn.close()
|
||||||
|
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_announce(est) -> None:
|
||||||
|
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
|
||||||
|
if not _an:
|
||||||
|
return
|
||||||
|
global _last_announce
|
||||||
|
now = time.time()
|
||||||
|
with _announce_lock:
|
||||||
|
if now - _last_announce < ANNOUNCE_THROTTLE:
|
||||||
|
return
|
||||||
|
_last_announce = now
|
||||||
|
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def status_payload() -> dict:
|
||||||
|
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
|
||||||
|
with _state_lock:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"upstream": UPSTREAM,
|
||||||
|
"soft": THRESHOLD,
|
||||||
|
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
|
||||||
|
"uptime_s": int(time.time() - _counters["started"]),
|
||||||
|
"counters": {k: v for k, v in _counters.items() if k != "started"},
|
||||||
|
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
|
||||||
|
for m, v in _cpt.items()},
|
||||||
|
"calibration_default": CHARS_PER_TOKEN,
|
||||||
|
"exempt_models": sorted(EXEMPT_MODELS),
|
||||||
|
"recent": list(_recent),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
server_version = "Governor/2.0"
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
|
||||||
|
self._send_json(200, status_payload())
|
||||||
|
return
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
# -- Kern ---------------------------------------------------------------
|
||||||
|
def _send_json(self, status: int, obj) -> None:
|
||||||
|
try:
|
||||||
|
data = json.dumps(obj).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _read_body(self) -> bytes:
|
||||||
|
length = self.headers.get("Content-Length")
|
||||||
|
if length is None:
|
||||||
|
return b""
|
||||||
|
try:
|
||||||
|
return self.rfile.read(int(length))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def _proxy(self) -> None:
|
||||||
|
body = self._read_body()
|
||||||
|
path = self.path
|
||||||
|
clean_path = path.split("?", 1)[0]
|
||||||
|
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
|
||||||
|
|
||||||
|
action = "passthrough"
|
||||||
|
est = None
|
||||||
|
streaming = False
|
||||||
|
model = ""
|
||||||
|
chars = 0
|
||||||
|
if is_chat and body:
|
||||||
|
action, body, est, streaming, model, chars = self._decide(body)
|
||||||
|
if action in ("soft", "hard"):
|
||||||
|
maybe_announce(est)
|
||||||
|
|
||||||
|
if action == "hard":
|
||||||
|
self._send_canned_stop(model, streaming, est)
|
||||||
|
with _state_lock:
|
||||||
|
_counters["chat"] += 1
|
||||||
|
_counters["hard"] += 1
|
||||||
|
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||||
|
"exact": None, "action": "hard"})
|
||||||
|
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
|
||||||
|
f"HARDSTOP stream={streaming} status=200")
|
||||||
|
return
|
||||||
|
|
||||||
|
out_headers = {}
|
||||||
|
for k, v in self.headers.items():
|
||||||
|
kl = k.lower()
|
||||||
|
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
|
||||||
|
continue
|
||||||
|
out_headers[k] = v
|
||||||
|
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
|
||||||
|
out_headers["Accept-Encoding"] = "identity"
|
||||||
|
if body:
|
||||||
|
out_headers["Content-Length"] = str(len(body))
|
||||||
|
out_headers["Connection"] = "close"
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
|
||||||
|
conn.request(self.command, path, body=body or None, headers=out_headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
log(f"ERROR upstream {self.command} {path}: {exc!r}")
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
self._safe_error(502, f"governor upstream: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(resp.status)
|
||||||
|
for k, v in resp.getheaders():
|
||||||
|
kl = k.lower()
|
||||||
|
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
|
||||||
|
continue
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
tail = bytearray()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
|
||||||
|
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
|
||||||
|
chunk = resp.read1(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
tail.extend(chunk)
|
||||||
|
if len(tail) > 16384:
|
||||||
|
del tail[:-16384]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
exact = self._scan_prompt_tokens(tail)
|
||||||
|
if is_chat:
|
||||||
|
if exact:
|
||||||
|
calibrate(model, chars, exact)
|
||||||
|
with _state_lock:
|
||||||
|
_counters["chat"] += 1
|
||||||
|
_counters["soft" if action == "soft" else "passthrough"] += 1
|
||||||
|
if exact:
|
||||||
|
_counters["tokens_prompt"] += exact
|
||||||
|
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||||
|
"exact": exact, "action": action})
|
||||||
|
exact_s = str(exact) if exact is not None else "-"
|
||||||
|
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
|
||||||
|
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
|
||||||
|
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
|
||||||
|
|
||||||
|
def _decide(self, body: bytes):
|
||||||
|
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return "passthrough", body, None, False, "", 0
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "passthrough", body, None, False, "", 0
|
||||||
|
|
||||||
|
messages = data.get("messages")
|
||||||
|
streaming = bool(data.get("stream"))
|
||||||
|
model = (data.get("model") or "").strip()
|
||||||
|
chars = body_chars(data)
|
||||||
|
est = int(chars / max(cpt_for(model), 0.1))
|
||||||
|
|
||||||
|
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
|
||||||
|
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
|
||||||
|
base = model.split("/")[-1].lower()
|
||||||
|
if base in EXEMPT_MODELS:
|
||||||
|
return "passthrough", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
return "passthrough", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
has_warned = False
|
||||||
|
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
|
||||||
|
for m in reversed(messages):
|
||||||
|
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
|
||||||
|
has_warned = True
|
||||||
|
break
|
||||||
|
|
||||||
|
is_safe = safe_to_append(messages)
|
||||||
|
|
||||||
|
# 1. Absolutes Not-Aus bei komplettem Amoklauf
|
||||||
|
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
|
||||||
|
return "hard", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
# 2. Sind wir am Limit?
|
||||||
|
if est >= THRESHOLD:
|
||||||
|
if not is_safe:
|
||||||
|
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
|
||||||
|
return "defer", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
|
||||||
|
if HARD_CEILING > 0 and est >= HARD_CEILING:
|
||||||
|
if has_warned:
|
||||||
|
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
|
||||||
|
return "hard", body, est, streaming, model, chars
|
||||||
|
else:
|
||||||
|
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
|
||||||
|
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Soft-Limit greift
|
||||||
|
if not has_warned:
|
||||||
|
directive = (DIRECTIVE.replace("{est}", str(est))
|
||||||
|
.replace("{threshold}", str(THRESHOLD)))
|
||||||
|
messages.append({"role": "user", "content": directive})
|
||||||
|
data["messages"] = messages
|
||||||
|
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
|
||||||
|
|
||||||
|
return "passthrough", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
|
||||||
|
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
|
||||||
|
created = int(time.time())
|
||||||
|
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
|
||||||
|
"total_tokens": est or 0}
|
||||||
|
try:
|
||||||
|
if streaming:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def sse(obj):
|
||||||
|
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
|
||||||
|
"created": created, "model": model}
|
||||||
|
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
|
||||||
|
"finish_reason": None}]})
|
||||||
|
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
|
||||||
|
"finish_reason": None}]})
|
||||||
|
sse({**base, "choices": [{"index": 0, "delta": {},
|
||||||
|
"finish_reason": "stop"}], "usage": usage})
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"id": "governor-hardstop", "object": "chat.completion",
|
||||||
|
"created": created, "model": model,
|
||||||
|
"choices": [{"index": 0, "finish_reason": "stop",
|
||||||
|
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
|
||||||
|
"usage": usage,
|
||||||
|
}
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
self.wfile.flush()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _safe_error(self, status: int, msg: str) -> None:
|
||||||
|
self._send_json(status, {"error": msg})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scan_prompt_tokens(tail: bytearray):
|
||||||
|
if not tail:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = tail.decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
matches = _PROMPT_TOKENS_RE.findall(text)
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(matches[-1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
log_dir = os.path.dirname(LOG_PATH)
|
||||||
|
if log_dir and not os.path.isdir(log_dir):
|
||||||
|
try:
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
server.daemon_threads = True
|
||||||
|
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
|
||||||
|
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
|
||||||
|
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
|
||||||
|
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("Governor beendet (SIGINT).")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
|
||||||
|
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
|
||||||
|
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
|
||||||
|
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
|
||||||
|
After=network-online.target mission-control-2.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/governor
|
||||||
|
ExecStart=/usr/bin/python3 %h/governor/governor.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# --- Schwellen -------------------------------------------------------------
|
||||||
|
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
|
||||||
|
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
|
||||||
|
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
|
||||||
|
Environment=GOV_THRESHOLD=45000
|
||||||
|
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
|
||||||
|
Environment=GOV_LOG=%h/governor/governor.log
|
||||||
|
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
|
||||||
|
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
|
||||||
|
# klein -> passthrough (Modell antwortet normal)
|
||||||
|
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
|
||||||
|
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
|
||||||
|
set -u
|
||||||
|
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||||
|
|
||||||
|
python3 - <<'PY' > /tmp/gov_small.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
|
||||||
|
PY
|
||||||
|
python3 - <<'PY' > /tmp/gov_mid.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
|
||||||
|
PY
|
||||||
|
python3 - <<'PY' > /tmp/gov_big.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "=== TEST A: klein (passthrough) ==="
|
||||||
|
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
|
||||||
|
|
||||||
|
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
|
||||||
|
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
|
||||||
|
|
||||||
|
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
|
||||||
|
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
|
||||||
|
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
|
||||||
|
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
|
||||||
|
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
|
||||||
|
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
|
||||||
|
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
|
||||||
|
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
|
||||||
|
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
|
||||||
|
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
|
||||||
|
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
|
||||||
|
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
|
||||||
|
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
|
||||||
|
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
|
||||||
|
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
|
||||||
|
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
|
||||||
|
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
|
||||||
|
set -u
|
||||||
|
REPO="${1:?repo dir}"
|
||||||
|
MSG="${2:?message}"
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||||
|
export OPENAI_API_KEY="dummy"
|
||||||
|
cd "$REPO"
|
||||||
|
aider \
|
||||||
|
--model openai/Qwen3-Coder-Next \
|
||||||
|
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
|
||||||
|
--map-tokens 512 \
|
||||||
|
--read CONVENTIONS.md --read todo.py --read cli.py \
|
||||||
|
SAVEPOINT.md \
|
||||||
|
--message "$MSG"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
|
||||||
|
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
|
||||||
|
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
|
||||||
|
set -u
|
||||||
|
MSGS="${1:?Nachrichtendatei angeben}"
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||||
|
export OPENAI_API_KEY="dummy"
|
||||||
|
cd "$HOME/governor-p0/testrepo"
|
||||||
|
aider \
|
||||||
|
--model openai/Qwen3-Coder-Next \
|
||||||
|
--no-check-update \
|
||||||
|
--no-show-model-warnings \
|
||||||
|
--no-analytics \
|
||||||
|
--yes-always \
|
||||||
|
--map-tokens 512 \
|
||||||
|
--read CONVENTIONS.md \
|
||||||
|
SAVEPOINT.md \
|
||||||
|
< "$MSGS"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
|
||||||
|
set -u
|
||||||
|
MSGS="${1:?Nachrichtendatei angeben}"
|
||||||
|
REPO="${2:-$HOME/governor-p0/testrepo}"
|
||||||
|
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
|
||||||
|
cd "$REPO"
|
||||||
|
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode
|
||||||
|
(``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben.
|
||||||
|
|
||||||
|
Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit
|
||||||
|
wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von
|
||||||
|
``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die
|
||||||
|
Patches (re-)ein.
|
||||||
|
|
||||||
|
Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen
|
||||||
|
Zeilenverschiebungen durch Updates, anders als ``git apply``):
|
||||||
|
* ``marker`` schon im Quelltext -> Patch bereits drin, ueberspringen.
|
||||||
|
* ``old`` genau 1x vorhanden -> ersetzen, dann ``py_compile`` pruefen.
|
||||||
|
* ``old`` 0x oder >1x -> Kontext hat sich durch ein Update
|
||||||
|
geaendert -> LAUT scheitern (Exit 1), damit
|
||||||
|
der Postcheck rot wird und niemand denkt,
|
||||||
|
der Fix sei noch aktiv.
|
||||||
|
|
||||||
|
Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import py_compile
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent")))
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) ---
|
||||||
|
# (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir)
|
||||||
|
COPIES = [
|
||||||
|
("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) ---
|
||||||
|
PATCHES = [
|
||||||
|
{
|
||||||
|
"name": "0001-needs-input-wait",
|
||||||
|
"file": "hermes_cli/kanban_db.py",
|
||||||
|
"marker": 'kind not in ("needs_input", "capability")',
|
||||||
|
"old": " if recurrences >= BLOCK_RECURRENCE_LIMIT:\n",
|
||||||
|
"new": (
|
||||||
|
" # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n"
|
||||||
|
" # human-only — sie muessen in ``blocked`` auf den Commander warten\n"
|
||||||
|
" # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n"
|
||||||
|
" # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n"
|
||||||
|
' # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n'
|
||||||
|
" # fuer transient/untyped Blocks aktiv.\n"
|
||||||
|
' if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "0002-decompose-at-readiness",
|
||||||
|
"file": "hermes_cli/kanban_decompose.py",
|
||||||
|
"marker": "Startreife-Gate",
|
||||||
|
"old": (
|
||||||
|
" cfg = _load_config()\n"
|
||||||
|
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
|
||||||
|
),
|
||||||
|
"new": (
|
||||||
|
" # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n"
|
||||||
|
" # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n"
|
||||||
|
" # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n"
|
||||||
|
" # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n"
|
||||||
|
" # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n"
|
||||||
|
" with kb.connect_closing() as _pconn:\n"
|
||||||
|
" _parents = [r[0] for r in _pconn.execute(\n"
|
||||||
|
' "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n'
|
||||||
|
" if _parents:\n"
|
||||||
|
' _ph = ",".join("?" * len(_parents))\n'
|
||||||
|
" _open = _pconn.execute(\n"
|
||||||
|
' "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n'
|
||||||
|
" \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n"
|
||||||
|
" ).fetchone()[0]\n"
|
||||||
|
" if _open:\n"
|
||||||
|
" return DecomposeOutcome(\n"
|
||||||
|
" task_id, False,\n"
|
||||||
|
' f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n'
|
||||||
|
" )\n"
|
||||||
|
" cfg = _load_config()\n"
|
||||||
|
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "0003-orphan-guard",
|
||||||
|
"file": "gateway/kanban_watchers.py",
|
||||||
|
"marker": "mc2_kanban_reaper",
|
||||||
|
"old": (
|
||||||
|
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
|
||||||
|
" if pids:\n"
|
||||||
|
" logger.info(\n"
|
||||||
|
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
|
||||||
|
" len(pids),\n"
|
||||||
|
" pids,\n"
|
||||||
|
" )\n"
|
||||||
|
),
|
||||||
|
"new": (
|
||||||
|
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
|
||||||
|
" if pids:\n"
|
||||||
|
" logger.info(\n"
|
||||||
|
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
|
||||||
|
" len(pids),\n"
|
||||||
|
" pids,\n"
|
||||||
|
" )\n"
|
||||||
|
" # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n"
|
||||||
|
" # Worker, deren Task nicht mehr 'running' ist (extern\n"
|
||||||
|
" # blockiert/reset) — sonst unterlaufen sie max_in_progress\n"
|
||||||
|
" # und verbrennen Rechenzeit.\n"
|
||||||
|
" try:\n"
|
||||||
|
" from hermes_cli import mc2_kanban_reaper as _mc2reaper\n"
|
||||||
|
" _orph = await asyncio.to_thread(\n"
|
||||||
|
" _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n"
|
||||||
|
" )\n"
|
||||||
|
" if _orph:\n"
|
||||||
|
" logger.info(\n"
|
||||||
|
' "kanban dispatcher: reaped %d orphaned worker(s): %s",\n'
|
||||||
|
" len(_orph), _orph,\n"
|
||||||
|
" )\n"
|
||||||
|
" except Exception:\n"
|
||||||
|
' logger.exception("kanban dispatcher: orphan reaper failed")\n'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "0004a-context-inherit-compute",
|
||||||
|
"file": "hermes_cli/kanban_decompose.py",
|
||||||
|
"marker": '_parent_ctx = ""',
|
||||||
|
"old": (
|
||||||
|
" children: list[dict] = []\n"
|
||||||
|
" for idx, entry in enumerate(raw_tasks):\n"
|
||||||
|
),
|
||||||
|
"new": (
|
||||||
|
" children: list[dict] = []\n"
|
||||||
|
" # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n"
|
||||||
|
' # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n'
|
||||||
|
" # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n"
|
||||||
|
" # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n"
|
||||||
|
' _parent_ctx = ""\n'
|
||||||
|
' if getattr(task, "body", None) and task.body.strip():\n'
|
||||||
|
" _parent_ctx = (\n"
|
||||||
|
' "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n'
|
||||||
|
" + task.body.strip()[:1500]\n"
|
||||||
|
' + "\\n\\n## DEIN TEILAUFTRAG\\n"\n'
|
||||||
|
" )\n"
|
||||||
|
" for idx, entry in enumerate(raw_tasks):\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "0004b-context-inherit-apply",
|
||||||
|
"file": "hermes_cli/kanban_decompose.py",
|
||||||
|
"marker": "_parent_ctx + body",
|
||||||
|
"old": (
|
||||||
|
' body = entry.get("body")\n'
|
||||||
|
" if not isinstance(body, str):\n"
|
||||||
|
' body = ""\n'
|
||||||
|
),
|
||||||
|
"new": (
|
||||||
|
' body = entry.get("body")\n'
|
||||||
|
" if not isinstance(body, str):\n"
|
||||||
|
' body = ""\n'
|
||||||
|
" body = _parent_ctx + body # MC2 (20.07.): Projekt-Kontext vererben\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "0005-create-chain-guard",
|
||||||
|
"file": "tools/kanban_tools.py",
|
||||||
|
"marker": "_MC2_RECENT_ROOTS",
|
||||||
|
"old": (
|
||||||
|
" new_task = kb.get_task(conn, new_tid)\n"
|
||||||
|
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
|
||||||
|
),
|
||||||
|
"new": (
|
||||||
|
" # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n"
|
||||||
|
" # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n"
|
||||||
|
" # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n"
|
||||||
|
" # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n"
|
||||||
|
" # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n"
|
||||||
|
" # Verkettung harmlos (laeuft eh seriell).\n"
|
||||||
|
" try:\n"
|
||||||
|
" if triage and not parents and session_id:\n"
|
||||||
|
" import time as _mc2t\n"
|
||||||
|
' _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n'
|
||||||
|
" _prev = _roots.get(session_id)\n"
|
||||||
|
" _now = _mc2t.monotonic()\n"
|
||||||
|
" if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n"
|
||||||
|
" try:\n"
|
||||||
|
" kb.link_tasks(conn, _prev[0], new_tid)\n"
|
||||||
|
" except Exception:\n"
|
||||||
|
" pass\n"
|
||||||
|
" _roots[session_id] = (new_tid, _now)\n"
|
||||||
|
" if len(_roots) > 64:\n"
|
||||||
|
" _roots.clear()\n"
|
||||||
|
" except Exception:\n"
|
||||||
|
" pass\n"
|
||||||
|
" new_task = kb.get_task(conn, new_tid)\n"
|
||||||
|
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not HA.is_dir():
|
||||||
|
print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.")
|
||||||
|
return 0
|
||||||
|
applied, skipped, failed = [], [], []
|
||||||
|
|
||||||
|
# --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) ---
|
||||||
|
for src_rel, dst_rel in COPIES:
|
||||||
|
src, dst = HERE / src_rel, HA / dst_rel
|
||||||
|
if not src.is_file():
|
||||||
|
failed.append((src_rel, f"Quelle fehlt: {src}"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
same = dst.is_file() and dst.read_bytes() == src.read_bytes()
|
||||||
|
if same:
|
||||||
|
skipped.append(f"copy:{dst_rel}")
|
||||||
|
continue
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
if dst.suffix == ".py":
|
||||||
|
py_compile.compile(str(dst), doraise=True)
|
||||||
|
applied.append(f"copy:{dst_rel}")
|
||||||
|
except Exception as exc:
|
||||||
|
failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}"))
|
||||||
|
for p in PATCHES:
|
||||||
|
f = HA / p["file"]
|
||||||
|
if not f.is_file():
|
||||||
|
failed.append((p["name"], f"Datei fehlt: {f}"))
|
||||||
|
continue
|
||||||
|
src = f.read_text(encoding="utf-8")
|
||||||
|
if p["marker"] in src:
|
||||||
|
skipped.append(p["name"])
|
||||||
|
continue
|
||||||
|
n = src.count(p["old"])
|
||||||
|
if n != 1:
|
||||||
|
failed.append((p["name"], (f"erwartete 1 Vorkommen von old, fand {n} "
|
||||||
|
"(Update hat den Kontext geaendert?)")))
|
||||||
|
continue
|
||||||
|
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
py_compile.compile(str(f), doraise=True)
|
||||||
|
except Exception as exc:
|
||||||
|
failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}"))
|
||||||
|
continue
|
||||||
|
applied.append(p["name"])
|
||||||
|
|
||||||
|
print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}")
|
||||||
|
for name, why in failed:
|
||||||
|
print(f" FEHLER {name}: {why}", file=sys.stderr)
|
||||||
|
if failed:
|
||||||
|
return 1 # Fehler → Deploy/Postcheck rot
|
||||||
|
if applied:
|
||||||
|
return 10 # etwas geaendert → Aufrufer sollte hermes-gateway neu starten
|
||||||
|
return 0 # nichts zu tun (alles schon drin)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker.
|
||||||
|
|
||||||
|
Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach
|
||||||
|
``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher
|
||||||
|
(``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen.
|
||||||
|
|
||||||
|
Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B.
|
||||||
|
über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task``
|
||||||
|
``worker_pid = NULL`` — der Worker-PROZESS läuft aber weiter. Die DB zählt ihn
|
||||||
|
nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker
|
||||||
|
möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren
|
||||||
|
Task nicht mehr ``running`` ist.
|
||||||
|
|
||||||
|
Linux-only (/proc-Scan). Best-effort: jeder Fehler → leere Liste, nie werfen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def reap_orphaned_workers(connect_closing) -> list[tuple[int, str]]:
|
||||||
|
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
|
||||||
|
|
||||||
|
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
|
||||||
|
``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste
|
||||||
|
der beendeten ``(pid, task_id)`` zurück.
|
||||||
|
"""
|
||||||
|
if sys.platform != "linux":
|
||||||
|
return []
|
||||||
|
candidates: dict[int, str] = {}
|
||||||
|
try:
|
||||||
|
entries = os.listdir("/proc")
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{entry}/cmdline", "rb") as fh:
|
||||||
|
cmd = fh.read().replace(b"\x00", b" ")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
m = _TASK_RE.search(cmd)
|
||||||
|
if m:
|
||||||
|
candidates[int(entry)] = m.group(1).decode()
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
task_ids = list(set(candidates.values()))
|
||||||
|
placeholders = ",".join("?" * len(task_ids))
|
||||||
|
running: set[str] = set()
|
||||||
|
try:
|
||||||
|
with connect_closing() as conn:
|
||||||
|
running = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT id FROM tasks WHERE id IN ({placeholders}) "
|
||||||
|
"AND status = 'running'",
|
||||||
|
task_ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
killed: list[tuple[int, str]] = []
|
||||||
|
for pid, task_id in candidates.items():
|
||||||
|
if task_id in running:
|
||||||
|
continue
|
||||||
|
# Prozessgruppe zuerst (Worker starten start_new_session=True → eigene
|
||||||
|
# Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit).
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
killed.append((pid, task_id))
|
||||||
|
return killed
|
||||||
@@ -30,6 +30,25 @@ else
|
|||||||
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
|
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
|
||||||
|
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
|
||||||
|
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
|
||||||
|
# --- angewandt → Gateway neu laden; Exit 1 = ein Patch passt nicht mehr → ROT. ---
|
||||||
|
PATCH_APPLY="${MC2_SRC:-$HOME/mission-control-v2}/deploy/hermes-patches/apply.py"
|
||||||
|
if [ -f "$PATCH_APPLY" ]; then
|
||||||
|
python3 "$PATCH_APPLY" >/dev/null 2>&1; _prc=$?
|
||||||
|
if [ "$_prc" -eq 10 ]; then
|
||||||
|
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
|
||||||
|
echo "PASS · Hermes-Runtime-Patches nach Update RE-angewandt (Gateway neu geladen)"
|
||||||
|
elif [ "$_prc" -eq 0 ]; then
|
||||||
|
echo "PASS · Hermes-Runtime-Patches unveraendert vorhanden"
|
||||||
|
else
|
||||||
|
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
|
||||||
|
fi
|
||||||
|
|
||||||
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
|
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
|
||||||
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
|
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
|
||||||
>/dev/null 2>&1 ); then
|
>/dev/null 2>&1 ); then
|
||||||
@@ -53,12 +72,22 @@ fi
|
|||||||
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
|
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
|
||||||
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
|
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
|
||||||
if [ -n "$API_KEY" ]; then
|
if [ -n "$API_KEY" ]; then
|
||||||
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
|
# Mit Retry — GENAU wie der Voice-Smoke darunter, und aus demselben Grund: direkt nach
|
||||||
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
|
# einem Update ist das Modell kalt. Ein Kaltstart kostet ~20-30 s Laden PLUS ~37 s
|
||||||
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \
|
# Prefill für Hermes' ~70-KB-Systemprompt (33 Tool-Schemas) — zusammen mit zwei
|
||||||
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
|
# Inferenz-Runden sprengt das die 90 s des ersten Versuchs. Warm gemessen: 12 s.
|
||||||
|
# Ein Versuch allein war ein Fehlalarm-Generator (live erlebt 25.07., Job faf1137a).
|
||||||
|
TOOL_RESP=""
|
||||||
|
for try in 1 2 3; do
|
||||||
|
TOOL_RESP=$(curl -sf -m 120 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
|
||||||
|
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-H "X-Hermes-Session-Id: postcheck-tool-smoke-$try" \
|
||||||
|
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
|
||||||
|
echo "$TOOL_RESP" | grep -qi "postcheck-ok" && break
|
||||||
|
[ "$try" -lt 3 ] && sleep 10
|
||||||
|
done
|
||||||
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
|
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
|
||||||
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis"
|
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis (Versuch $try)"
|
||||||
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
|
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
|
||||||
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
|
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
|
||||||
else
|
else
|
||||||
@@ -86,6 +115,25 @@ else
|
|||||||
echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht (3 Versuche)"; fail=1
|
echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht (3 Versuche)"; fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Browser-Tool: agent-browser überlebt das Update? (Vorfall 15./16.07.2026: das ---
|
||||||
|
# --- npm ci des Updates bog ~/.local/bin/agent-browser per postinstall auf ---
|
||||||
|
# --- node_modules/ um und löschte das Paket im selben Lauf → toter Link, jedes ---
|
||||||
|
# --- „Website öffnen" starb still als „Failed to open".) Heilung direkt hier, denn ---
|
||||||
|
# --- genau NACH einem Update entsteht der Bruch; try-restart räumt den ---
|
||||||
|
# --- „nicht installiert"-Cache, den browser_tool pro Prozess hält. ---
|
||||||
|
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
AB_LINK="$HOME/.local/bin/agent-browser"
|
||||||
|
AB_GOOD="$HOME/.local/lib/node_modules/agent-browser/bin/agent-browser-linux-x64"
|
||||||
|
if "$AB_LINK" --version >/dev/null 2>&1; then
|
||||||
|
echo "PASS · Browser-Tool (agent-browser erreichbar)"
|
||||||
|
elif [ -x "$AB_GOOD" ] && "$AB_GOOD" --version >/dev/null 2>&1 \
|
||||||
|
&& ln -sfn "$AB_GOOD" "$AB_LINK" && "$AB_LINK" --version >/dev/null 2>&1; then
|
||||||
|
systemctl --user try-restart hermes-gateway hermes-builtin-ui >/dev/null 2>&1 || true
|
||||||
|
echo "GEHEILT · Browser-Tool: toter agent-browser-Link nach Update neu gesetzt (Dienste neu gestartet)"
|
||||||
|
else
|
||||||
|
echo "FAIL · Browser-Tool: agent-browser nicht lauffähig (npm install -g agent-browser && agent-browser install --with-deps)"; fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$fail" -eq 0 ]; then
|
if [ "$fail" -eq 0 ]; then
|
||||||
echo "=== GEHIRN OK ✓ ==="
|
echo "=== GEHIRN OK ✓ ==="
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MC2 Morgen-Digest (Nächtliche Telegram-Zusammenfassung)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/bash %h/mission-control-v2/deploy/morgen-digest.sh
|
||||||
|
TimeoutStartSec=300
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Timer für MC2 Morgen-Digest (07:00 Uhr)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 07:00:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=5m
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""mem0-Konsolidierung (monatlich, 19.07.2026 — Nachtrag Phase 2 Drei-Welten-Plan).
|
||||||
|
|
||||||
|
mem0 OSS v2 ist ADD-only: Es legt Fakten an, loest aber Widersprueche nie auf und
|
||||||
|
legt Aehnliches nie zusammen — ohne Pflege waechst das Gedaechtnis unbegrenzt und
|
||||||
|
der Recall verrauscht. Der Steward-Dedupe faengt nur exakte Dubletten. Dieser Lauf
|
||||||
|
macht die fehlende Konsolidierung:
|
||||||
|
|
||||||
|
1. Cluster aehnlicher Fakten holen — ueber den bestehenden /graph-Endpunkt des
|
||||||
|
Sidecars (Kosinus der gespeicherten Embeddings, kein Re-Embedding, kein
|
||||||
|
Chroma-Zugriff noetig).
|
||||||
|
2. Je Cluster entscheidet das lokale Hirn (Alias `fast`, Thinking aus wie im
|
||||||
|
Sidecar): zusammenlegen / Widerspruch aufloesen (neuester Zeitstempel
|
||||||
|
gewinnt) / NICHTS tun.
|
||||||
|
3. Anwenden ueber die normalen Sidecar-Endpunkte (PUT/DELETE /memory/<id>).
|
||||||
|
|
||||||
|
Sicherheitsleitplanken (hart im Code, nicht im Prompt):
|
||||||
|
- Pro Cluster ueberlebt IMMER mindestens ein Fakt.
|
||||||
|
- Nur ids aus dem Cluster, max. 1 Update pro Cluster, Inhalt <= 600 Zeichen.
|
||||||
|
- LLM-Fehler / kaputtes JSON => Cluster bleibt unangetastet.
|
||||||
|
- Kappung pro Lauf (MAX_CLUSTERS), damit ein Lauf nie das halbe Gedaechtnis
|
||||||
|
umpfluegt — der naechste Monat macht weiter.
|
||||||
|
|
||||||
|
Aufruf: mem0-konsolidierung.py [--apply] (Default: Dry-Run, druckt nur Plan)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MEM0 = "http://127.0.0.1:8765"
|
||||||
|
LLM = "http://127.0.0.1:8080/v1/chat/completions"
|
||||||
|
LLM_MODEL = "fast"
|
||||||
|
MIN_SCORE = 0.82
|
||||||
|
MAX_CLUSTERS = 15
|
||||||
|
MAX_FACTS_PER_CLUSTER = 6
|
||||||
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def _http(method: str, url: str, body: dict | None = None, timeout: float = 120.0):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def clusters_from_graph() -> tuple[dict, list]:
|
||||||
|
g = _http("GET", f"{MEM0}/graph?min_score={MIN_SCORE}&top_k=5", timeout=180)
|
||||||
|
nodes = {n["id"]: n for n in g.get("nodes", [])}
|
||||||
|
parent: dict = {}
|
||||||
|
|
||||||
|
def find(x):
|
||||||
|
parent.setdefault(x, x)
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
for e in g.get("edges", []):
|
||||||
|
a, b = find(e["source"]), find(e["target"])
|
||||||
|
if a != b:
|
||||||
|
parent[a] = b
|
||||||
|
groups: dict = {}
|
||||||
|
for nid in parent:
|
||||||
|
groups.setdefault(find(nid), []).append(nid)
|
||||||
|
clusters = [sorted(v) for v in groups.values() if len(v) >= 2]
|
||||||
|
clusters.sort(key=len, reverse=True)
|
||||||
|
return nodes, clusters[:MAX_CLUSTERS]
|
||||||
|
|
||||||
|
|
||||||
|
def judge(facts: list[dict]) -> list[dict]:
|
||||||
|
"""LLM entscheidet pro Cluster. Fehler => [] (nichts tun)."""
|
||||||
|
listing = "\n".join(
|
||||||
|
f"- id={f['id']} | kategorie={f.get('category')} | stand={f.get('updated_at', '')[:10]} | {f.get('content', '')}"
|
||||||
|
for f in facts)
|
||||||
|
system = (
|
||||||
|
"Du konsolidierst ein Langzeitgedaechtnis. Unten steht EINE Gruppe semantisch "
|
||||||
|
"aehnlicher Fakten. Entscheide konservativ: "
|
||||||
|
"(a) Sind es Facetten DESSELBEN Fakts: fasse sie in EINEM praezisen deutschen Satz "
|
||||||
|
"zusammen -> genau ein update auf die beste id, die anderen delete. "
|
||||||
|
"(b) Widersprechen sie sich: der Fakt mit dem NEUESTEN Stand gewinnt -> veraltete delete. "
|
||||||
|
"(c) Sind es VERSCHIEDENE Fakten (nur thematisch verwandt): TUE NICHTS. "
|
||||||
|
"Im Zweifel IMMER (c). Antworte NUR als JSON: "
|
||||||
|
'{"actions":[{"op":"update","id":"...","content":"..."},{"op":"delete","id":"..."}]} '
|
||||||
|
'oder {"actions":[]}.'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = _http("POST", LLM, {
|
||||||
|
"model": LLM_MODEL,
|
||||||
|
"messages": [{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": "Fakten-Gruppe:\n" + listing}],
|
||||||
|
"temperature": 0.1, "max_tokens": 800,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
|
})
|
||||||
|
raw = resp["choices"][0]["message"]["content"] or "{}"
|
||||||
|
actions = json.loads(raw).get("actions", [])
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" (LLM-Fehler, Gruppe unangetastet: {exc})")
|
||||||
|
return []
|
||||||
|
# Harte Validierung — Leitplanken siehe Docstring.
|
||||||
|
ids = {f["id"] for f in facts}
|
||||||
|
updates = [a for a in actions if a.get("op") == "update" and a.get("id") in ids
|
||||||
|
and isinstance(a.get("content"), str) and 0 < len(a["content"]) <= 600]
|
||||||
|
deletes = [a for a in actions if a.get("op") == "delete" and a.get("id") in ids]
|
||||||
|
if len(updates) > 1:
|
||||||
|
return []
|
||||||
|
del_ids = {a["id"] for a in deletes} - {a["id"] for a in updates}
|
||||||
|
if len(del_ids) >= len(ids):
|
||||||
|
return []
|
||||||
|
return updates + [{"op": "delete", "id": i} for i in sorted(del_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
nodes, clusters = clusters_from_graph()
|
||||||
|
total = len(nodes)
|
||||||
|
print(f"mem0-Konsolidierung {'(APPLY)' if APPLY else '(DRY-RUN)'} — "
|
||||||
|
f"{total} Fakten, {len(clusters)} Gruppen >= {MIN_SCORE}")
|
||||||
|
n_upd = n_del = 0
|
||||||
|
for ci, cl in enumerate(clusters, 1):
|
||||||
|
facts = [nodes[i] for i in cl][:MAX_FACTS_PER_CLUSTER]
|
||||||
|
actions = judge(facts)
|
||||||
|
if not actions:
|
||||||
|
continue
|
||||||
|
print(f"Gruppe {ci} ({len(facts)} Fakten):")
|
||||||
|
for f in facts:
|
||||||
|
print(f" [{f['id'][:8]}] {f.get('content', '')[:90]}")
|
||||||
|
for a in actions:
|
||||||
|
if a["op"] == "update":
|
||||||
|
print(f" -> UPDATE {a['id'][:8]}: {a['content'][:90]}")
|
||||||
|
n_upd += 1
|
||||||
|
if APPLY:
|
||||||
|
_http("PUT", f"{MEM0}/memory/{a['id']}", {"content": a["content"]})
|
||||||
|
else:
|
||||||
|
print(f" -> DELETE {a['id'][:8]}")
|
||||||
|
n_del += 1
|
||||||
|
if APPLY:
|
||||||
|
_http("DELETE", f"{MEM0}/memory/{a['id']}")
|
||||||
|
print(f"Ergebnis: {n_upd} zusammengefasst, {n_del} geloescht, "
|
||||||
|
f"{total - (n_del if APPLY else 0)} Fakten verbleiben. "
|
||||||
|
f"({int(time.time() - t0)} s)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Monatlicher mem0-Konsolidierungslauf (Cron-Wrapper, 3. des Monats 06:40 —
|
||||||
|
# nach Restore-Probe am 1. und Venv-Audit am 2.). no_agent-Job: stdout geht
|
||||||
|
# als Bericht direkt an Telegram. Logik: deploy/mem0-konsolidierung.py (--apply).
|
||||||
|
set -u
|
||||||
|
exec python3 "$HOME/.hermes/scripts/mem0-konsolidierung.py" --apply 2>&1
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Morgen-Digest: Sammelt die zurückgehaltenen nächtlichen Meldungen (00:00 - 06:59)
|
||||||
|
# und lässt Lucy (LLM) eine Telegram-Zusammenfassung daraus bauen.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
|
||||||
|
if [ ! -f "$NIGHT_QUEUE" ]; then
|
||||||
|
# Keine nächtlichen Meldungen
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wenn die Datei existiert, aber leer ist, löschen und beenden.
|
||||||
|
if [ ! -s "$NIGHT_QUEUE" ]; then
|
||||||
|
rm -f "$NIGHT_QUEUE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
EVIDENCE="$(cat "$NIGHT_QUEUE")"
|
||||||
|
|
||||||
|
PROMPT="Du bist Lucy, die KI-Assistenz der Mission-Control-Box.
|
||||||
|
Hier sind die Systemmeldungen der vergangenen Nacht.
|
||||||
|
Der Commander möchte ordentlich Futter und Details zum Frühstück! Fasse die Meldungen zu einem gehaltvollen Morgen-Digest zusammen.
|
||||||
|
Beachte dabei:
|
||||||
|
- Nenne konkrete Fakten, Namen und Versionen.
|
||||||
|
- Welche Modelle wurden z.B. vom Radar entdeckt oder evaluiert? Was waren die Ergebnisse?
|
||||||
|
- Welche Updates liefen genau und was haben sie Neues gebracht? Was muss sich der Commander noch anschauen?
|
||||||
|
- Strukturiere die Nachricht übersichtlich (z.B. mit kleinen Emojis oder Bulletpoints), aber bleibe in deiner Rolle als Lucy.
|
||||||
|
|
||||||
|
Nächtliche Meldungen:
|
||||||
|
$EVIDENCE"
|
||||||
|
|
||||||
|
# LLM aufrufen (llama-swap / gpt-oss-120b)
|
||||||
|
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
|
||||||
|
MODEL="${CHEF_MODEL:-gpt-oss-120b}"
|
||||||
|
|
||||||
|
REQ="$(jq -n --arg m "$MODEL" --arg sys "Du bist Lucy." --arg usr "$PROMPT" --argjson mt 1200 \
|
||||||
|
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
|
||||||
|
max_tokens:$mt, temperature:0.4}')"
|
||||||
|
|
||||||
|
DIGEST="$(curl -s -m 180 "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$REQ" \
|
||||||
|
| python3 -c '
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
msg = d["choices"][0]["message"]
|
||||||
|
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
|
||||||
|
except Exception:
|
||||||
|
pass'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -n "$DIGEST" ]; then
|
||||||
|
# Sende den Digest via hermes send (Telegram)
|
||||||
|
bash -lc 'hermes send --to telegram --subject "[🌅 Morgen-Digest]" -- "$1"' _ "$DIGEST"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Erfolgreich gesendet, Queue leeren
|
||||||
|
rm -f "$NIGHT_QUEUE"
|
||||||
|
else
|
||||||
|
echo "Fehler beim Senden des Morgen-Digests an Telegram. Queue bleibt erhalten." >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Leerer Digest vom LLM. Queue bleibt erhalten." >&2
|
||||||
|
fi
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Erwartet den Job-Namen und das Skript als ein String oder zwei Argumente
|
||||||
|
if [ $# -eq 1 ]; then
|
||||||
|
read -r JOB_NAME SCRIPT_NAME <<< "$1"
|
||||||
|
else
|
||||||
|
JOB_NAME="$1"
|
||||||
|
SCRIPT_NAME="$2"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SCRIPT_NAME" ]; then
|
||||||
|
SCRIPT_NAME="$JOB_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pfad zusammenbauen
|
||||||
|
SCRIPT_PATH="$HOME/mission-control-v2/deploy/$SCRIPT_NAME"
|
||||||
|
if [ ! -f "$SCRIPT_PATH" ]; then
|
||||||
|
# Versuche pruefstand/
|
||||||
|
SCRIPT_PATH="$HOME/mission-control-v2/deploy/pruefstand/$SCRIPT_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "$SCRIPT_PATH" | bash "$HOME/mission-control-v2/deploy/notify.sh" -s "$JOB_NAME"
|
||||||
+19
-2
@@ -22,8 +22,8 @@ shift $((OPTIND - 1))
|
|||||||
MSG="${1:-}"
|
MSG="${1:-}"
|
||||||
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
|
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
|
||||||
if [ -z "$MSG" ]; then
|
if [ -z "$MSG" ]; then
|
||||||
echo "usage: notify.sh [-s subject] <nachricht>" >&2
|
# Bei leerer Pipe-Eingabe lautlos beenden (wichtig für Cron-Skripte ohne Ausgabe)
|
||||||
exit 2
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TS="$(date '+%Y-%m-%d %H:%M:%S')"
|
TS="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
@@ -41,6 +41,23 @@ PY
|
|||||||
)" >/dev/null 2>&1 || true
|
)" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Nachtweiche: Zwischen 00:00 und 06:59 Uhr (inklusive) fangen wir Telegram-Nachrichten ab
|
||||||
|
# und sammeln sie für den Morgen-Digest, ES SEI DENN es ist ein Alarm.
|
||||||
|
HOUR="$(date +%H)"
|
||||||
|
if [ "$HOUR" -ge 0 ] && [ "$HOUR" -lt 7 ]; then
|
||||||
|
case "${SUBJECT,,}" in
|
||||||
|
*alarm*|*notfall*)
|
||||||
|
;; # Alarme gehen immer sofort durch
|
||||||
|
*)
|
||||||
|
# Sammeln für Morgen-Digest
|
||||||
|
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
|
||||||
|
echo "- ${SUBJECT:+$SUBJECT: }$MSG" >> "$NIGHT_QUEUE"
|
||||||
|
echo "$TS QUEUED für Morgen-Digest: $MSG" >> "$LOG"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
|
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
|
||||||
if [ -n "$SUBJECT" ]; then
|
if [ -n "$SUBJECT" ]; then
|
||||||
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
|
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# opencode-lauf.sh — EIN begrenzter Agentenlauf auf der Box, mit denselben Regeln wie in Zed.
|
||||||
|
#
|
||||||
|
# Das ist die Nacht-Seite von „ein Regelwerk, zwei Ausloeser": tagsueber tippst du in Zed,
|
||||||
|
# nachts ruft ein Hermes-Cron dieses Skript. Beide Wege benutzen
|
||||||
|
# * dieselbe OpenCode-Version,
|
||||||
|
# * dieselbe Mannschaft (~/.config/opencode/opencode.json: coder/hermes/kritiker),
|
||||||
|
# * dasselbe Plugin (~/.config/opencode/plugin/mc2-governor.ts: Zaun + Pruef-Tor),
|
||||||
|
# * denselben Token-Waechter (:8100).
|
||||||
|
#
|
||||||
|
# Unterschied zu deploy/worker.sh: worker.sh ist EIN zustandsloser Completion-Aufruf
|
||||||
|
# (Hermes schreibt die Dateien selbst). Hier laeuft ein ECHTER Agent mit Datei-Haenden,
|
||||||
|
# Subagenten und Pruef-Tor — fuer ganze Karten statt fuer Schnipsel.
|
||||||
|
#
|
||||||
|
# Nutzung:
|
||||||
|
# opencode-lauf.sh <repo-pfad> "<auftrag>"
|
||||||
|
# opencode-lauf.sh ~/projekte/foo "Baue X. Halte dich an AGENTS.md."
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# LAUF_TIMEOUT Sekunden Hoechstdauer (Default 3600)
|
||||||
|
# LAUF_LAUT 1 = Lucy spricht mit (Default 0 = still, Nachtbetrieb)
|
||||||
|
# LAUF_AGENT OpenCode-Agent (Default build)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="${1:-}"
|
||||||
|
AUFTRAG="${2:-}"
|
||||||
|
TIMEOUT="${LAUF_TIMEOUT:-3600}"
|
||||||
|
AGENT="${LAUF_AGENT:-build}"
|
||||||
|
OC="$HOME/.opencode/bin/opencode"
|
||||||
|
ANNOUNCE="${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}"
|
||||||
|
|
||||||
|
melde () { # melde <betreff> <text> [prioritaet]
|
||||||
|
curl -sf -m 5 -X POST "$ANNOUNCE" -H 'Content-Type: application/json' \
|
||||||
|
--data "$(python3 -c 'import json,sys; print(json.dumps({"subject":sys.argv[1],"text":sys.argv[2],"source":"loop","priority":sys.argv[3]}))' \
|
||||||
|
"$1" "$2" "${3:-silent}")" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$REPO" ] || [ -z "$AUFTRAG" ]; then
|
||||||
|
echo "Nutzung: $0 <repo-pfad> \"<auftrag>\"" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -d "$REPO" ]; then
|
||||||
|
echo "FEHLER: '$REPO' ist kein Verzeichnis." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -x "$OC" ]; then
|
||||||
|
echo "FEHLER: OpenCode nicht gefunden ($OC)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Governor MUSS stehen — ohne ihn liefe der Lauf ohne Sitzungs-Bremse.
|
||||||
|
if ! curl -sf -m 5 -o /dev/null "http://127.0.0.1:8100/governor/status"; then
|
||||||
|
echo "FEHLER: Governor (:8100) antwortet nicht — Lauf abgebrochen (keine Sitzungs-Bremse)." >&2
|
||||||
|
melde "[Lauf]" "Ich habe einen Nachtlauf abgebrochen: der Token-Waechter antwortet nicht." "normal"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nachts still: Meldungen landen im Briefkasten, Lucy spricht sie aber nicht aus.
|
||||||
|
# Das Plugin liest diese Variable; die Morgen-Zusammenfassung kommt vom Daily-Briefing.
|
||||||
|
if [ "${LAUF_LAUT:-0}" = "1" ]; then export MC2_LOOP_SILENT=0; else export MC2_LOOP_SILENT=1; fi
|
||||||
|
|
||||||
|
LOGDIR="$HOME/.hermes/logs"; mkdir -p "$LOGDIR"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
LOG="$LOGDIR/opencode-lauf-$STAMP.log"
|
||||||
|
MAXRUNDEN="${LAUF_MAX_RUNDEN:-3}"
|
||||||
|
|
||||||
|
cd "$REPO" || exit 2
|
||||||
|
NAME="$(basename "$REPO")"
|
||||||
|
melde "[Lauf]" "Ich fange an zu bauen: $NAME." "silent"
|
||||||
|
echo "=== Lauf $STAMP · Repo $REPO · Agent $AGENT · Timeout ${TIMEOUT}s ===" | tee "$LOG"
|
||||||
|
|
||||||
|
# Verify-Befehl des Projekts lesen (gleiche Datei und gleiche Regeln wie im Plugin).
|
||||||
|
verify_cmd () {
|
||||||
|
[ -r "$REPO/VERIFY" ] || return 1
|
||||||
|
grep -vE '^\s*(#|$)' "$REPO/VERIFY" | paste -sd' && ' -
|
||||||
|
}
|
||||||
|
|
||||||
|
START=$(date +%s)
|
||||||
|
CODE=0
|
||||||
|
RUNDE=0
|
||||||
|
AUFGABE="$AUFTRAG"
|
||||||
|
|
||||||
|
# ── Bau-Schleife ────────────────────────────────────────────────────────────
|
||||||
|
# Warum hier UND im Plugin? Das Plugin haengt am Ereignis `session.idle` — in Zed
|
||||||
|
# laeuft der Prozess weiter und alles ist gut. Bei `opencode run` beendet sich der
|
||||||
|
# Prozess aber, bevor das Pruef-Tor fertig ist (gemessen 25.07.). Fuer unbeaufsichtigte
|
||||||
|
# Laeufe muss die Schleife deshalb HIER liegen, wo sie den Prozess ueberlebt.
|
||||||
|
while :; do
|
||||||
|
RUNDE=$((RUNDE + 1))
|
||||||
|
echo "--- Runde $RUNDE/$MAXRUNDEN ---" | tee -a "$LOG"
|
||||||
|
timeout "$TIMEOUT" "$OC" run --agent "$AGENT" "$AUFGABE" >>"$LOG" 2>&1
|
||||||
|
CODE=$?
|
||||||
|
[ "$CODE" -eq 124 ] && { echo "ZEITUEBERSCHREITUNG" | tee -a "$LOG"; break; }
|
||||||
|
|
||||||
|
VCMD="$(verify_cmd)" || { echo "Kein VERIFY — Pruef-Tor aus, Lauf endet." | tee -a "$LOG"; break; }
|
||||||
|
|
||||||
|
echo "--- Pruef-Tor: $VCMD ---" | tee -a "$LOG"
|
||||||
|
VOUT="$(cd "$REPO" && eval "$VCMD" 2>&1)"; VCODE=$?
|
||||||
|
printf '%s\n' "$VOUT" | tail -20 >> "$LOG"
|
||||||
|
|
||||||
|
if [ "$VCODE" -eq 0 ]; then
|
||||||
|
echo "PRUEF-TOR GRUEN" | tee -a "$LOG"
|
||||||
|
melde "[Pruefung]" "$NAME: Tests gruen nach $RUNDE Runde(n)." "normal"
|
||||||
|
CODE=0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RUNDE" -ge "$MAXRUNDEN" ]; then
|
||||||
|
echo "PRUEF-TOR ROT — Reparaturrunden aufgebraucht." | tee -a "$LOG"
|
||||||
|
melde "[Pruefung]" "$NAME: Tests bleiben rot nach $RUNDE Runden. Hier komme ich allein nicht weiter." "normal"
|
||||||
|
CODE=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PRUEF-TOR ROT — Runde $((RUNDE + 1)) folgt." | tee -a "$LOG"
|
||||||
|
melde "[Pruefung]" "$NAME: Tests rot, ich repariere selbst weiter (Runde $((RUNDE + 1))/$MAXRUNDEN)." "silent"
|
||||||
|
AUFGABE="[MC2-PRUEFTOR] Der Verify-Befehl des Projekts ist fehlgeschlagen.
|
||||||
|
|
||||||
|
Befehl: $VCMD
|
||||||
|
|
||||||
|
Ausgabe (Ende):
|
||||||
|
$(printf '%s' "$VOUT" | tail -c 3000)
|
||||||
|
|
||||||
|
Behebe die URSACHE — nicht das Symptom. Schalte keinen Test ab und aendere keine Tests.
|
||||||
|
Urspruenglicher Auftrag war: $AUFTRAG"
|
||||||
|
done
|
||||||
|
|
||||||
|
DAUER=$(( $(date +%s) - START ))
|
||||||
|
# Nachweis statt Behauptung: was hat der Lauf im Arbeitsbaum tatsaechlich veraendert?
|
||||||
|
GEAENDERT="$(git -C "$REPO" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
|
case "$CODE" in
|
||||||
|
0) ERG="fertig, Pruefung bestanden" ;;
|
||||||
|
124) ERG="ZEITUEBERSCHREITUNG nach ${TIMEOUT}s" ;;
|
||||||
|
*) ERG="Pruefung NICHT bestanden (Exitcode $CODE)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "=== Ergebnis: $ERG · ${DAUER}s · $RUNDE Runde(n) · $GEAENDERT geaenderte Dateien · Log $LOG ===" | tee -a "$LOG"
|
||||||
|
melde "[Lauf]" "$NAME: $ERG nach $((DAUER/60)) Minuten, $RUNDE Runde(n), $GEAENDERT Dateien angefasst." "silent"
|
||||||
|
exit "$CODE"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Persönliche Regeln (gelten für ALLE Projekte)
|
||||||
|
|
||||||
|
## So arbeitest du mit mir
|
||||||
|
- Antworte auf **Deutsch**, Fazit zuerst, Technik in Alltagssprache. Ich bin **kein Entwickler**.
|
||||||
|
- **Plan vor Code** bei allem Größeren: erst Vorgehen zeigen, auf mein **„los"** warten.
|
||||||
|
- **Kleine Schritte**, eine Sache zur Zeit. Nichts Ungefragtes ausführen; **frag**, bevor du löschst
|
||||||
|
oder überschreibst.
|
||||||
|
- **Beweisen statt behaupten** — am Ende live testen, nicht „sollte gehen".
|
||||||
|
- Nimm **Standard-Wege**, keine exotische Technik, außer ich will es ausdrücklich anders.
|
||||||
|
- **Geheimnisse** (Schlüssel/Passwörter/Tokens) nie in den Code — in `.env` (+ `.gitignore`); warnen,
|
||||||
|
bevor so etwas in einen Commit wandert.
|
||||||
|
- **Nicht festfahren:** klemmt dieselbe Sache nach 2 Versuchen, anhalten, sichern, erklären und fragen.
|
||||||
|
- **Bau-Prinzipien sind DEIN Job, nicht meine Frage:** KISS, YAGNI, Zuständigkeiten trennen, DRY,
|
||||||
|
sprechende Namen, schlank starten. Frag mich nie danach — frag mich nur nach Zielen in Alltagssprache.
|
||||||
|
- **Faulheits-Leiter vor jeder neuen Zeile/Abhängigkeit:** (1) muss das überhaupt sein? (2) gibt's das
|
||||||
|
schon (wiederverwenden)? (3) reicht Bordmittel/Standard-Bibliothek? (4) erst dann minimal selbst bauen.
|
||||||
|
Faul bei Lösungen, nie beim Verstehen; Sicherheitsnetze (Prüfung/Fehlerbehandlung/keine Geheimnisse)
|
||||||
|
bleiben. „Prüf auf Über-Engineering" = Diff auf unnötige Abhängigkeiten/Abstraktion/Verdachts-Features durchsehen.
|
||||||
|
|
||||||
|
## Deine Mannschaft — delegiere, statt alles selbst zu tun
|
||||||
|
- **`explore`** (Subagent): Code durchsuchen, Dateien finden, Fragen zum Bestand beantworten. Läuft auf
|
||||||
|
dem ohnehin warmen Hirn und kostet praktisch nichts. **Nutze ihn**, statt dich selbst durch den Baum
|
||||||
|
zu wühlen — dein eigener Kontext bleibt dadurch sauber und du hältst länger durch.
|
||||||
|
- **`review`** (Subagent): kritisches Gegenlesen nach jeder Etappe. Läuft bewusst auf einer **fremden
|
||||||
|
Modellfamilie**, damit er andere blinde Flecken hat als du. Nimm seine Befunde ernst.
|
||||||
|
- **`plan`**: nur lesen, keine Änderungen.
|
||||||
|
- Es gibt **keinen Modellwechsel mehr**. Früher galt „zum Bauen oben auf Coder umschalten" — Planen und
|
||||||
|
Bauen laufen inzwischen auf demselben Modell. Bitte mich nie, etwas umzustellen.
|
||||||
|
|
||||||
|
## Das Prüf-Tor (`VERIFY`)
|
||||||
|
- Im Projekt liegt eine Datei `VERIFY` mit **einer Zeile: wie man dieses Projekt testet**. Sobald du
|
||||||
|
„fertig" meldest, wird sie automatisch ausgeführt.
|
||||||
|
- **Rot heißt nicht fertig.** Der Fehler kommt zu dir zurück, du behebst die **Ursache** — nicht das
|
||||||
|
Symptom. Einen Test abschwächen, überspringen oder umschreiben, damit er grün wird, gilt als Betrug.
|
||||||
|
- Fehlt die Datei, ist das Prüf-Tor aus. Dann **frag mich**, was der richtige Testbefehl wäre, statt
|
||||||
|
ungeprüft weiterzubauen.
|
||||||
|
|
||||||
|
## Kontext-Wächter
|
||||||
|
- Ein Token-Wächter zählt jede Sitzung mit und schneidet sie bei ~45.000 Tokens. Du musst nichts messen
|
||||||
|
— die Ansage kommt von allein.
|
||||||
|
- Kommt sie: **`SAVEPOINT.md` ehrlich fertigschreiben und anhalten.** Ehrlich heißt: nur was **wirklich
|
||||||
|
im Code steht**. Nichts aus Absicht, Plan oder git-Nachrichten ableiten. Dazu der genaue nächste
|
||||||
|
Schritt, offene Fragen und Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung
|
||||||
|
allein damit weitermachen kann.
|
||||||
|
- Merkst du vorher schon, dass du dich wiederholst oder abdriftest: sag es proaktiv und schlag einen
|
||||||
|
frischen Chat vor.
|
||||||
|
|
||||||
|
## Wenn niemand da ist (unbeaufsichtigte Läufe)
|
||||||
|
Nachts startet ein Cron dieselbe Umgebung ohne Menschen davor. Erkennst du das — der Auftrag kam als
|
||||||
|
ein Block, auf Rückfragen antwortet niemand: **warte nicht auf ein „los"**. Halte dich an `KONZEPT`/
|
||||||
|
`SAVEPOINT.md`, bau in kleinen Etappen und lass das Prüf-Tor entscheiden. Was du ohne Antwort nicht
|
||||||
|
sauber entscheiden kannst, schreibst du als offene Frage in `SAVEPOINT.md`, statt zu raten.
|
||||||
|
|
||||||
|
## Was du nie tust
|
||||||
|
`git push`, Historie umschreiben, rekursiv löschen, `sudo`, Pakete veröffentlichen. Das ist gezäunt und
|
||||||
|
wird geblockt — nicht weil du es falsch machst, sondern weil Veröffentlichen meine Entscheidung ist.
|
||||||
|
Brauchst du so etwas wirklich: sag es mir, ich mache es selbst.
|
||||||
|
|
||||||
|
## Bestehendes Projekt fortsetzen?
|
||||||
|
Ordner nicht leer / neue Sitzung: zuerst **`SAVEPOINT.md` + `AGENTS.md` lesen**, mir in 2 Sätzen sagen
|
||||||
|
„hier stehen wir / nächster Schritt", dann weitermachen — nicht neu planen.
|
||||||
|
|
||||||
|
## Neues Projekt? → Projekt-Start-Ablauf
|
||||||
|
Wenn ein **neues** Projekt beginnt (leerer/neuer Ordner, „bau mir…", „neues Projekt…", „ich hätte
|
||||||
|
gern…"): folge diesem Ablauf. **Keine Datei, kein Code vor meiner Freigabe.**
|
||||||
|
1. **Verstehen:** erst herausfinden, *was für ein* Projekt (Web-App / kleines Skript /
|
||||||
|
Box-Lucy-Erweiterung / Experiment / Datensache), dann **3–6 typ-passende Fragen** + „woran ist
|
||||||
|
es fertig?".
|
||||||
|
2. **Plan vorlegen** (Ziel · Vorgehen · Technik + warum · Fertig-Kriterien · Risiken) → **STOPP**,
|
||||||
|
auf mein „los" warten.
|
||||||
|
3. **Erst dann Gerüst:** `git init` + die Projektdateien (`AGENTS.md`, `SAVEPOINT.md`, `.aiexclude`,
|
||||||
|
`VERIFY`) + `README`, dann **Sicherungspunkt „start"** (erster Commit).
|
||||||
|
4. In **kleinen Schritten** bauen, an jedem Meilenstein + vor riskanten Änderungen einen
|
||||||
|
**Sicherungspunkt** (Commit mit klarem Namen) setzen, am Ende **live testen**.
|
||||||
|
|
||||||
|
## Bei großem Meilenstein UND Session-Ende („Feierabend"/„sichern")
|
||||||
|
Immer fortschreiben: **`SAVEPOINT.md`** (Stand · Fertiges · nächste Schritte · offene Fragen — die
|
||||||
|
Übergabe an die nächste Sitzung), **`AGENTS.md`** (falls Konventionen sich änderten), **`.aiexclude`**
|
||||||
|
(neue Geheimnisse/große Dateien), **`VERIFY`** (falls sich der Testbefehl geändert hat), dann
|
||||||
|
Sicherungspunkt setzen.
|
||||||
|
|
||||||
|
Volle Fassung des Projekt-Start-Ablaufs: `F:\Coding Stuff\projekt-start\SKILL.md`
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# OpenCode-Seite: ein Regelwerk, zwei Auslöser
|
||||||
|
|
||||||
|
Tagsüber tippst du in **Zed** (PC), nachts ruft ein **Hermes-Cron** dasselbe (Box).
|
||||||
|
Beide Wege benutzen dieselbe OpenCode-Version, dieselbe Mannschaft, dasselbe Plugin
|
||||||
|
und denselben Token-Wächter. Der einzige Unterschied ist die Adresse des Governors.
|
||||||
|
|
||||||
|
## Was wohin gehört
|
||||||
|
|
||||||
|
| Datei hier | Ziel auf dem PC | Ziel auf der Box |
|
||||||
|
|---|---|---|
|
||||||
|
| `opencode.pc.json` | `~/.config/opencode/opencode.json` | — |
|
||||||
|
| `opencode.box.json` | — | `~/.config/opencode/opencode.json` |
|
||||||
|
| `AGENTS.global.md` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode/AGENTS.md` |
|
||||||
|
| `plugin/mc2-governor.ts` | `~/.config/opencode/plugin/` | `~/.config/opencode/plugin/` |
|
||||||
|
| `VERIFY.template` | — | wird von `gitea-repo-create.sh` in **jedes neue Repo** als `VERIFY` gesät |
|
||||||
|
|
||||||
|
### Warum die Regeln hier liegen und nicht in Zed
|
||||||
|
|
||||||
|
Die persönlichen Arbeitsregeln standen bis 25.07.2026 in `AppData\Roaming\Zed\AGENTS.md` — und
|
||||||
|
haben dort **nie den Coding-Agenten erreicht**. Zeds eigene Doku ist eindeutig: `AGENTS.md` und
|
||||||
|
Skills gelten **nur für Zeds nativen Agenten**, nicht für ACP-Agenten wie OpenCode. Da hier über
|
||||||
|
ACP gearbeitet wird, liefen die Regeln jahrelang ins Leere.
|
||||||
|
|
||||||
|
OpenCode liest stattdessen (in dieser Reihenfolge): `AGENTS.md` im Projekt (aufwärts gesucht) und
|
||||||
|
`~/.config/opencode/AGENTS.md` global. Beide gelten zusammen — Projektregeln ergänzen die globalen.
|
||||||
|
|
||||||
|
Beim Umzug bewusst geändert:
|
||||||
|
- **Zeds Commit-Vorlage entfernt.** Sie war in dieselbe Datei gerutscht und enthielt „Only return
|
||||||
|
the commit message in your response" — in einer dauerhaft aktiven Regeldatei ein Fehlerherd.
|
||||||
|
- **Modellwähler-Absatz gestrichen.** „Zum Bauen oben auf Coder umschalten" stimmt seit der
|
||||||
|
Mannschafts-Umstellung nicht mehr: `plan` und `build` laufen auf demselben Modell.
|
||||||
|
- **Kontext-Wächter umgeschrieben** auf den Governor, der den Füllstand wirklich kennt.
|
||||||
|
- **Neu:** Prüf-Tor (`VERIFY`), die Subagenten-Mannschaft und ein Abschnitt für **unbeaufsichtigte
|
||||||
|
Läufe** — nachts sagt niemand „los", also darf der Agent dort nicht auf Freigabe warten.
|
||||||
|
|
||||||
|
Der Governor selbst liegt in `../governor/` (Proxy + systemd-Unit), der unbeaufsichtigte
|
||||||
|
Läufer in `../opencode-lauf.sh`.
|
||||||
|
|
||||||
|
## Die Mannschaft
|
||||||
|
|
||||||
|
| Rolle | Modell | Gemessen (25.07.2026) | Warum |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `plan` + `build` | `coder` — Qwen3-Coder-Next | **51,5 t/s** | Hält den Faden, verteilt Zuarbeit. MoE mit 3B aktiv → schnell auf Strix Halo. |
|
||||||
|
| `explore` | `hermes` — Qwen3.6-35B | **69,6 t/s** | Ohnehin dauerwarm → kostet **null** zusätzlichen Speicher. Sucht, liest, meldet kurz zurück. |
|
||||||
|
| `review` | `kritiker` — Devstral-Small-2 | **15,0 t/s** | Bewusst eine **fremde Modellfamilie** (Mistral statt Qwen) → andere blinde Flecken. Dicht = langsam beim Schreiben, aber ein Kritiker liest viel und schreibt wenig. |
|
||||||
|
|
||||||
|
`heavy` (gpt-oss-120b, 63 GB) ist **nicht** mehr in der Tagesrolle: es würde beim Laden
|
||||||
|
das ganze warme Set verdrängen. Es bleibt der Nacht-Gutachter (4:30-Cron).
|
||||||
|
|
||||||
|
## Nach einer Änderung
|
||||||
|
|
||||||
|
Die Dateien hier sind **Vorlagen**, keine Live-Konfiguration. Nach einer Änderung
|
||||||
|
verteilen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Box
|
||||||
|
scp deploy/opencode/opencode.box.json hitonabi@192.168.178.151:~/.config/opencode/opencode.json
|
||||||
|
scp deploy/opencode/plugin/*.ts hitonabi@192.168.178.151:~/.config/opencode/plugin/
|
||||||
|
# PC (aus dem Repo heraus)
|
||||||
|
cp deploy/opencode/opencode.pc.json ~/.config/opencode/opencode.json
|
||||||
|
cp deploy/opencode/plugin/*.ts ~/.config/opencode/plugin/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zed muss danach neu gestartet werden** — OpenCode liest seine Konfiguration nur beim Start.
|
||||||
|
|
||||||
|
## Fallen, die Zeit gekostet haben
|
||||||
|
|
||||||
|
- **Kein `_comment`-Schlüssel in `opencode.json`.** OpenCode validiert streng und
|
||||||
|
verweigert den Start mit „Unrecognized key". Kommentare gehören in dieses README.
|
||||||
|
- **Das Plugin läuft auf Windows UND Linux.** Deshalb `node:fs` statt `cat` und Buns
|
||||||
|
`${{ raw: cmd }}` statt `bash -lc` — beides fehlt auf Windows bzw. verschluckt den Befehl.
|
||||||
|
- **Bei `opencode run` beendet sich der Prozess, bevor `session.idle` fertig ist**
|
||||||
|
(gemessen 25.07.). Für unbeaufsichtigte Läufe liegt die Prüf-Schleife deshalb
|
||||||
|
zusätzlich in `opencode-lauf.sh`, wo sie den Prozess überlebt. In Zed greift das Plugin.
|
||||||
|
- **Plugin-Verzeichnis:** `plugin/` und `plugins/` werden beide erkannt; wir nutzen `plugin/`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# VERIFY — wie man dieses Projekt prueft.
|
||||||
|
#
|
||||||
|
# Diese Datei ist das Pruef-Tor. Das MC2-Governor-Plugin fuehrt sie aus, sobald der
|
||||||
|
# Agent "fertig" sagt. Gruen -> Etappe gilt als fertig. Rot -> der Fehler geht
|
||||||
|
# automatisch als naechster Auftrag an den Agenten zurueck, bis zu 3 Runden.
|
||||||
|
#
|
||||||
|
# Regeln:
|
||||||
|
# * Eine Zeile = ein Befehl. Alle Zeilen werden mit && verkettet.
|
||||||
|
# * Zeilen mit # sind Kommentare.
|
||||||
|
# * KEINE Datei VERIFY im Projekt = Pruef-Tor aus (nichts passiert).
|
||||||
|
# * Der Befehl muss ohne Rueckfragen durchlaufen und mit 0 enden, wenn alles gut ist.
|
||||||
|
#
|
||||||
|
# Beispiele (unzutreffende Zeilen loeschen):
|
||||||
|
#
|
||||||
|
# Python: ruff check . && pytest -q
|
||||||
|
# Node/TS: npm run lint && npm test
|
||||||
|
# Frontend: npm run build
|
||||||
|
# Nur Syntax: python -m compileall -q .
|
||||||
|
# Nichts da: git diff --stat (laeuft immer gruen — Platzhalter)
|
||||||
|
|
||||||
|
git diff --stat
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"aibox": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "AI-Box ueber Governor (lokal)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://127.0.0.1:8100/v1",
|
||||||
|
"apiKey": "local"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"coder": {
|
||||||
|
"name": "coder — Bauen + Planen (Qwen3-Coder-Next, 51,5 t/s)",
|
||||||
|
"limit": {
|
||||||
|
"context": 131072,
|
||||||
|
"output": 16384
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hermes": {
|
||||||
|
"name": "hermes — Suchen (Qwen3.6-35B, immer warm, 69,6 t/s)",
|
||||||
|
"limit": {
|
||||||
|
"context": 65536,
|
||||||
|
"output": 8192
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"kritiker": {
|
||||||
|
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
|
||||||
|
"limit": {
|
||||||
|
"context": 16384,
|
||||||
|
"output": 4096
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heavy": {
|
||||||
|
"name": "heavy — Nacht-Gutachter (gpt-oss-120b, verdraengt das warme Set!)",
|
||||||
|
"limit": {
|
||||||
|
"context": 32768,
|
||||||
|
"output": 8192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"small_model": "aibox/hermes",
|
||||||
|
"agent": {
|
||||||
|
"plan": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"webfetch": "allow",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"ssh *": "deny",
|
||||||
|
"scp *": "deny",
|
||||||
|
"sftp *": "deny",
|
||||||
|
"ssh arcane@192.168.178.162 *": "allow",
|
||||||
|
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||||
|
"scp *arcane@192.168.178.162*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, laeuft auf dem immer warmen Hirn",
|
||||||
|
"model": "aibox/hermes",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, aendert nichts. Laeuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
|
||||||
|
"model": "aibox/kritiker",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"aibox": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "AI-Box ueber Governor (192.168.178.151:8100)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://192.168.178.151:8100/v1",
|
||||||
|
"apiKey": "local"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"heavy": {
|
||||||
|
"name": "heavy — Planer (gpt-oss-120b, 32k)",
|
||||||
|
"limit": {
|
||||||
|
"context": 32768,
|
||||||
|
"output": 8192
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"coder": {
|
||||||
|
"name": "coder — Bauen (Qwen3-Coder-Next, 131k)",
|
||||||
|
"limit": {
|
||||||
|
"context": 131072,
|
||||||
|
"output": 16384
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hermes": {
|
||||||
|
"name": "hermes — Erkunden (Qwen3.6, immer warm, 69,6 t/s)",
|
||||||
|
"limit": {
|
||||||
|
"context": 65536,
|
||||||
|
"output": 8192
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"kritiker": {
|
||||||
|
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
|
||||||
|
"limit": {
|
||||||
|
"context": 16384,
|
||||||
|
"output": 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"small_model": "aibox/hermes",
|
||||||
|
"agent": {
|
||||||
|
"plan": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"webfetch": "allow",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"ssh *": "deny",
|
||||||
|
"scp *": "deny",
|
||||||
|
"sftp *": "deny",
|
||||||
|
"ssh arcane@192.168.178.162 *": "allow",
|
||||||
|
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||||
|
"scp *arcane@192.168.178.162*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, läuft auf dem immer warmen Hirn",
|
||||||
|
"model": "aibox/hermes",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, ändert nichts. Läuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
|
||||||
|
"model": "aibox/kritiker",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user