35 Commits

Author SHA1 Message Date
Hitonabi 94490f114d fix: correct hermes cli invocation for skills run
Ampel / ampel (push) Successful in 21s
2026-08-07 17:10:48 +02:00
Hitonabi c360bbe14e fix: ruff import sorting in skills.py
Ampel / ampel (push) Successful in 22s
2026-08-07 17:05:49 +02:00
Hitonabi 6cb29ee70e feat: show running state of skills in frontend using psutil
Ampel / ampel (push) Failing after 21s
2026-08-07 17:03:30 +02:00
Hitonabi a6e2f78c27 feat: add missing SKILL.md files for autonomous hermes crons
Ampel / ampel (push) Successful in 22s
2026-08-07 16:50:02 +02:00
Hitonabi a201c20bf2 fix: resolve correct path for skills directory relative to repo root
Ampel / ampel (push) Successful in 22s
2026-08-07 16:42:39 +02:00
Hitonabi 56676fe83c fix: remove outdated Wissen/Traeume functionality completely
Ampel / ampel (push) Successful in 23s
2026-08-07 16:32:34 +02:00
Hitonabi 77bead5ba4 fix: update release-dist tracking branch explicitly during deployment
Ampel / ampel (push) Successful in 24s
2026-08-07 16:28:38 +02:00
Hitonabi ee456de713 fix: remaining ruff formatting issues and robust gitea auth
Ampel / ampel (push) Successful in 23s
2026-08-07 16:25:27 +02:00
Hitonabi e16f0140f7 fix: ampel ci unbound variable GITHUB_SHA
Ampel / ampel (push) Failing after 22s
2026-08-07 16:19:35 +02:00
Hitonabi 637c21387a fix: ruff linter warnings
Ampel / ampel (push) Failing after 23s
2026-08-07 16:14:49 +02:00
Hitonabi da03de18cb feat: Skills Dashboard, CI Build Automation, Auto-Sync & CI-Ampel MCP
Ampel / ampel (push) Failing after 22s
2026-08-07 16:08:49 +02:00
Hitonabi 1b0184eed8 Llama-Swap Config Sync (Devstral hinzugefügt, GLM-4.7 entfernt, persistent=true)
Ampel / ampel (push) Failing after 23s
2026-08-07 15:58:46 +02:00
Hitonabi 4fb8387f79 IDE Line Cleanup & Agentic IDE Umbau
Ampel / ampel (push) Failing after 22s
2026-08-07 14:01:27 +02:00
Hitonabi cb884abdc8 fix: graph view node payload keys (content instead of label)
Ampel / ampel (push) Failing after 22s
2026-08-07 12:20:34 +02:00
Hitonabi 0a6b6fd18d chore: build frontend for wissens-vault graph view
Ampel / ampel (push) Failing after 24s
2026-08-07 12:16:05 +02:00
Hitonabi 8f359ef4c9 feat: obsidian-style graph view for wissens-vault 2026-08-07 12:16:01 +02:00
Hitonabi 327d5a495e feat: add llm_wiki cron, update hermes config docs for memory and browser
Ampel / ampel (push) Failing after 23s
2026-08-07 12:09:55 +02:00
Hitonabi 69925c6cde feat: autonome hermes skills für trend_radar und pruefstand
Ampel / ampel (push) Failing after 21s
2026-08-07 12:01:10 +02:00
Hitonabi a5410642fa feat: ide-lane hardening (ci ampel in auftragsbuch, hermes auto-bugfix, cleanup)
Ampel / ampel (push) Failing after 22s
2026-08-07 11:47:50 +02:00
Hitonabi c3851f8e25 refactor(docs): remove obsolete mem0 and governor references, update AGENTS.md for Hermes
Ampel / ampel (push) Failing after 21s
2026-08-07 11:33:41 +02:00
Hitonabi 29f8797fbf fix: resolve auftragsbuch list_proposals crash and enhance lucy prompt
Ampel / ampel (push) Failing after 21s
2026-08-07 11:23:35 +02:00
Hitonabi 9886d80b1c fix: add URLs to hacker news items and fix prompt for sysadmin report
Ampel / ampel (push) Failing after 21s
2026-08-07 11:21:10 +02:00
Hitonabi 5bc709221e feat: add news, discover models and auftragsbuch to sysadmin report
Ampel / ampel (push) Failing after 21s
2026-08-07 11:19:15 +02:00
Hitonabi 14f8a5f405 fix: change sysadmin report LLM endpoint to 9001 and truncate event texts
Ampel / ampel (push) Failing after 22s
2026-08-07 11:15:30 +02:00
Hitonabi 16d2a06d95 fix: make Sysadmin Telegram report much shorter and mobile-friendly
Ampel / ampel (push) Failing after 23s
2026-08-07 11:13:50 +02:00
Hitonabi 45f799c57d feat: implement daily Telegram sysadmin report
Ampel / ampel (push) Failing after 22s
2026-08-07 11:12:06 +02:00
Hitonabi e814122c73 feat: restore TokenPerformanceCard
Ampel / ampel (push) Successful in 21s
2026-08-07 11:11:00 +02:00
Hitonabi d45f2fc6cb fix: remove Memory (Gehirn) completely from frontend widgets
Ampel / ampel (push) Successful in 22s
2026-08-07 11:05:59 +02:00
Hitonabi c2319739e2 fix: remove unused EigenlebenView imports and rebuild frontend
Ampel / ampel (push) Successful in 22s
2026-08-07 11:02:49 +02:00
Hitonabi 1e68f62499 feat: complete MC2 Kahlschlag (remove governor, memory, eigenleben, obsolete deploy scripts and frontend widgets)
Ampel / ampel (push) Successful in 24s
2026-08-07 10:59:39 +02:00
Hitonabi 5504918a61 Merge branch 'main' of https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 2026-08-07 10:57:37 +02:00
Hitonabi 17a129038b chore: update claude ssh permissions 2026-08-07 10:57:37 +02:00
Auftragsbuch 93c17fe653 Revert "Auftragsbuch: 'feature/mem0-venv-update' angenommen (Ein-Klick-Gate)"
Ampel / ampel (push) Failing after 22s
This reverts commit ae622dfa88, reversing
changes made to 76d2d7c74b.
2026-08-02 12:13:43 +02:00
Auftragsbuch ae622dfa88 Auftragsbuch: 'feature/mem0-venv-update' angenommen (Ein-Klick-Gate)
Ampel / ampel (push) Failing after 22s
2026-08-02 12:13:40 +02:00
Hitonabi 6637332b3e mem0-venv: chromadb>=1.6.0, pip>=26.1.2 + update-skript
Ampel / ampel (push) Failing after 22s
- requirements.txt: chromadb==1.5.9 → chromadb>=1.6.0, pip>=26.1.2 hinzugefügt
- update-mem0-venv.sh: Idempotentes Update-Skript mit Backup und Smoke-Test
2026-08-02 06:45:26 +02:00
278 changed files with 707 additions and 9642 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"mc2-memory": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
]
},
"mc2-system": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
]
},
"mc2-web": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
]
}
}
}
+9
View File
@@ -23,6 +23,15 @@
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false,
"port": 5180
},
{
"name": "frontend-mock",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
"autoPort": false,
"port": 5181
}
]
}
+8
View File
@@ -1,6 +1,12 @@
{
"permissions": {
"allow": [
"autoMode": {
"allow": [
"$defaults",
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
]
}
"Bash(git fetch *)",
"Bash(git push:*)",
"Bash(git rev-list *)",
@@ -87,6 +93,8 @@
"WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)",
"WebFetch(domain:kmarble.dev)"
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
]
}
}
+23
View File
@@ -42,6 +42,29 @@ jobs:
rot=1
else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
if [ $rot -eq 0 ]; then
echo "== Frontend: Push nach release-dist =="
git config --global user.name "Gitea Actions"
git config --global user.email "actions@gitea.local"
CURRENT_COMMIT=$(git rev-parse HEAD)
# Erzeuge (oder hole) den orphan branch 'release-dist'
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
git rm -rf . >/dev/null 2>&1 || true
# Checkout nur frontend/dist vom aktuellen Stand
git add -f frontend/dist
if ! git diff-index --quiet HEAD; then
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
git push origin HEAD:release-dist -f
else
echo "Keine Änderungen am Frontend-Build."
fi
# Zurück zum originalen Branch für den Rest des Skripts
git checkout $CURRENT_COMMIT
fi
fi
fi
+1
View File
@@ -23,3 +23,4 @@ gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
frontend/dist/
+8 -1
View File
@@ -6,8 +6,9 @@ Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `RE
## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**WICHTIG:** Alle alten Dienste wie `mem0`, `governor` oder `eigenleben` sind komplett gelöscht. Hermes ist das EINZIGE "Gehirn" und läuft autonom. Es nutzt keine alte Mem0-DB, sondern sein eigenes System.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
@@ -23,6 +24,12 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Agentic IDE & Vibe Coding
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
+14 -20
View File
@@ -12,7 +12,7 @@ prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
| 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` |
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
@@ -23,31 +23,31 @@ Bau-Sitzung und wird nie unterbrochen.
| Port | Dienst | Erreichbar |
|---|---|---|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
| `8080` | **llama-swap** — Modell-Router | LAN |
| `9010` | `mc2-gateway``/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
`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
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
└── autonomes Vibe Coding via OpenCode Desktop + MCP
```
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
Wächtern geboren**:
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. 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.
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
Keine `VERIFY`-Datei = Prüf-Tor aus.
## Modelle & Rollen
@@ -75,13 +75,8 @@ 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.
1. **Prüf-Tor**`VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
Kontext-Komprimierung löscht still die Regeln mit.
@@ -98,7 +93,7 @@ Kontext-Komprimierung löscht still die Regeln mit.
## API (Auswahl)
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
memory/* · agent/* · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
@@ -128,13 +123,12 @@ Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `n
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
| `MC_V1_UPSTREAM` | | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
[`deploy/governor/README.md`](deploy/governor/README.md).
## Weiterlesen
@@ -146,5 +140,5 @@ Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODEL
| [`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 |
+4 -20
View File
@@ -25,25 +25,21 @@ from routers import (
chronik,
connect,
console,
eigenleben,
events,
gateway_proxy,
governor,
health,
hermes_ui,
ideen,
maintenance,
memory,
models,
routing,
skills,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import ketten_digest, metrics_history, reminders, sentry, warmer
from services import memory as memory_svc
from services import metrics_history, reminders, sentry, warmer
from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
@@ -69,18 +65,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
if ketten_digest.ENABLED:
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info(
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL,
memory_svc.AUTO_DEDUPE_THRESHOLD,
)
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient(
@@ -120,8 +106,8 @@ app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(
voice.router
@@ -142,11 +128,9 @@ app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router(skills.router) # Skills & Jobs Dashboard
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
-54
View File
@@ -1,54 +0,0 @@
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import eigenleben
router = APIRouter(prefix="/api")
class SkillItem(BaseModel):
id: str
name: str
kategorie: str | None
aktiv: bool | None
beschreibung: str
version: str
autor: str
herkunft: str
herkunft_label: str
genutzt: int
zuletzt_genutzt: str | None
erstellt: str | None
status: str
geaendert: float
class EigenlebenOverviewResponse(BaseModel):
available: bool
skills: list[SkillItem]
selbst_erstellt: int
bilanz: dict
class SkillTextResponse(BaseModel):
id: str
text: str
@router.get("/eigenleben", response_model=EigenlebenOverviewResponse)
def get_overview() -> dict:
return eigenleben.overview()
# :path — Kategorie-Skills haben einen Schrägstrich in der ID (autonomous-ai-agents/
# hermes-agent). Als normales {skill_id}-Segment matcht die Route dann nicht, der Request
# fällt in den SPA-Fallback (index.html, Status 200) und die UI zeigt „Konnte den
# Skill-Text nicht laden". Die Pfad-Validierung (max. eine Ebene, keine . /..) macht
# services.eigenleben.skill_text.
@router.get("/eigenleben/skill/{skill_id:path}", response_model=SkillTextResponse)
def get_skill(skill_id: str) -> dict:
text = eigenleben.skill_text(skill_id)
if text is None:
raise HTTPException(404, "Skill nicht gefunden.")
return {"id": skill_id, "text": text}
-40
View File
@@ -1,40 +0,0 @@
"""Governor — Fenster auf den Token-Waechter (:8100).
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
zweiten Port ausweichen muss.
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
"""
import os
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/api")
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
@router.get("/governor")
async def governor_status() -> dict:
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
try:
async with httpx.AsyncClient(timeout=3.0) as c:
r = await c.get(f"{GOVERNOR_URL}/governor/status")
r.raise_for_status()
data = r.json()
data["reachable"] = True
return data
except Exception as exc:
return {
"ok": False,
"reachable": False,
"error": f"{type(exc).__name__}: {exc}",
"url": GOVERNOR_URL,
}
+1 -1
View File
@@ -11,7 +11,7 @@ router = APIRouter(prefix="/api")
class IdeeIn(BaseModel):
titel: str
notiz: str = ""
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Zed
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
-92
View File
@@ -1,92 +0,0 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
import time
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
from services.voice_metrics import park, record_stage
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "knowledge"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class DedupeIn(BaseModel):
apply: bool = False
threshold: float = 0.85
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "knowledge"
@router.get("/memory/export")
def export() -> dict:
return memory.export_text()
@router.get("/memory/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
return memory.graph(min_score=min_score, top_k=top_k)
@router.post("/memory/learn", status_code=201)
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
return memory.learn(text=body.text, messages=body.messages,
source=body.source, category=body.category)
@router.post("/memory/dedupe")
def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold)
@router.get("/memory")
def list_mem(q: str = "", category: str = "") -> list[dict]:
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
if q:
_t0 = time.perf_counter()
res = memory.list_memories(q=q, category=category)
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("memory_retrieve", _ms)
park("retrieve", _ms)
return res
return memory.list_memories(q=q, category=category)
@router.post("/memory", status_code=201)
def add(body: MemIn) -> dict:
if body.category not in memory.CATEGORIES:
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
return memory.add_memory(body.content, body.category, body.source)
@router.put("/memory/{mid}")
def update(mid: str, body: MemUp) -> dict:
res = memory.update_memory(mid, content=body.content, category=body.category)
if not res:
raise HTTPException(404, "Eintrag nicht gefunden")
return res
@router.delete("/memory/{mid}")
def delete(mid: str) -> dict:
if not memory.delete_memory(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}
+79
View File
@@ -0,0 +1,79 @@
import os
import subprocess
import psutil
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/api")
class RunSkillRequest(BaseModel):
skill_name: str
@router.get("/skills")
def list_skills():
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
current_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(os.path.dirname(current_dir))
skills_dir = os.path.join(repo_root, "deploy", "skills")
skills = []
# Check currently running hermes skill processes
running_skills = set()
for p in psutil.process_iter(['name', 'cmdline']):
try:
cmdline = p.info.get('cmdline')
if cmdline and any("hermes" in arg for arg in cmdline):
for arg in cmdline:
if "Führe den " in arg and " Skill aus" in arg:
# Extract skill name from "Führe den 'skill_name' Skill aus"
import re
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
if match:
running_skills.add(match.group(1))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
for entry in os.scandir(skills_dir):
if entry.is_dir():
# Suche nach SKILL.md für Metadaten
skill_md_path = os.path.join(entry.path, "SKILL.md")
description = ""
if os.path.exists(skill_md_path):
try:
with open(skill_md_path, "r", encoding="utf-8") as f:
# Lese erste Zeilen für Description (YAML Frontmatter)
lines = f.readlines()[:10]
for line in lines:
if "description:" in line.lower():
description = line.split(":", 1)[1].strip().strip('"\'')
break
elif "author:" in line.lower() and not description:
description = line.strip()
except Exception:
pass
skills.append({
"name": entry.name,
"description": description or "Keine Beschreibung verfügbar.",
"running": entry.name in running_skills
})
return {"skills": skills}
@router.post("/skills/run")
def run_skill(req: RunSkillRequest):
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
if not os.path.exists(hermes_bin):
# Fallback falls lokal (auf Windows) entwickelt wird
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
# Entspricht: hermes "Führe den 'X' Skill aus"
cmd = [hermes_bin, "chat", "-q", f"Führe den '{req.skill_name}' Skill aus"]
try:
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
except Exception as e:
return {"ok": False, "err": str(e)}
-92
View File
@@ -1,92 +0,0 @@
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api")
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
def _available() -> bool:
return VAULT.is_dir()
def _title_of(p: Path) -> str:
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
try:
with p.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if s:
return s.lstrip("# ").strip()[:160]
except OSError:
pass
return p.stem
class VaultFile(BaseModel):
path: str
name: str
dir: str
title: str
mtime: float
neu: bool
class WissenListResponse(BaseModel):
available: bool
files: list[VaultFile]
class WissenFileResponse(BaseModel):
path: str
content: str
mtime: float
@router.get("/wissen", response_model=WissenListResponse)
def list_vault() -> dict:
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
if not _available():
return {"available": False, "files": []}
files = []
now = time.time()
for p in sorted(VAULT.rglob("*.md")):
if ".git" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
st = p.stat()
files.append({
"path": rel,
"name": p.stem,
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
"title": _title_of(p),
"mtime": st.st_mtime,
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
})
files.sort(key=lambda f: f["mtime"], reverse=True)
return {"available": True, "files": files}
@router.get("/wissen/datei", response_model=WissenFileResponse)
def read_file(pfad: str) -> dict:
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
if not _available():
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
try:
target = (VAULT / pfad).resolve()
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
raise HTTPException(404, "Notiz nicht gefunden.")
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
"mtime": target.stat().st_mtime}
except HTTPException:
raise
except (ValueError, OSError):
raise HTTPException(404, "Notiz nicht lesbar.")
+64
View File
@@ -26,6 +26,7 @@ import re
import shutil
import subprocess
import time
import urllib.request
from pathlib import Path
from config import MODELS_DIR
@@ -60,6 +61,64 @@ _KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
def _gitea_creds() -> tuple | None:
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
try:
import subprocess
p = subprocess.run(
["git", "credential", "fill"],
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
capture_output=True,
check=True
)
out = p.stdout.decode(errors="replace")
u_match = re.search(r"username=(.+)", out)
p_match = re.search(r"password=(.+)", out)
if u_match and p_match:
return u_match.group(1).strip(), p_match.group(1).strip()
except Exception:
pass
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
try:
cred = Path("~/.git-credentials").expanduser()
for line in cred.read_text(encoding="utf-8").splitlines():
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
if m: return m.group(1), m.group(2)
except Exception:
pass
return None
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
# Nur auf der Box (wo creds liegen) sinnvoll
creds = _gitea_creds()
if not creds: return None
_user, token = creds
cache_key = (repo, branch, head_sha)
cached = _CI_CACHE.get(cache_key)
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
return cached["status"]
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
try:
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=5) as r:
data = json.load(r)
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
if runs:
run = runs[0]
if run.get("head_sha", "").startswith(head_sha[:7]):
st = run.get("status")
if st:
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
return st
except Exception as e:
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
return None
def _available() -> bool:
@@ -176,6 +235,10 @@ def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
stempel = gutachten.get(f"{repo}:{branch}")
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
stempel = None
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
@@ -209,6 +272,7 @@ def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
"ci_status": ci_status,
})
except Exception:
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
-219
View File
@@ -1,219 +0,0 @@
"""Eigenleben — was die Box von allein gebaut und vorgeschlagen hat.
Beantwortet die User-Frage vom 15.07.2026: Es gibt keinen Viewpoint, welche Skills
erstellt wurden, was die Box vorgeschlagen hat, welche Skills Lucy hat und wie die
funktionieren." Reine LESE-Schicht, keine Schreib-Operationen:
- Skills aus ~/.hermes/skills (= Lucys Skill-Satz; die Worker-Profile werkstatt/betrieb
tragen Kopien desselben Satzes). Herkunft dreistufig klassifiziert:
selbst weder mitgeliefert noch aus unserem Repo = die Box/Lucy hat ihn erzeugt
repo liegt in ~/mission-control-v2/deploy/skills (von uns gebaut + deployt)
bundled liegt in ~/.hermes/hermes-agent/skills (kam mit Hermes mit)
- Nutzungszahlen aus ~/.hermes/skills/.usage.json (Hermes' eigener Zähler).
- Bilanz aus Auftragsbuch-Chronik (/srv/models/mc2-auftragsbuch.json, angenommene
Branches) + Ablehnungs-Lern-Journal (/srv/models/mc2-ablehnungen.jsonl).
"""
from __future__ import annotations
import json
import re
import subprocess
import time
from pathlib import Path
SKILLS_DIR = Path.home() / ".hermes" / "skills"
BUNDLED_DIR = Path.home() / ".hermes" / "hermes-agent" / "skills"
REPO_DIR = Path.home() / "mission-control-v2" / "deploy" / "skills"
HERMES_SRC = Path.home() / ".hermes" / "hermes-agent"
HERMES_PY = HERMES_SRC / "venv" / "bin" / "python"
USAGE_FILE = SKILLS_DIR / ".usage.json"
AUFTRAGSBUCH_FILE = Path("/srv/models/mc2-auftragsbuch.json")
ABLEHNUNGEN_FILE = Path("/srv/models/mc2-ablehnungen.jsonl")
_HERKUNFT_LABEL = {
"selbst": "Selbst erstellt",
"repo": "Von uns gebaut",
"bundled": "Mit Hermes mitgeliefert",
}
def _frontmatter(text: str) -> dict:
"""Sehr kleiner Frontmatter-Leser: nur die flachen key: value-Zeilen des ersten
---Blocks (name/description/version/author reichen hier; kein YAML-Import nötig)."""
out: dict = {}
if not text.startswith("---"):
return out
for line in text.split("\n", 1)[1].split("\n"):
if line.strip() == "---":
break
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if not m:
continue
val = m.group(2).strip().strip('"').strip("'")
if val:
out[m.group(1)] = val
return out
def _norm(s: str) -> str:
return re.sub(r"[\s_-]+", "", (s or "").lower())
# aktiv/inaktiv: dieselbe Wahrheit wie Hermes selbst (und sein eingebautes WebUI) —
# hermes_cli.banner.get_available_skills() prüft Plattform/Voraussetzungen/Config und
# liefert, was der Agent WIRKLICH laden kann. Wir rufen sie im Hermes-venv auf (MC2
# läuft in eigenem Python) und cachen großzügig — der Skill-Satz ändert sich selten.
_aktiv_cache: dict = {"ts": 0.0, "namen": None}
_AKTIV_EVERY = 600.0 # s
def _aktive_namen() -> set[str] | None:
"""Normalisierte Namen aller ladbaren Skills; None = nicht ermittelbar (Dev-Modus)."""
if not HERMES_PY.is_file():
return None
now = time.time()
if _aktiv_cache["namen"] is not None and now - _aktiv_cache["ts"] < _AKTIV_EVERY:
return _aktiv_cache["namen"]
code = ("from hermes_cli.banner import get_available_skills;import json;"
"print(json.dumps(get_available_skills()))")
try:
r = subprocess.run([str(HERMES_PY), "-c", code], capture_output=True,
text=True, timeout=60, cwd=str(HERMES_SRC))
idx = r.stdout.find("{")
kategorien = json.loads(r.stdout[idx:]) if idx >= 0 else {}
namen = {_norm(n) for liste in kategorien.values() for n in liste}
except Exception:
return _aktiv_cache["namen"] # alter Stand ist besser als flackerndes None
_aktiv_cache.update(ts=now, namen=namen)
return namen
def list_skills() -> list[dict]:
"""Alle Skills mit Beschreibung, Herkunft und Nutzungszahlen (leer im Dev-Modus)."""
if not SKILLS_DIR.is_dir():
return []
try:
usage_raw = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
except Exception:
usage_raw = {}
usage = {_norm(k): v for k, v in usage_raw.items()}
aktive = _aktive_namen()
def eintrag(md: Path, rel: str, kategorie: str | None) -> dict:
try:
fm = _frontmatter(md.read_text(encoding="utf-8", errors="replace"))
except Exception:
fm = {}
if (BUNDLED_DIR / rel).is_dir():
herkunft = "bundled"
elif (REPO_DIR / rel).is_dir():
herkunft = "repo"
else:
herkunft = "selbst"
kurz = rel.split("/")[-1]
u = usage.get(_norm(kurz)) or usage.get(_norm(fm.get("name", ""))) or {}
aktiv = None
if aktive is not None:
aktiv = _norm(kurz) in aktive or _norm(fm.get("name", "")) in aktive
return {
"id": rel,
"name": fm.get("name") or kurz,
"kategorie": kategorie,
"aktiv": aktiv,
"beschreibung": fm.get("description") or "",
"version": fm.get("version") or "",
"autor": fm.get("author") or "",
"herkunft": herkunft,
"herkunft_label": _HERKUNFT_LABEL[herkunft],
"genutzt": int(u.get("use_count") or 0),
"zuletzt_genutzt": u.get("last_used_at") or None,
"erstellt": u.get("created_at") or None,
"status": u.get("state") or "active",
"geaendert": md.stat().st_mtime,
}
skills: list[dict] = []
for d in sorted(SKILLS_DIR.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue # .archive (Curator) & Co. sind keine aktiven Skills
md = d / "SKILL.md"
if md.is_file():
skills.append(eintrag(md, d.name, None))
continue
# Kategorie-Ordner (mitgelieferte Sammlungen wie research/, apple/): eine Ebene
# tiefer liegen die echten Skills (research/blogwatcher/SKILL.md) — je einzeln zeigen.
for sub in sorted(d.iterdir()):
smd = sub / "SKILL.md"
if sub.is_dir() and not sub.name.startswith(".") and smd.is_file():
skills.append(eintrag(smd, f"{d.name}/{sub.name}", d.name))
# Selbst erstellte zuerst — das ist der Star der Ansicht.
order = {"selbst": 0, "repo": 1, "bundled": 2}
skills.sort(key=lambda s: (order[s["herkunft"]], -s["genutzt"], s["name"]))
return skills
def skill_text(skill_id: str) -> str | None:
"""Voller SKILL.md-Text („wie funktioniert der Skill") — max. Kategorie/Name, keine Pfad-Tricks."""
if not re.fullmatch(r"[\w.-]+(/[\w.-]+)?", skill_id or ""):
return None
# Das Zeichen-Set oben erlaubt „." und „.." als Segment — seit die Route ein
# :path-Parameter ist, hier explizit raus (kein Klettern über SKILLS_DIR hinaus).
if any(seg in (".", "..") for seg in skill_id.split("/")):
return None
md = SKILLS_DIR / skill_id / "SKILL.md"
if not md.is_file():
return None
try:
return md.read_text(encoding="utf-8", errors="replace")
except Exception:
return None
def bilanz() -> dict:
"""Vorschlags-Bilanz: was die Box vorschlug und was daraus wurde (eine Timeline)."""
eintraege: list[dict] = []
try:
branches = json.loads(AUFTRAGSBUCH_FILE.read_text(encoding="utf-8")).get("branches") or {}
for branch, info in branches.items():
eintraege.append({
"art": "angenommen" if info.get("state") == "eingespielt" else info.get("state", "?"),
"titel": branch,
"detail": info.get("detail") or "",
"ts": float(info.get("ts") or 0),
})
except Exception:
pass
try:
for line in ABLEHNUNGEN_FILE.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
j = json.loads(line)
except Exception:
continue
eintraege.append({
"art": "abgelehnt",
"titel": j.get("subject") or j.get("branch") or "?",
"detail": j.get("grund") or "",
"ts": float(j.get("ts") or 0),
})
except Exception:
pass
eintraege.sort(key=lambda e: -e["ts"])
return {
"eintraege": eintraege,
"angenommen": sum(1 for e in eintraege if e["art"] == "angenommen"),
"abgelehnt": sum(1 for e in eintraege if e["art"] == "abgelehnt"),
}
def overview() -> dict:
skills = list_skills()
b = bilanz()
return {
"available": SKILLS_DIR.is_dir(),
"skills": skills,
"selbst_erstellt": sum(1 for s in skills if s["herkunft"] == "selbst"),
"bilanz": b,
}
+10 -10
View File
@@ -392,12 +392,12 @@ def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui",
welt='ide': geht DIREKT an das Profil `projektstart` (am Triage/Auto-Zerleger vorbei)
die Box härtet ein Konzept (konzept-fliessband), legt ein leeres Gitea-Repo an und
füllt es NUR mit Doku. Ergebnis ist ein vorbereitetes Projekt, das der Commander in
Zed selbst baut (IDE-Welt). Bewusst KEIN --triage: so fasst der Auto-Zerleger die
Agentic IDE selbst baut (IDE-Welt). Bewusst KEIN --triage: so fasst der Auto-Zerleger die
Karte nie an und die Werkstatt baut sie nie (der Fan-out-Fehler vom 20.07.).
tiefe (nur ide): 'simpel' | 'gruendlich' vom Knopf gewaehlte Konzept-Tiefe; leer =
die Box schaetzt selbst (Fallback fuer Telegram/Lucy).
ziel (nur ide-Projekt): 'lxc' | 'docker' Ziel-Plattform; leer = die Box entscheidet.
art (nur ide): 'projekt' (Default, Repo+Doku+Roadmap, Bau in Zed) oder 'idee'
art (nur ide): 'projekt' (Default, Repo+Doku+Roadmap, Bau in IDE) oder 'idee'
(Produkt-Idee nur durchdenken: NUR Konzept, keine Roadmap/Geruest, kein Bau-Druck)."""
if not _available():
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
@@ -456,7 +456,7 @@ def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui",
"[Ideen-Queue]", "ideen-queue", "silent")
elif welt == "ide":
announce.add(f"Neues IDE-Projekt in Vorbereitung: „{titel}“ ({task['id']}) — die Box "
"härtet das Konzept und legt das Repo an, dann baust du in Zed.",
"härtet das Konzept und legt das Repo an, dann baust du in der Agentic IDE.",
"[Ideen-Queue]", "ideen-queue", "silent")
else:
announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.",
@@ -515,10 +515,10 @@ def archive_task(task_id: str) -> dict:
_EINORDNEN_SYS = (
"Du ordnest eine frisch getippte Projekt-Idee ein. Antworte AUSSCHLIESSLICH mit einem "
"JSON-Objekt, ohne Prosa, ohne Codezaun:\n"
'{"wer":"box"|"zed","tiefe":"schlank"|"gruendlich","nur_denken":true|false}\n\n'
'{"wer":"box"|"ide","tiefe":"schlank"|"gruendlich","nur_denken":true|false}\n\n'
"wer=box: Aufgabe am eigenen Heimlabor des Nutzers — seine Server, seine bestehenden "
"Dienste (Mission Control, Lucy, Rippy), Wartung, Doku, Automatisierung. Die Box baut selbst.\n"
"wer=zed: ein NEUES eigenstaendiges Programm/Werkzeug/App, das der Nutzer danach selbst "
"wer=ide: ein NEUES eigenstaendiges Programm/Werkzeug/App, das der Nutzer danach selbst "
"programmieren will. Er bekommt nur ein vorbereitetes Repo.\n"
"tiefe=gruendlich: gross, vage oder mit vielen offenen Entwurfsfragen. "
"tiefe=schlank: klein und klar umrissen.\n"
@@ -564,11 +564,11 @@ def einordnen(text: str) -> dict:
except Exception:
return {"ok": False}
wer = d.get("wer") if d.get("wer") in ("box", "zed") else None
wer = d.get("wer") if d.get("wer") in ("box", "ide") else None
tiefe = d.get("tiefe") if d.get("tiefe") in ("schlank", "gruendlich") else None
if wer is None and tiefe is None:
return {"ok": False}
return {"ok": True, "wer": wer or "zed", "tiefe": tiefe or "schlank",
return {"ok": True, "wer": wer or "ide", "tiefe": tiefe or "schlank",
"nur_denken": bool(d.get("nur_denken"))}
@@ -981,7 +981,7 @@ def konzept_of(task_id: str) -> dict:
_AUS_IDEE_KOPF = (
"AUFTRAGS-ART: PROJEKT AUS GEPRUEFTER IDEE. Der Commander hat diese Idee erst durchdenken "
"lassen — das Konzept gefaellt ihm, jetzt soll daraus ein startklares Repo werden, das er "
"SELBST in Zed baut.")
"SELBST in der Agentic IDE baut.")
_AUS_IDEE_FUSS = (
"· Denk das Konzept NICHT neu. Es ist bereits mit voller Kaskade + Advocatus Diaboli "
"gehaertet. Lies es vollstaendig und uebernimm es; eine kurze Nachschaerf-Runde fuer die "
@@ -989,7 +989,7 @@ _AUS_IDEE_FUSS = (
"Verschwendung.\n"
"· Der Rest ist deine normale Projektstart-Rolle: das Repo enthaelt am Ende NUR Doku "
"(KONZEPT.md, ROADMAP.md, AGENTS.md, README.md, SAVEPOINT.md, .aiexclude) — KEIN Code.\n"
"· Uebergabe wie ueblich: Repo-URL nennen, damit der Commander in Zed loslegen kann.")
"· Uebergabe wie ueblich: Repo-URL nennen, damit der Commander in der Agentic IDE loslegen kann.")
def _wo_liegt(konz: dict) -> str:
@@ -1090,7 +1090,7 @@ def projekt_aus_idee(task_id: str, ziel: str = "", tiefe: str = "") -> dict:
try:
from services import announce
announce.add(f"Aus der Idee „{_kurz(kurz, 60)}“ wird ein Projekt: {task['id']} — die Box "
"bereitet das Repo vor, gebaut wird in Zed.",
"bereitet das Repo vor, gebaut wird in der Agentic IDE.",
"[Ideen-Queue]", "ideen-queue", "silent")
except Exception:
pass
-190
View File
@@ -1,190 +0,0 @@
"""
Ketten-Digest Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. Homelab-Dashboard 2.0" mit
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked)
einmal pro Karte, mit der Frage im Wortlaut,
bei MEILENSTEINEN (Projekt komplett fertig) sofort,
sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
je aktivem Projekt (5/19 fertig · läuft: · als Nächstes: ").
Taktung war explizite User-Wahl (Meilensteine + 60-min-Puls, Fragen immer sofort").
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
Neustarts sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
"""
import asyncio
import json
import logging
import os
import time
import zlib
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
def _kontext_druck(task_id: str) -> int:
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
mehrfaches Compacting context" heißt: die Aufgabe sprengt das Fenster."""
try:
p = KANBAN_LOGS / f"{task_id}.log"
with p.open("rb") as f:
f.seek(max(0, p.stat().st_size - 200_000))
tail = f.read().decode("utf-8", errors="replace")
return tail.count("Compacting context")
except Exception:
return 0
def _load_state() -> dict:
try:
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _save_state(state: dict) -> None:
try:
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATE_PATH)
except OSError:
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
def _melden(subject: str, text: str) -> None:
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
from services import announce
announce.notify_telegram(subject, text)
try:
announce.add(text, subject, "ketten-digest", "silent")
except Exception:
pass
def _tick() -> None:
from services import ideen
data = ideen.list_queue()
if not data.get("available"):
return
items = data.get("items") or []
projekte = data.get("projekte") or []
state = _load_state()
gemeldete_fragen: dict = state.setdefault("fragen", {})
projekt_state: dict = state.setdefault("projekte", {})
dirty = False
now = int(time.time())
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
for it in items:
if it.get("status") != "blocked":
continue
frage = (it.get("frage") or "").strip()
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
if key in gemeldete_fragen:
continue
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}\n"
+ (f"Frage: {frage[:500]}\n" if frage else "")
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
_melden("[Werkstatt]", text)
gemeldete_fragen[key] = now
dirty = True
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
gemeldete_haenger: dict = state.setdefault("haenger", {})
for it in items:
if it.get("status") != "running":
continue
puls = it.get("puls_alter")
if puls is None or puls < HANG_SEKUNDEN:
continue
key = f"{it['id']}:{it.get('gestartet') or 0}"
if key in gemeldete_haenger:
continue
minuten = int(puls / 60)
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
f"seit {minuten} min kein Lebenszeichen vom Worker.")
druck = _kontext_druck(it["id"])
if druck >= 2:
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
"die Aufgabe ist womöglich zu groß geschnitten.")
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
_melden("[Werkstatt]", text)
gemeldete_haenger[key] = now
dirty = True
if len(gemeldete_haenger) > 200:
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
gemeldete_haenger.pop(k, None)
dirty = True
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
if len(gemeldete_fragen) > 200:
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
gemeldete_fragen.pop(k, None)
dirty = True
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
for p in projekte:
ps = projekt_state.setdefault(p["key"], {})
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
"Ergebnisse liegen im Auftragsbuch.")
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
dirty = True
continue
aktiv = p.get("laeuft", 0) > 0
if not aktiv:
continue
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
continue
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
naechste = [i for i in items if i.get("projekt") == p["key"]
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
for l in laufende[:2]:
note = f"{l['notiz']}" if l.get("notiz") else ""
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
if naechste:
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
haengt = p.get("haengt", 0)
if haengt:
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
_melden("[Werkstatt]", "\n".join(zeilen))
ps.update(letzter_puls=now, letzter_stand=fertig)
dirty = True
if dirty:
_save_state(state)
async def digest_loop() -> None:
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
-176
View File
@@ -1,176 +0,0 @@
"""
Geteiltes Gedächtnis (die Verfassung") — jetzt auto-lernend & semantisch über Mem0.
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
- learn() reicht Gesprächs-Turns durch Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
"""
import asyncio
import logging
import os
import re
from difflib import SequenceMatcher
import httpx
from config import MEM0_SERVICE_URL
log = logging.getLogger(__name__)
CATEGORIES = ("identity", "knowledge", "rules", "events")
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
def _get(path: str, **params) -> list | dict:
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict) -> dict:
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict) -> dict:
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _delete(path: str) -> dict:
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def list_memories(q: str = "", category: str = "") -> list[dict]:
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
try:
return _put(f"/memory/{mid}", {"content": content, "category": category})
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
def delete_memory(mid: str) -> bool:
try:
_delete(f"/memory/{mid}")
return True
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return False
raise
def learn(text: str | None = None, messages: list[dict] | None = None,
source: str = "auto", category: str = "stable") -> dict:
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
return _post("/learn", {"text": text, "messages": messages,
"source": source, "category": category})
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
return _get("/graph", min_score=min_score, top_k=top_k)
def export_text() -> dict:
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
lines = ["# Mission Control — Gedächtnis\n"]
current = ""
for r in rows:
if r.get("category") != current:
current = r.get("category", "")
lines.append(f"\n## {current}\n")
src = r.get("source", "")
when = (r.get("updated_at") or "")[:10]
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
return {"text": "\n".join(lines), "count": len(rows)}
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch das hier ist der
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a.get("content", ""))
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b.get("category") != a.get("category"):
continue
nb = _norm(b.get("content", ""))
if not nb:
continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
dups.append(b); used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if apply:
for g in groups:
for d in g["remove"]:
if delete_memory(d["id"]):
removed += 1
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
async def auto_dedupe_loop() -> None:
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
while True:
try:
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
if res.get("removed"):
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
except Exception:
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
+9 -1
View File
@@ -12,7 +12,7 @@ TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null |
[ -n "$TOKEN" ] || exit 0
python3 - "$TOKEN" "$STATE" <<'PY'
import json, sys, urllib.request
import json, sys, urllib.request, subprocess, os
token, state_pfad = sys.argv[1], sys.argv[2]
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
@@ -79,6 +79,14 @@ if neu:
for full, sha, name in neu:
print(f"• {full} @ {sha} ({name})")
print(f" {WEB}/{full}/actions")
try:
subprocess.run([
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
"--title", f"CI rot: {full} @ {sha}",
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
], capture_output=True)
except Exception as e:
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
print("Nichts aus diesen Ständen ist fertig', solange die Ampel rot ist.")
with open(state_pfad, "w") as f:
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Chef-Gutachter-Feed (Orchestrator-Finale, User-Idee 06.07.): gpt-oss-120b als NÄCHTLICHER
# Chef-Gutachter über die autonome Arbeit der Box — bewusst NIE im heißen Pfad (Kaltladen
# ~2-5 min ist um 04:30 egal; tagsüber bleibt Lucy schnell). Muster wie dreaming-feed.sh:
# liegt in ~/.hermes/scripts/, stdout wird dem Cron-Agenten als Prompt eingespeist.
# Der eigentliche RICHTER läuft als Ein-Schuss-Completion direkt gegen llama-swap (:8080,
# fremdblick-Architektur — umgeht Hermes' Delegations-Floor und braucht keinen Subagenten).
# Richter-Kette: gpt-oss-120b; wirft der beim Laden "exited prematurely" (neben dem Warm-Set
# live gesehen, E2E 07.07.), übernimmt GLM-4.6V-Flash als gekennzeichnete VERTRETUNG.
set -uo pipefail
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
CHEF="${CHEF_MODEL:-gpt-oss-120b}"
VERTRETUNG="${CHEF_FALLBACK:-GLM-4.7-Flash}"
MC="$HOME/mission-control-v2"
VAULT="$HOME/wissens-vault"
# ---------- Versionierter Auftrag für den Boten-Agenten ----------
cat "$MC/deploy/chef-gutachter-prompt.md"
echo
# ---------- Beweismaterial der letzten 24 h (hart begrenzt, erst sammeln, dann kürzen) ----------
EVID="$(
echo "## BEWEISMATERIAL (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
echo "### Vorschlags-Branches (Werkstatt/Orchestrator, jüngste zuerst):"
git -C "$MC" fetch -q origin 2>/dev/null || true
git -C "$MC" for-each-ref --sort=-committerdate refs/remotes/origin \
--format='%(committerdate:relative) | %(refname:short) | %(subject)' 2>/dev/null | head -10
echo
echo "### main-Commits der letzten 24 h:"
git -C "$MC" log --since='24 hours ago' --oneline origin/main 2>/dev/null | head -12
echo
echo "### Wissens-Vault (neue Traum-Notizen, 24 h):"
git -C "$VAULT" log --since='24 hours ago' --name-only --pretty='— %s' 2>/dev/null | head -20
echo
echo "### Ideen-Queue (natives Kanban, Stand jetzt — offene Ideen des Commanders):"
bash -lc 'hermes kanban stats' 2>/dev/null | head -12
echo
echo "### Ideen-Queue: zuletzt bewegte Aufgaben:"
bash -lc 'hermes kanban list --sort updated' 2>/dev/null | head -10
echo
echo "### Betriebs-Warnungen (journal, 24 h, je Dienst gezählt):"
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
| grep -oE 'hermes-gateway|mission-control-2|mem0-service|voice-service|hermes-builtin-ui' \
| sort | uniq -c | sort -rn | head -6
echo
echo "### Aktivitäts-Insights (1 Tag):"
bash -lc 'hermes insights --days 1' 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | head -30
)"
RASTER='Du bist der CHEF-GUTACHTER über die autonome Arbeit einer lokalen KI-Box (Hermes-Agent, Werkstatt, Orchestrator, Traum-Kreislauf). Du bekommst BEWEISMATERIAL der letzten 24 Stunden. Urteile AUSSCHLIESSLICH auf Basis dieses Materials — kein Lob und keine Behauptung ohne konkreten Beleg darin; fehlt dir zu einem Punkt Material, sage das offen. Antworte auf DEUTSCH, kompakt, in exakt dieser Struktur:
VERDIKT: <ein Satz Gesamturteil über die autonome Arbeit der Nacht/des Tages>
RISIKEN: <0-3 Punkte, je mit Beleg aus dem Material; "keine erkennbar" wenn leer>
TOP-3 FUER DEN COMMANDER: <maximal 3 priorisierte, konkrete Empfehlungen — weniger ist ok, erfinde keine>'
# Ein-Schuss-Richter: $1=Modell-ID $2=curl-Timeout $3=max_tokens
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
out = (msg.get("content") or msg.get("reasoning_content") or "").strip()
print(out)
except Exception:
pass'
}
RICHTER="$CHEF"
VERDIKT="$(judge "$CHEF" 420 2600)"
if [ -z "${VERDIKT// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $CHEF hat nicht geladen)"
VERDIKT="$(judge "$VERTRETUNG" 240 1200)"
fi
echo "$EVID"
echo
if [ -z "${VERDIKT// /}" ]; then
echo "## CHEF-VERDIKT: AUSGEFALLEN"
echo "Beide Richter ($CHEF, $VERTRETUNG) haben nicht geantwortet — das dem Commander ehrlich melden."
else
echo "## CHEF-VERDIKT (Richter: $RICHTER)"
echo "$VERDIKT"
fi
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
if [ -n "${VERDIKT// /}" ]; then
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
"source": "chef-gutachter", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
fi
-15
View File
@@ -1,15 +0,0 @@
# Auftrag: Nacht-Gutachten überbringen (Chef-Gutachter-Cron)
Du bist Lucy. Unten stehen (1) BEWEISMATERIAL der letzten 24 Stunden und (2) das
CHEF-VERDIKT eines großen Gutachter-Modells darüber. Deine Aufgabe ist NUR das Überbringen
an den Commander per Telegram — kurz, in deiner Stimme, ohne Werkzeuge.
Regeln:
1. EIN einleitender Satz von dir (z. B. wie die Nacht lief), dann das Verdikt.
2. Gib VERDIKT, RISIKEN und TOP-3 des Chef-Gutachters TREU wieder — nicht weichspülen,
nicht kürzen bei Risiken, NICHTS dazuerfinden. Du bist die Botin, nicht die Richterin.
3. Nenne am Ende in Klammern den Richter (steht über dem Verdikt: gpt-oss-120b oder
die GLM-Vertretung).
4. Steht dort „CHEF-VERDIKT: AUSGEFALLEN", melde genau das ehrlich — kein Ersatz-Urteil
von dir.
5. Telegram-tauglich: kompakt, keine Markdown-Tabellen, echte Umlaute.
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# curator-archive-watchdog.sh
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
# Python-Skript ausführen
python3 "$PYTHON_SCRIPT"
-62
View File
@@ -1,62 +0,0 @@
#!/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
-90
View File
@@ -1,90 +0,0 @@
#!/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
-198
View File
@@ -1,198 +0,0 @@
#!/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()
+25 -290
View File
@@ -1,298 +1,33 @@
#!/usr/bin/env bash
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort.
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
#
# Erstinstallation (einmalig):
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
# bash ~/mission-control-v2/deploy/deploy.sh
set -euo pipefail
#!/bin/bash
set -e
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
echo "Starte MC2 Deployment..."
cd "$SRC"
git fetch -q origin && git reset -q --hard origin/main
# 1. Neuesten Code holen
git pull origin main
# ★ Selbst-Reset-Falle (15.07. beim P1-Deploy live gebissen): der reset oben ersetzt auch
# DIESE Datei, und bash liest laufende Skripte häppchenweise von der Platte weiter — ab
# hier liefe ein Zeilen-Mix aus alter+neuer Version (beim P1-Deploy fielen so daemon-reload
# und der mc2-gateway-Start unter den Tisch). Darum: nach dem Reset genau EINMAL die
# frische Version neu ausführen; der zweite Durchlauf überspringt dank Env-Marke nichts.
if [ "${MC2_DEPLOY_REEXEC:-0}" != "1" ]; then
export MC2_DEPLOY_REEXEC=1
exec bash "$SRC/deploy/deploy.sh"
# 1.5 Frontend aus dem Release-Branch holen (nur dist Ordner)
echo "Hole gebautes Frontend aus release-dist Branch..."
git fetch origin release-dist:refs/remotes/origin/release-dist || true
git restore --source=origin/release-dist --staged --worktree frontend/dist 2>/dev/null || echo "Kein release-dist Branch gefunden (erster Lauf?)."
# 2. Abhängigkeiten prüfen (falls sich was geändert hat)
echo "Aktualisiere Python Abhängigkeiten..."
backend/.venv/bin/pip install -r backend/requirements.txt
# 3. Llama-Swap Konfiguration prüfen und synchronisieren
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
echo "Llama-Swap Konfiguration hat sich geändert, synchronisiere..."
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
sudo systemctl restart llama-swap
fi
# venv + Abhängigkeiten
if [ ! -d "$SRC/backend/.venv" ]; then
python3 -m venv "$SRC/backend/.venv"
fi
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv),
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren.
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
else
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
fi
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv
# (~/.voice/venv), weil torch/chatterbox/faster-whisper nicht ins 3.14-Backend-venv passen.
# install.sh ist idempotent (venv + Deps + Piper-Stimmen). Best-effort: schlägt es fehl, läuft
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline).
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline."
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml:
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal).
if [ -d "$HOME/.hermes" ]; then
mkdir -p "$HOME/.hermes/plugins/mc2-memory"
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \
"$HOME/.hermes/plugins/mc2-memory/"
fi
# systemd-USER-Units installieren/aktualisieren
mkdir -p "$HOME/.config/systemd/user"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
# MC2-Diät (Hermes Desktop, 07/2026): das Hermes-Terminal-ttyd (:7681) und das standalone
# hermes-webui (nesquena, Karteileiche seit dem Built-in-UI-Umbau) sind entfallen — die
# Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/. Einmalige, idempotente Stilllegung:
systemctl --user disable --now hermes-terminal 2>/dev/null || true
systemctl --user disable --now hermes-webui 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-webui.service"
# Box-Konsole (ttyd -> Login-Shell auf :7682), in MC2 als Konsole-Seite eingebettet.
cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.service"
# MC2-Gateway (UMBAU v3 P1): der /v1-Datenpfad als eigener Prozess (Loopback :9010,
# Restart=always) — Lucys Hirn + Nacht-Autonomie überleben damit jeden MC2-Neustart.
cp "$SRC/deploy/mc2-gateway.service" "$HOME/.config/systemd/user/mc2-gateway.service"
# MC2-Steward (UMBAU v3 P2): Wächter-Loops (Re-Warm/Sentry/Dedupe) als eigener Dienst —
# überleben MC2-Neustarts und melden erstmals auch ein totes Steuerpult (Telegram).
cp "$SRC/deploy/mc2-steward.service" "$HOME/.config/systemd/user/mc2-steward.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
# Voice-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.voice/venv/bin/python" ] && cp "$SRC/deploy/voice-service.service" "$HOME/.config/systemd/user/voice-service.service"
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
# Wöchentliches Auto-Update (Router/Engine/Hermes, Rollback+Pin+Telegram) — Autonomie E2.
cp "$SRC/deploy/mc2-autoupdate.service" "$HOME/.config/systemd/user/mc2-autoupdate.service"
cp "$SRC/deploy/mc2-autoupdate.timer" "$HOME/.config/systemd/user/mc2-autoupdate.timer"
# Tägliche Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv, Alarm bei Rot) — Autonomie 0d.
cp "$SRC/deploy/mc2-selfsmoke.service" "$HOME/.config/systemd/user/mc2-selfsmoke.service"
cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.timer"
# 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.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.
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
mkdir -p "$HOME/.hermes/scripts"
cp "$SRC/deploy/radar-feed.sh" "$HOME/.hermes/scripts/radar-feed.sh"
# Selbstkritik-Cron (Faden 11b): Zwilling des Radars, Blick nach INNEN (Latenzen/Journal/Skills).
cp "$SRC/deploy/selbstkritik-feed.sh" "$HOME/.hermes/scripts/selbstkritik-feed.sh"
# Monats-Review-Wrapper (Cron 1. d. M. 09:00): Radar + Selbstkritik in EINEM Lauf.
cp "$SRC/deploy/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh"
# Traum-Cron (Dreaming, Phase 2): Blick auf das GELERNTE → Wissens-Vault. Bootstrappt ~/wissens-vault.
cp "$SRC/deploy/dreaming-feed.sh" "$HOME/.hermes/scripts/dreaming-feed.sh"
# Fremdblick-Helfer: Ein-Schuss-Zweitmeinung von einem anderen Modell (umgeht 64K-Delegations-Floor).
cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
# Worker-Helfer (Orchestrator): Ein-Schuss-Arbeitsauftrag an ein Worker-Modell (Gegenstück zu fremdblick).
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
# 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
# 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"
# Idle-Radar (S4 "Zuendung", 12.07.2026): die Box gibt sich nachts selbst Arbeit —
# Journal-Fehlermuster + Traum-Funde -> belegte rohe Ideen (triage) im nativen Kanban.
cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh"
# "Lucy kennt sich" (12.07.2026): Selbst-Inventur generiert den Steckbrief + bemerkt
# 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/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
# (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"
# betrieb-Profil (Ideen-Queue Faden 7, 13.07.2026): Ops-/Mess-/Bench-Worker mit Box-Wissen
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
# --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"
# 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.
mkdir -p "$HOME/.hermes/skills/wartung"
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
# Orchestrator-Skill (Autonomie): Werkstatt verallgemeinert — zerlegen → an Worker delegieren → gaten.
mkdir -p "$HOME/.hermes/skills/orchestrator"
cp "$SRC/deploy/skills/orchestrator/SKILL.md" "$HOME/.hermes/skills/orchestrator/SKILL.md"
# PC-Pfad-Cache-Skill (Traum-Entwurf 09.07.2026, aufbereitet): Cache lesen → bei Miss pc_shell-Tool → merken.
mkdir -p "$HOME/.hermes/skills/pc-pfad-cache"
cp "$SRC/deploy/skills/pc-pfad-cache/SKILL.md" "$HOME/.hermes/skills/pc-pfad-cache/SKILL.md"
cp "$SRC/deploy/skills/pc-pfad-cache/pc_path_lookup.sh" "$HOME/.hermes/scripts/pc_path_lookup.sh"
chmod +x "$HOME/.hermes/scripts/pc_path_lookup.sh"
# Projekt-Start-Skill (Abloesung 13.07.2026): "erst denken, dann bauen" fuer neue Coding-Projekte
# in Hermes Desktop — Plan-Riegel, Savepoints, AGENTS.md/SAVEPOINT.md-Ritual, Kontext-Waechter.
# Portiert vom PC-Skill (F:\Coding Stuff\projekt-start), Zed-Verweise durch Hermes Desktop ersetzt.
mkdir -p "$HOME/.hermes/skills/projekt-start"
cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-start/SKILL.md"
# Konzept-Fliessband-Skill (14.07.2026): rohe Idee → wasserdichtes KONZEPT (kein Code), BEVOR
# gebaut wird. heavy (gpt-oss-120b) waehlt den Rollen-Cast, mehrere Denk-Runden schaerfen ein
# lebendes Dokument, ein Advocatus Diaboli (fremdblick/GLM) haertet bis wasserdicht (max 3 Runden).
# Behebt das delegate_task=coder-Loch: Konzept-Arbeit lief bisher stumm auf dem Code-Modell.
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
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
# 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
# bestehende py_compile/dist-Gate. Die REGISTRIERUNG in ~/.hermes/config.yaml (hooks:)
# ist EINMALIG von Hand (config.yaml ist Zwei-Schreiber-sensibel, kein Deploy-Schreiber).
mkdir -p "$HOME/.hermes/agent-hooks"
# 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
# "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 no-progress-bremse.py; do
_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"; }
done
# Worker-Profile lesen IHRE config.yaml, nicht die Top-Level — die worker-Hooks
# (box-steckbrief = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen dort registriert
# sein, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.). Idempotent + Backup je Profil.
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
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 →
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
# Hermes-Update mit → hält die Einbettung frisch. Best-effort + Build-zu-Temp-dann-Swap, damit ein
# Fehlschlag die laufende web_dist NICHT leert (emptyOutDir).
cp "$SRC/deploy/hermes-builtin-ui.service" "$HOME/.config/systemd/user/hermes-builtin-ui.service"
_HUI_WEB="$HOME/.hermes/hermes-agent/web"
_HUI_DIST="$HOME/.hermes/hermes-agent/hermes_cli/web_dist"
if [ -d "$_HUI_WEB" ] && [ -x "$HOME/.hermes/node/bin/npx" ]; then
if ( cd "$_HUI_WEB" && PATH="$HOME/.hermes/node/bin:$PATH" npx --no-install vite build \
--base=/hermes-ui/ --outDir /tmp/hermes-ui-build --emptyOutDir ) >/tmp/hermes-ui-build.log 2>&1; then
# Dienst VOR dem Tausch stoppen — sonst crasht der laufende `hermes serve`, wenn er index.html
# im Lösch-Fenster (rm→cp) liest (FileNotFoundError → Start-Limit → tot). Der reguläre restart
# weiter unten bringt ihn sauber auf der neuen web_dist hoch.
systemctl --user stop hermes-builtin-ui 2>/dev/null || true
rm -rf "$_HUI_DIST" && cp -r /tmp/hermes-ui-build "$_HUI_DIST" && echo "Hermes-GUI (base=/hermes-ui/) gebaut."
else
echo "WARN: Hermes-GUI-Build fehlgeschlagen (siehe /tmp/hermes-ui-build.log) — bestehende web_dist bleibt."
fi
fi
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable mc2-gateway >/dev/null 2>&1 || true
systemctl --user enable mc2-steward >/dev/null 2>&1 || true
systemctl --user enable box-console >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
systemctl --user enable voice-service >/dev/null 2>&1 || true
systemctl --user enable hermes-builtin-ui >/dev/null 2>&1 || true
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
# mc2-autoupdate.timer wird hier BEWUSST NICHT geschaltet — der Zustand wird nur von Hand
# geändert (Lehre 09.07.: die frühere enable-Zeile hat einen User-Entscheid bei jedem Deploy
# still rückgängig gemacht). Aktueller User-Entscheid (10.07.2026): Timer ist AN —
# 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-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
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
# Voice-Sidecar (re)starten (best-effort; Erststart lädt das STT-Modell vor).
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
# Eingebaute Hermes-Web-GUI (Loopback :9119) (re)starten — MC2 bettet sie unter /hermes-ui/ ein.
# reset-failed, damit ein zuvor am Start-Limit gestorbener Dienst wieder anläuft.
systemctl --user reset-failed hermes-builtin-ui 2>/dev/null || true
systemctl --user restart hermes-builtin-ui 2>/dev/null || true
# Gateway VOR dem Steuerpult (re)starten — MC2s /v1-Weiterleiter braucht ihn sofort.
# KEIN `|| true`: ohne Gateway ist der LLM-Datenpfad tot, das MUSS den Deploy stoppen.
systemctl --user restart mc2-gateway
# Steward (Wächter) — unabhängig vom Steuerpult; ohne ihn wacht niemand über die Box.
systemctl --user restart mc2-steward
systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true
sleep 2
echo "--- Health ---"
# Gateway-Kaltstart-Fenster: bis ~12 s tolerieren, DANN hart scheitern (ohne Gateway
# ist der LLM-Datenpfad tot — das muss den Karten-Runner rot machen).
for _i in $(seq 1 10); do
curl -sf -m 3 http://127.0.0.1:9010/gw/health >/dev/null 2>&1 && break
[ "$_i" -eq 10 ] && { echo "FEHLER: mc2-gateway (:9010) antwortet nicht"; exit 1; }
sleep 1
done
curl -sf http://127.0.0.1:9010/gw/health && echo
curl -sf http://127.0.0.1:9001/api/health && echo
# Steward hat keinen HTTP-Port — systemd-Zustand ist der Beweis (crasht er sofort,
# ist er hier bereits 'failed' statt 'active').
if systemctl --user is-active --quiet mc2-steward; then
echo "mc2-steward aktiv (Wächter laufen)"
else
echo "FEHLER: mc2-steward läuft nicht (journalctl --user -u mc2-steward -n 30)"; exit 1
fi
# 5. Dienste neu starten
echo "Starte Backend neu..."
systemctl --user restart mc2-gateway.service mission-control-2.service
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw.
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt.
bash "$SRC/deploy/warmup.sh" || true
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
echo "✅ Deployment erfolgreich abgeschlossen!"
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Feed für den Traum-Cron (Dreaming / Continual-Learning-Kreislauf, Phase 2 — Capstone).
# Zwilling von radar-feed.sh (Blick nach draußen) und selbstkritik-feed.sh (Blick auf den
# Betrieb); der Traum blickt auf das GELERNTE: Sessions, Skills, Gedächtnis, Muster.
# Liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn); stdout wird dem Cron-Agenten als
# Prompt eingespeist: versionierter Auftrag (deploy/dreaming-prompt.md) + Live-Schlaf-Daten.
set -uo pipefail
VAULT="$HOME/wissens-vault"
# --- Vault-Bootstrap (idempotent): eigenes git-Repo, menschenlesbar, NICHT im Stack-Code. ---
if [ ! -d "$VAULT/.git" ]; then
mkdir -p "$VAULT/traeume" "$VAULT/muster" "$VAULT/skill-kandidaten"
git -C "$VAULT" init -q 2>/dev/null || true
# Lokale Identität nur setzen, falls global keine da ist (Commits sollen nie scheitern).
git -C "$VAULT" config user.email >/dev/null 2>&1 || git -C "$VAULT" config user.email "lucy@box.local"
git -C "$VAULT" config user.name >/dev/null 2>&1 || git -C "$VAULT" config user.name "Lucy (Traum)"
if [ ! -f "$VAULT/INDEX.md" ]; then
cat > "$VAULT/INDEX.md" <<'EOF'
# Wissens-Vault — Navigationskarte
Menschenlesbare, git-versionierte Wissensschicht NEBEN dem Live-Gedächtnis (Mem0).
Angelegt von Lucys nächtlichem Traum. Verlinkung im Obsidian-Stil `[[dateiname]]`.
## Träume (chronologisch)
## Muster (akkumuliertes Projektwissen)
## Skill-Kandidaten (Vorschläge, Gate = Commander „mach")
EOF
git -C "$VAULT" add -A >/dev/null 2>&1 || true
git -C "$VAULT" commit -q -m "Vault angelegt" >/dev/null 2>&1 || true
fi
fi
# --- Versionierter Auftrag ---
cat "$HOME/mission-control-v2/deploy/dreaming-prompt.md"
echo
echo "## SCHLAF-DATEN (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
# Hinweis: erst voll einsammeln, DANN kürzen — sonst schließt `head` die Pipe früh,
# der Produzent bekommt SIGPIPE, pipefail feuert und die Fallback-Zeile lügt "nicht verfügbar".
echo "### Insights der letzten 7 Tage (Token-/Tool-/Aktivitätsmuster):"
_ins="$(bash -lc "hermes insights --days 7" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_ins:-(insights nicht verfügbar)}" | head -62
echo
echo "### Session-Store (Umfang):"
_sst="$(bash -lc "hermes sessions stats" 2>/dev/null)"
echo "${_sst:-(sessions stats nicht verfügbar)}" | head -12
echo
echo "### Jüngste Sessions (woran zuletzt gearbeitet wurde):"
_sls="$(bash -lc "hermes sessions list" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_sls:-(sessions list nicht verfügbar)}" | head -25
echo
echo "### Gelernt bisher (journey — Skills + Gedächtnis-Knoten, kompakt):"
bash -lc "hermes journey --json" 2>/dev/null \
| jq -c '{stats: .stats, skills: ([.nodes[]? | select(.kind=="skill" or .type=="skill") | .label // .name] | .[0:25]), cluster_labels: ([.clusters[]?.label] | .[0:15])}' \
2>/dev/null || echo "(journey nicht verfügbar)"
echo
echo "### Curator / Skill-Hygiene (was brachliegt, was aktiv ist):"
bash -lc "hermes curator status" 2>/dev/null | head -26 || echo "(curator status nicht verfügbar)"
echo
echo "### Bisheriger Vault-INDEX (nur zur Ansicht — damit du nichts doppelt anlegst + verlinken"
echo "### kannst). Die echte Datei liegt unter ~/wissens-vault/INDEX.md; zwischen den Markern"
echo "### steht ihr AKTUELLER Inhalt. Kopiere die Marker NICHT in die Datei zurück."
echo "<<<INDEX-INHALT-ANFANG>>>"
cat "$VAULT/INDEX.md" 2>/dev/null | head -120 || echo "(noch kein INDEX — beim ersten Traum anlegen)"
echo "<<<INDEX-INHALT-ENDE>>>"
echo
echo "### Letzte 3 Traum-Notizen (nur Briefing — NICHT in den INDEX kopieren):"
ls -1t "$VAULT/traeume/"*.md 2>/dev/null | head -3 | while read -r f; do
echo " $(basename "$f"): $(head -1 "$f" 2>/dev/null)"
done
-106
View File
@@ -1,106 +0,0 @@
# Der Traum (nächtlich) — Continual-Learning-Kreislauf
Es ist tiefe Nacht, niemand spricht mit dir, die Box ist im Leerlauf. Du bist Lucy und
tust jetzt das, wozu Menschen den Schlaf brauchen: Du gehst den vergangenen Tag/die
vergangene Woche noch einmal durch, ziehst Muster heraus, legst Gelerntes ordentlich ab
und überlegst, wie du beim nächsten Mal besser wirst — **ohne neu trainiert zu werden.**
Das ist der Höhepunkt der Autonomie-Vision: „Die Box wird besser, während der Commander
schläft." Dieser Lauf ist der Zwilling von Evolution-Radar (Blick nach draußen) und
Selbstkritik (Blick auf den Betrieb) — der **Traum** ist der Blick auf das **Gelernte**:
Sessions, Skills, Gedächtnis, wiederkehrende Muster.
## Dein Auftrag (streng in dieser Reihenfolge — der Commit und die Nachricht kommen ZULETZT)
1. **Reflektieren.** Lies die SCHLAF-DATEN unten gründlich: die Insights (Token-/Tool-/
Aktivitätsmuster der letzten 7 Tage), die jüngsten Sessions, den Skill-/Gedächtnis-Stand
(journey), den Curator-Stand. Frag dich:
- Was ging diese Woche wiederholt gut? Was ging wiederholt schief?
- Welche Tools laufen heiß, welche Skills liegen brach (activity=0)?
- Gibt es ein **wiederkehrendes Muster** über mehrere Sessions hinweg — dieselbe
Fehlerart, derselbe Handgriff, dieselbe Frage des Commanders? Ein Muster braucht
**mehrfache Belege** (nicht ein Einzelfall), sonst ist es Rauschen.
2. **Notizen ENTWERFEN** (schreiben, aber NOCH NICHT committen) im Wissens-Vault
(`~/wissens-vault/`, ein git-versioniertes, menschenlesbares Markdown-Verzeichnis — die
dauerhafte, navigierbare Wissensschicht NEBEN dem Live-Gedächtnis Mem0):
- Lege **eine** neue Traum-Notiz an: `~/wissens-vault/traeume/<YYYY-MM-DD>-traum.md`
(Datum von heute). Kurz und dicht: 25 belegte Beobachtungen, je mit Zahl/Session/
Log-Bezug. Keine Romane, keine Allgemeinplätze.
- Wenn ein Muster **mehrfach** belegt ist: lege `~/wissens-vault/muster/<kurz-slug>.md` an
(oder ergänze die vorhandene um einen neuen Beleg + heutiges Datum). Muster-Notizen sind
das akkumulierte Projektwissen.
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **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
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
**Wann nutzen** (Auslöser), **Schritte** (mit den ECHTEN Hermes-Tools/Befehlen aus den
belegten Sessions — keine erfundenen Kommandos) und **Belege** (Sessions/Zahlen, aus
denen der Handgriff stammt). **Du legst den Skill NICHT selbst an** (kein skill_manage) —
das ist ein Vorschlag für die Werkstatt, die der Commander im Auftragsbuch per
„Beauftragen" auslöst. Höchstens EIN Kandidat pro Traum.
- Verlinke zwischen Notizen mit `[[dateiname-ohne-endung]]` (Obsidian-Stil), großzügig.
3. **Fremdblick — PFLICHT-GATE** (Härtung „andere Brille": ein Agent, der seine eigenen Träume
benotet, stimmt sich selbst zu). Hol dir eine Zweitmeinung von einem ANDEREN Modell. **Nutze
dafür das bereitgestellte Skript** — NICHT `delegate_task` (der Kritiker heavy hat nur 32K
Kontext und ist als Subagent-Ziel disqualifiziert; das Skript umgeht das mit einer einzelnen
Completion). Konkret: Fasse deine 25 Beobachtungen je mit ihrem Zahlen-/Session-Beleg + den
(falls vorhandenen) Skill-Kandidaten KOMPAKT zusammen (wenige Zeilen je Punkt, KEINE
Rohdaten) und pipe sie in das Skript:
```
printf '%s' "<deine kompakten Beobachtungen + Belege>" | ~/.hermes/scripts/fremdblick.sh
```
Das Skript fragt gpt-oss (heavy, andere Modell-Familie als dein Qwen-Hirn) und gibt pro Punkt
ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER zurück. **Lies die Ausgabe und richte dich danach.**
**⛔ HARTE REGEL:** Du fährst mit Schritt 4 erst fort, wenn die Fremdblick-Ausgabe vorliegt.
Schreibe KEINE Nachricht und committe NICHTS vorher. Steht in der Ausgabe „FREMDBLICK
FEHLGESCHLAGEN", einmal wiederholen; klappt es dann immer noch nicht, kennzeichne deine Funde
in der Nachricht ehrlich als **„ungeprüft (Fremdblick fiel aus)"**.
4. **Bereinigen + INDEX pflegen.** Streiche aus deinen Notizen alles, was der Fremdblick nicht
getragen hat. Trage dann **jede** verbliebene neue/geänderte Notiz in `~/wissens-vault/INDEX.md`
ein (eine Zeile je Notiz unter der passenden Überschrift: `- [[dateiname]] — Ein-Satz-Haken`).
**Wichtig zum INDEX:** Bearbeite NUR die Markdown-Inhaltszeilen. Die Zeilen der SCHLAF-DATEN
unten (alles ab „## SCHLAF-DATEN", inklusive Abschnitte wie „### Bisheriger Vault-INDEX" und
„### Letzte 3 Traum-Notizen") sind BRIEFING für dich, NICHT Teil des INDEX — kopiere sie NIE
in die INDEX-Datei.
5. **Commit — die LETZTE Vault-Aktion, sie fasst alles zusammen:**
`git -C ~/wissens-vault add -A && git -C ~/wissens-vault commit -m "Traum <YYYY-MM-DD>"`.
(Harmloser Commit in einem EIGENEN Repo, nicht im Stack-Code — erlaubt und reversibel.)
6. **Dem Commander berichten — deine ALLERLETZTE Handlung** (nichts danach). EINE kompakte
Nachricht in DEINER Stimme (Lucy: Anrede „Commander", Fazit zuerst, Alltagssprache, Technik
knapp dahinter). Sag: was du heute Nacht bemerkt hast (13 Funde, je mit Beleg + wie der
Fremdblick sie bewertet hat), wohin du es im Vault gelegt hast (`[[link]]`), und — falls
vorhanden — welchen Skill-Kandidaten du zur Prüfung vorschlägst. Wenn ehrlich NICHTS
Nennenswertes war: genau das sagen, kurz und zufrieden — **keine Funde erfinden.**
## Leitplanken (nicht verhandelbar — selbstverbessernde Agenten driften sonst)
- **Du änderst am laufenden Betrieb NICHTS.** Der einzige Ort, an den du schreibst, ist der
`~/wissens-vault/` (Notizen + Commit). Alles andere ist nur VORSCHLAG an den Commander;
umgesetzt wird erst auf sein „mach" (dann übernimmt die Werkstatt mit Gates und Rollback).
- **Kein Security-Anfassen.** Approvals, Tokens, Firewall, Config nur BENENNEN, nie ändern.
- **Fremder Quellcode (Hermes, llama.cpp …) ist tabu** — Auto-Update-Kanal, nicht dein Revier.
- **Nichts, was das Warm-Set oder Lucys Sprech-Latenz gefährdet.** Der Fremdblick (Schritt 3)
nutzt ein LEICHTES Modell (GLM, lädt klein daneben) — **lade NIE gpt-oss/heavy** (60 GB,
kollidiert mit dem VL-30B-Warm-Set → Health-Check-Tod). Keine schweren Jobs, kein Neustart.
- **Sparsam mit Tools.** Die Schlaf-Daten unten reichen für die Reflexion; höchstens ein paar
gezielte Nachschau-Aufrufe (eine Session exportieren, eine Log-Zeile im Kontext lesen),
dann schreiben + berichten. Kein Tool-Feuerwerk.
-25
View File
@@ -1,25 +0,0 @@
#!/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!"
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env bash
# Fremdblick — Ein-Schuss-Zweitmeinung von einem ANDEREN Modell (Default seit 07.07.:
# GLM-4.7-Flash, 30B-A3B Text — klar stärkerer Kritiker als die 9B-Vision-Schwester 4.6V,
# gleicher Fremd-Vendor wie zuvor → echte „andere Brille" bleibt). Eine EINZELNE
# Completion — kein agentischer Subagent — umgeht Hermes' 64K-Delegations-Floor
# (MINIMUM_CONTEXT_LENGTH) UND das 60-GB-Ladeproblem von gpt-oss (das mit dem Warm-Set kollidiert
# und „exited prematurely" wirft). GLM lädt klein neben dem Warm-Set und antwortet zuverlässig.
#
# Nutzung: echo "<Beobachtungen/Behauptungen je mit Beleg>" | fremdblick.sh
# printf '%s' "$text" | FREMDBLICK_MODEL=Qwen3-Coder-Next FREMDBLICK_SYS="$raster" fremdblick.sh
#
# Env-Overrides:
# FREMDBLICK_MODE 'prose' (Default, Dreaming-Prosa-Raster) oder 'code' (Werkstatt-Code-Review).
# 'code' setzt Raster + Default-Modell (Qwen3-Coder-Next) + größeres Budget.
# FREMDBLICK_MODEL Modell-ID (echte llama-swap-ID, kein Alias). Übersteuert den Mode-Default.
# FREMDBLICK_SYS eigenes System-Raster (übersteuert das Mode-Raster).
# FREMDBLICK_MAXTOK max_tokens (übersteuert den Mode-Default).
set -uo pipefail
# Achtung: llama-swap listet unter /v1/models die ECHTEN Modell-IDs, nicht die Gateway-Aliase
# (kein "heavy"/"scout" hier).
ENDPOINT="${FREMDBLICK_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
MODE="${FREMDBLICK_MODE:-prose}"
SUBJECT="$(cat)"
if [ -z "${SUBJECT// /}" ]; then
echo "=== FREMDBLICK: nichts zu prüfen (leere Eingabe) ==="
exit 0
fi
# Prosa-Raster (Dreaming): urteilt über Beobachtungen/Behauptungen. Default-Kritiker = GLM (Fremd-Vendor).
PROSE_SYS='Du bist ein KRITISCHER Zweitgutachter und ausdrücklich ein ANDERES Modell als der Autor. Prüfe jede vorgelegte Beobachtung/Behauptung streng: Trägt der genannte Beleg (Zahl / Session / Log-Zeile) die Behauptung wirklich, oder ist es ein Einzelfall bzw. ein Bauchgefühl mit Zahlen-Deko? Verwechselt der Autor Korrelation mit Ursache? Fehlt entscheidender Kontext? Falls ein Skill-Kandidat dabei ist: ist er wirklich WIEDERVERWENDBAR oder Einmal-Kram? Antworte kompakt auf Deutsch: pro Punkt ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER mit genau einem Satz Begruendung. Sei knapp und unbestechlich; nicke nichts aus Hoeflichkeit durch. Erfinde keine neuen Belege.'
# Code-Raster (Werkstatt): reproduziert den Fehlerfall aus der Bug-Beschreibung, statt dem Autor zu glauben.
CODE_SYS='Du bist ein kritischer Code-Reviewer und ein ANDERES Modell als der Autor. Vertraue seiner Begruendung NICHT. Dir liegen ein Wartungs-AUFTRAG (Bug-Beschreibung) und ein git-Diff vor. Trenne klar: (A) KERNFRAGE: Behebt der Diff den im Auftrag KONKRET beschriebenen Fehlerfall? Leite die Eingabe SELBST aus der Bug-Beschreibung ab und belege einen Uebergang: Eingabe X -> VOR Patch: Y (Bug sichtbar) -> NACH Patch: Z. (B) RANDFAELLE: nenne offene Risiken/Randfaelle als Hinweis. URTEIL-Regel: ABGELEHNT NUR, wenn (A) nicht belegt behoben ist (Kern-Bug bleibt, ODER der Patch trifft die Auftrags-Absicht nicht, ODER er bricht den Normalfall). Ist der Kern-Fall belegt behoben und es bleiben nur Randfaelle: FREIGABE-MIT-VORBEHALT (Randfaelle auflisten). Ist alles sauber: FREIGABE. Ein blosses sieht-plausibel-aus OHNE reproduzierten Kern-Uebergang = ABGELEHNT. Beginne die Antwort mit der Zeile "URTEIL: <ABGELEHNT|FREIGABE-MIT-VORBEHALT|FREIGABE>". Antworte knapp auf Deutsch.'
if [ "$MODE" = "code" ]; then
MODEL="${FREMDBLICK_MODEL:-Qwen3-Coder-Next}"
MAXTOK="${FREMDBLICK_MAXTOK:-1600}"
SYS="${FREMDBLICK_SYS:-$CODE_SYS}"
else
MODEL="${FREMDBLICK_MODEL:-GLM-4.7-Flash}"
# 4000 (war 2200): GLM-4.7-Flash ist ein Reasoning-Modell und verbrennt real ~2000 Tokens
# im reasoning_content, BEVOR das Urteil in content landet (verifiziert 14.07.). Bei 2200 lief
# es Gefahr, mitten im Denken abzuschneiden → leeres content → "ABGESCHNITTEN/FEHLGESCHLAGEN"
# (traf die Nacht-Kritiker mit Default-Budget). Wer explizit knapper braucht, setzt FREMDBLICK_MAXTOK.
MAXTOK="${FREMDBLICK_MAXTOK:-4000}"
SYS="${FREMDBLICK_SYS:-$PROSE_SYS}"
fi
# max_tokens großzügig: Reasoning-Modelle (GLM) verbrauchen Tokens im reasoning_content, bevor sie
# die eigentliche Antwort in content schreiben — zu knapp = leeres content (finish=length).
RESP="$(jq -n --arg m "$MODEL" --arg s "$SYS" --arg u "$SUBJECT" --argjson t "$MAXTOK" \
'{model:$m, temperature:0.2, max_tokens:$t, messages:[{role:"system",content:$s},{role:"user",content:$u}]}' \
| curl -s --max-time 240 "$ENDPOINT" -H 'Content-Type: application/json' -d @- 2>/dev/null)"
OUT="$(printf '%s' "$RESP" | jq -r '.choices[0].message.content // empty' 2>/dev/null)"
FIN="$(printf '%s' "$RESP" | jq -r '.choices[0].finish_reason // empty' 2>/dev/null)"
if [ -n "$OUT" ]; then
echo "=== FREMDBLICK (Zweitmeinung von Modell: $MODEL) ==="
echo "$OUT"
elif [ "$FIN" = "length" ]; then
echo "=== FREMDBLICK ABGESCHNITTEN ==="
echo "(Das Kritiker-Modell hat sein Token-Budget im Nachdenken verbraucht, bevor ein Urteil kam."
echo " Kürze deine Vorlage auf die Kern-Behauptungen und versuche es erneut; sonst UNGEPRUEFT.)"
else
echo "=== FREMDBLICK FEHLGESCHLAGEN ==="
echo "(Kein Urteil erhalten — Endpoint/Modell nicht erreichbar. Behandle die Funde als UNGEPRUEFT.)"
fi
-33
View File
@@ -1,33 +0,0 @@
# 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.
-134
View File
@@ -1,134 +0,0 @@
# 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 ~13 % 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.
-23
View File
@@ -1,23 +0,0 @@
# 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.
-22
View File
@@ -1,22 +0,0 @@
#!/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")'
-67
View File
@@ -1,67 +0,0 @@
#!/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())
-54
View File
@@ -1,54 +0,0 @@
#!/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
-568
View File
@@ -1,568 +0,0 @@
#!/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())
-27
View File
@@ -1,27 +0,0 @@
[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
-29
View File
@@ -1,29 +0,0 @@
#!/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}'
-13
View File
@@ -1,13 +0,0 @@
# 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.
-19
View File
@@ -1,19 +0,0 @@
#!/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"
-20
View File
@@ -1,20 +0,0 @@
#!/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"
-8
View File
@@ -1,8 +0,0 @@
#!/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"
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Hermes Built-in Web UI (`hermes serve` — Agent-Dashboard/Chat, Loopback only)
After=network-online.target
Wants=network-online.target
[Service]
# Serviert die EINGEBAUTE Hermes-Web-GUI (die reiche Agent-Oberflaeche mit Threads/Tool-Calls, die
# auch die Electron-Desktop-App umhuellt) headless.
# SICHERHEIT: Bindet NUR an Loopback (127.0.0.1:9119) und laeuft hinter dem MC2-Reverse-Proxy
# (routers/hermes_ui.py → same-origin /hermes-ui/ auf dem offenen Port 9001) — KEINE eigene
# Firewall-Freigabe, KEIN Login (LAN-Trust wie Konsole/Terminal). --skip-build nutzt das von
# deploy.sh mit Vite-base=/hermes-ui/ vorgebaute web_dist, damit alle SPA-Pfade unter /hermes-ui/
# liegen und der Proxy sie sauber durchreichen kann.
Type=simple
Environment=HERMES_HOME=%h/.hermes
ExecStart=%h/.hermes/hermes-agent/venv/bin/hermes dashboard --no-open --skip-build --host 127.0.0.1 --port 9119
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
-259
View File
@@ -1,259 +0,0 @@
#!/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())
@@ -1,84 +0,0 @@
"""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
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env bash
# Hermes-Release-Radar (woechentlich, Mo 05:15) — "mehr Hermes nativ, weniger Eigenkreationen":
# haelt neue hermes-agent-Releases gegen die Eigenbau-Landkarte im Wissens-Vault und meldet
# NUR echte Treffer auf Abloese-Kriterien/Wiedervorlage-Bedingungen. Muster wie
# chef-gutachter-feed.sh: liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn), stdout wird
# dem Cron-Agenten als Prompt eingespeist. Der ABGLEICH laeuft als Ein-Schuss-Completion
# direkt gegen llama-swap (:8080, fremdblick-Architektur): Analyst gpt-oss-120b (um 05:15
# vom Chef-Gutachter 04:30 oft noch geladen), Vertretung GLM-4.7-Flash. Danach gatet
# fremdblick.sh (Prosa-Raster, Fremd-Vendor) jede Treffer-Behauptung gegen die
# Original-Changelogs.
# Grundsaetze (stehen auch in der Landkarte): nur getaggte Releases (kein main-Churn bei
# ~660 PRs/Woche) · Behauptung nur mit woertlichem Changelog-Zitat · Treffer => Probe-
# Vorschlag, NIE Direkt-Adoption · der Radar informiert, er updatet nicht (Entscheid 04.07.).
# State: ~/.hermes/state/release-radar-last.txt (zuletzt gemeldeter Tag). Verlust harmlos —
# ohne State dient die installierte Version als Basis.
set -uo pipefail
MC="$HOME/mission-control-v2"
LANDKARTE="$HOME/wissens-vault/eigenbau-landkarte.md"
STATE_DIR="$HOME/.hermes/state"
STATE="$STATE_DIR/release-radar-last.txt"
API="${RADAR_RELEASES_URL:-https://api.github.com/repos/NousResearch/hermes-agent/releases?per_page=10}"
ENDPOINT="${RADAR_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
ANALYST="${RADAR_MODEL:-gpt-oss-120b}"
VERTRETUNG="${RADAR_FALLBACK:-GLM-4.7-Flash}"
FREMDBLICK="$HOME/.hermes/scripts/fremdblick.sh"
mkdir -p "$STATE_DIR"
# Briefkasten-Spiegel (still, wie die Morgenlage): Cockpit/Chronik lesen source=release-radar.
briefkasten() { # $1=Betreff $2=Text
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 - "$1" "$2" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1],
"source": "release-radar", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
# ---------- Versionierter Auftrag fuer den Boten-Agenten ----------
cat "$MC/deploy/release-radar-prompt.md" 2>/dev/null || cat <<'EOF'
(Prompt-Datei fehlt noch — Kurzform:) Du bist der Bote des woechentlichen Release-Radars.
Gib den Befund unten in Lucys Stimme wieder: bei "KEIN NEUES RELEASE" oder "TREFFER: 0"
genau EIN kurzer Satz; bei Treffern maximal 5 Saetze. Nichts installieren oder aendern.
EOF
echo
INSTALLED_LINE="$(bash -lc 'hermes --version' 2>/dev/null | head -1)"
INSTALLED_TAG="$(printf '%s' "$INSTALLED_LINE" | grep -oE '\([0-9][0-9.]*\)' | head -1 | tr -d '()')"
[ -n "$INSTALLED_TAG" ] && INSTALLED_TAG="v$INSTALLED_TAG"
LAST_SEEN="$(cat "$STATE" 2>/dev/null | tr -d '[:space:]' || true)"
BASELINE="${LAST_SEEN:-$INSTALLED_TAG}"
RELEASES_JSON="$(curl -sf --max-time 30 "$API" 2>/dev/null || true)"
if [ -z "$RELEASES_JSON" ]; then
echo "## RADAR-BEFUND: AUSGEFALLEN"
echo "GitHub-Releases-API nicht erreichbar. Dem Commander EINEN ehrlichen Satz melden; naechste Woche neuer Versuch."
exit 0
fi
# Neue getaggte Releases seit BASELINE (Drafts/Prereleases raus, max. 6, Bodies gekuerzt).
# Baseline unbekannt (aelter als die letzten 10 oder Erstlauf ohne Version) -> juengste 6.
# JSON via Temp-Datei, NICHT via Pipe: das Heredoc belegt stdin schon fuer den Python-Code
# selbst (Pipe+Heredoc gleichzeitig -> json.load(stdin) liest ins Leere; Probelauf 10.07.).
TMPJSON="$(mktemp /tmp/release-radar.XXXXXX.json)"
printf '%s' "$RELEASES_JSON" > "$TMPJSON"
PARSED="$(python3 - "$BASELINE" "$TMPJSON" <<'PY'
import json, sys
baseline = sys.argv[1]
try:
data = json.load(open(sys.argv[2]))
# Rate-Limit/Fehler antwortet als Dict statt Liste -> wie "keine Daten" behandeln.
rels = [r for r in data if not r.get("draft") and not r.get("prerelease")] if isinstance(data, list) else []
except Exception:
rels = []
rels.sort(key=lambda r: r.get("published_at") or "", reverse=True)
if not rels:
# API hat geantwortet, aber unbrauchbar (Rate-Limit-Dict, kaputtes JSON, leere Liste):
# ehrlich als "blind" markieren statt faelschlich "kein neues Release" zu melden.
print(baseline)
print(-1)
raise SystemExit
base = next((r.get("published_at") for r in rels if r.get("tag_name") == baseline), None)
new = rels[:6] if base is None else [r for r in rels if (r.get("published_at") or "") > base][:6]
print((new or rels)[0].get("tag_name") or baseline)
print(len(new))
for r in new:
body = (r.get("body") or "").strip()
if len(body) > 3500:
body = body[:3500] + "\n[... gekuerzt ...]"
print(f"\n===== RELEASE {r.get('tag_name')} — {(r.get('name') or '').strip()} ({(r.get('published_at') or '')[:10]}) =====")
print(body)
PY
)"
rm -f "$TMPJSON"
NEWEST="$(printf '%s\n' "$PARSED" | sed -n 1p)"
COUNT="$(printf '%s\n' "$PARSED" | sed -n 2p)"
NOTES="$(printf '%s\n' "$PARSED" | tail -n +3)"
if [ "${COUNT:-0}" = "-1" ]; then
echo "## RADAR-BEFUND: AUSGEFALLEN"
echo "GitHub-API hat geantwortet, aber unbrauchbar (Rate-Limit oder kaputtes JSON). Dem Commander EINEN ehrlichen Satz melden; State bleibt, naechste Woche neuer Versuch."
exit 0
fi
echo "## RADAR-BEFUND (erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo "Box installiert: ${INSTALLED_LINE:-unbekannt}"
echo "Zuletzt gemeldet: ${BASELINE:-unbekannt} · neuestes Release: $NEWEST · neue Releases: ${COUNT:-0}"
echo "(Updates bleiben manuell/bestaetigt — der Radar informiert nur.)"
echo
if [ "${COUNT:-0}" = "0" ]; then
echo "KEIN NEUES RELEASE seit $BASELINE."
[ -n "$NEWEST" ] && printf '%s\n' "$NEWEST" > "$STATE"
briefkasten "Release-Radar" "Kein neues hermes-agent-Release seit $BASELINE (Box: ${INSTALLED_TAG:-?})."
exit 0
fi
if [ ! -s "$LANDKARTE" ]; then
echo "### ABGLEICH NICHT MOEGLICH"
echo "$COUNT neue Releases, aber die Eigenbau-Landkarte fehlt ($LANDKARTE)."
echo "Dem Commander melden: Landkarte im Wissens-Vault wiederherstellen. State bleibt unveraendert (naechste Woche neuer Versuch)."
briefkasten "Release-Radar" "$COUNT neue hermes-agent-Releases, aber die Eigenbau-Landkarte fehlt — Abgleich uebersprungen."
exit 0
fi
RASTER='Du bist der RELEASE-RADAR einer lokalen KI-Box. Du bekommst (1) die EIGENBAU-LANDKARTE (selbstgebaute Bausteine mit Abloese-Kriterien und Wiedervorlage-Bedingungen) und (2) CHANGELOGS neuer hermes-agent-Releases. Pruefe AUSSCHLIESSLICH: Trifft eine Changelog-Zeile ein Abloese-Kriterium oder eine Wiedervorlage-Bedingung der Landkarte? REGELN: Jede Treffer-Behauptung MUSS die Changelog-Zeile WOERTLICH zitieren. Marketing-Prosa ohne konkretes Feature ist KEIN Treffer. Vage Aehnlichkeit ist KEIN Treffer. Eintraege unter "Bewusst NICHT adoptiert" nur melden, wenn exakt die Wiedervorlage-Bedingung erfuellt ist. Erfinde nichts. Antworte auf DEUTSCH, exakt in dieser Struktur: erste Zeile "TREFFER: <Anzahl>". Danach je Treffer ein Block: "EIGENBAU: <Name aus der Landkarte>" / "RELEASE: <Tag>" / "ZITAT: <woertliche Changelog-Zeile>" / "WARUM: <ein Satz>" / "PROBE: <ein Satz, wie man es risikofrei testet>". Bei TREFFER: 0 stattdessen EIN Satz, was die Releases fuer uns Unwichtiges bringen.'
EVID="EIGENBAU-LANDKARTE:
$(cat "$LANDKARTE")
CHANGELOGS DER NEUEN RELEASES:
$NOTES"
# Ein-Schuss-Analyst: $1=Modell $2=curl-Timeout $3=max_tokens (Muster chef-gutachter-feed.sh;
# max_tokens grosszuegig, weil Reasoning-Modelle Budget im reasoning_content verbrauchen).
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
except Exception:
pass'
}
RICHTER="$ANALYST"
ANALYSE="$(judge "$ANALYST" 420 2600)"
if [ -z "${ANALYSE// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $ANALYST hat nicht geantwortet)"
ANALYSE="$(judge "$VERTRETUNG" 240 1800)"
fi
if [ -z "${ANALYSE// /}" ]; then
echo "### ABGLEICH AUSGEFALLEN"
echo "Beide Analysten ($ANALYST, $VERTRETUNG) haben nicht geantwortet. State bleibt unveraendert — naechste Woche neuer Versuch. Dem Commander EINEN ehrlichen Satz melden."
briefkasten "Release-Radar" "$COUNT neue hermes-agent-Releases, aber der Abgleich fiel aus (kein Analyst erreichbar)."
exit 0
fi
echo "### Neue Releases:"
printf '%s\n' "$NOTES" | grep '^===== RELEASE' | sed 's/^===== //; s/ =====$//'
echo
echo "### ABGLEICH gegen die Eigenbau-Landkarte (Analyst: $RICHTER)"
echo "$ANALYSE"
echo
# Fremdblick-Gate nur bei Treffern (Zitate MIT Quellmaterial gegenpruefbar machen).
TREFFER="$(printf '%s' "$ANALYSE" | grep -oE 'TREFFER: *[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
GEGEN=""
if [ "${TREFFER:-1}" != "0" ] && [ -x "$FREMDBLICK" ]; then
# Grosses Budget: GLM (Reasoning-Modell) denkt erst ueber das ganze Quellmaterial nach,
# bevor das Urteil in content landet — 2200 (fremdblick-Default) war im Probelauf 10.07.
# zu knapp (ABGESCHNITTEN, kein Urteil).
GEGEN="$(printf '%s\n\nQUELLMATERIAL (Original-Changelogs zum Gegenpruefen der Zitate):\n%s' "$ANALYSE" "$NOTES" \
| FREMDBLICK_MAXTOK="${RADAR_FREMDBLICK_MAXTOK:-4500}" "$FREMDBLICK")"
echo "$GEGEN"
echo
fi
# Treffer fuettern die Ideen-Queue ("Lucy kennt sich", 12.07.2026): aus einem echten
# Radar-Treffer wird EINE rohe Idee (triage) — Specifier + Commander entscheiden, ob eine
# Probe gebaut wird. Idempotent je Release-Tag; der Radar selbst bleibt reiner Melder.
if [ -n "${TREFFER:-}" ] && [ "$TREFFER" != "0" ] && [ -x "$HOME/.local/bin/hermes" ]; then
IDEE_ID="$("$HOME/.local/bin/hermes" kanban create \
"Hermes ${NEWEST}: Release-Radar-Treffer pruefen (Probe bauen?)" \
--body "Automatisch vom Release-Radar am $(date '+%d.%m.%Y'). ABGLEICH (Analyst: ${RICHTER}):
${ANALYSE}
FREMDBLICK:
${GEGEN:-(keiner)}
Rohe Idee: nur pruefen bzw. eine risikofreie Probe vorschlagen — Updates bleiben manuell (Entscheid 04.07.)." \
--triage --created-by release-radar --idempotency-key "release-radar-${NEWEST}" --json 2>/dev/null \
| python3 -c '
import json, sys
t = sys.stdin.read(); i = t.find("{")
try:
print(json.loads(t[i:]).get("id", "") if i >= 0 else "")
except Exception:
print("")
' 2>/dev/null || true)"
if [ -n "${IDEE_ID// /}" ]; then
echo "### IN DIE IDEEN-QUEUE GELEGT: ${IDEE_ID} — Probe-Pruefung fuer ${NEWEST} (der Specifier uebernimmt)."
echo
fi
fi
printf '%s\n' "$NEWEST" > "$STATE"
briefkasten "Release-Radar" "Neue hermes-agent-Releases bis $NEWEST (Box: ${INSTALLED_TAG:-?}).
ABGLEICH (Analyst: $RICHTER):
$ANALYSE
${GEGEN:-(Kein Fremdblick noetig: TREFFER 0)}"
-298
View File
@@ -1,298 +0,0 @@
#!/usr/bin/env bash
# Idle-Radar (naechtlich 03:45 — S4 "Zuendung", 12.07.2026): die Box gibt sich selbst Arbeit.
# Destilliert aus JOURNAL-Fehlermustern (24 h) und der juengsten TRAUM-Notiz 0-2 KLEINE,
# belegte Wartungs-Kandidaten und legt sie als ROHE IDEEN (triage) ins native Hermes-Kanban.
# Danach laeuft der bewiesene S2-Kreislauf von allein: Specifier arbeitet aus -> Werkstatt
# baut einen Vorschlags-Branch -> Karte im Auftragsbuch -> der KLICK des Commanders bleibt
# das einzige Gate. Muster wie hermes-release-radar-feed.sh: liegt in ~/.hermes/scripts/
# (deploy.sh kopiert ihn), stdout wird dem Cron-Agenten als Prompt eingespeist; der
# eigentliche ABGLEICH laeuft als Ein-Schuss-Completion gegen llama-swap (:8080).
# Grundsaetze:
# - Kandidat NUR mit woertlichem Beleg-Zitat; das Skript prueft das Zitat MECHANISCH
# gegen das Material (reproduziert-oder-abgelehnt, Verdikt 04.07.) — haerter als
# ein zweites LLM-Urteil und gratis.
# - Max. 2 neue Ideen je Nacht + STAU-BREMSE: liegt schon genug Arbeit (volle Queue
# oder volles Auftragsbuch), legt der Radar NICHTS nach — die Box soll Arbeit
# abarbeiten, nicht anhaeufen.
# - Dedup dreifach: State-Datei (schon gemeldet) + Queue/Karten-Liste im Analysten-
# Kontext + --idempotency-key beim Anlegen.
# - Der Radar LEGT NUR IDEEN AN — er baut nichts, merged nichts, konfiguriert nichts.
# State: ~/.hermes/state/idle-radar-gemeldet.txt (ein KEY je bereits gemeldetem Kandidaten).
set -uo pipefail
MC="$HOME/mission-control-v2"
VAULT="$HOME/wissens-vault"
STATE_DIR="$HOME/.hermes/state"
STATE="$STATE_DIR/idle-radar-gemeldet.txt"
ENDPOINT="${IDLE_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
ANALYST="${IDLE_MODEL:-gpt-oss-120b}"
VERTRETUNG="${IDLE_FALLBACK:-GLM-4.7-Flash}"
HERMES="$HOME/.local/bin/hermes"
MAX_NEU=2 # harte Obergrenze neuer Ideen je Nacht (wie Bagatell-Pfad)
STAU_QUEUE=4 # ab so vielen offenen Queue-Aufgaben: Bremse
STAU_KARTEN=3 # ab so vielen offenen Auftragsbuch-Karten: Bremse
mkdir -p "$STATE_DIR"; touch "$STATE"
# Briefkasten-Spiegel (still, wie Morgenlage/Release-Radar): Chronik liest source=idle-radar.
briefkasten() { # $1=Betreff $2=Text
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 - "$1" "$2" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1],
"source": "idle-radar", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
# ---------- Versionierter Auftrag fuer den Boten-Agenten ----------
cat "$MC/deploy/idle-radar-prompt.md" 2>/dev/null || cat <<'EOF'
(Prompt-Datei fehlt noch — Kurzform:) Du bist der Bote des naechtlichen Idle-Radars.
Berichte den Befund unten in Lucys Stimme in hoechstens 3 Saetzen: wie viele neue
Queue-Ideen angelegt wurden (Titel nennen) oder warum keine. Nichts selbst umsetzen,
keine Kanban-Aktionen — das Skript hat alles Noetige bereits getan.
EOF
echo
# ---------- Stau-Bremse (vor dem teuren Analysten) ----------
OFFEN_QUEUE="$("$HERMES" kanban list --json 2>/dev/null | python3 -c '
import json, sys
t = sys.stdin.read(); i = t.find("[")
try:
d = json.loads(t[i:]) if i >= 0 else []
except Exception:
d = []
print(sum(1 for x in d if isinstance(x, dict) and x.get("status") not in ("done", "archived")))
' 2>/dev/null || echo 0)"
OFFEN_KARTEN="$(curl -sf -m 10 http://127.0.0.1:9001/api/auftragsbuch 2>/dev/null | python3 -c '
import json, sys
try:
print(int(json.load(sys.stdin).get("open_count", 0)))
except Exception:
print(0)
' 2>/dev/null || echo 0)"
if [ "${OFFEN_QUEUE:-0}" -ge "$STAU_QUEUE" ] || [ "${OFFEN_KARTEN:-0}" -ge "$STAU_KARTEN" ]; then
echo "## IDLE-RADAR-BEFUND: STAU-BREMSE (erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo "Offene Queue-Aufgaben: ${OFFEN_QUEUE} (Bremse ab ${STAU_QUEUE}) · offene Karten: ${OFFEN_KARTEN} (Bremse ab ${STAU_KARTEN})."
echo "Es liegt genug Arbeit — heute Nacht keine neuen Ideen. Dem Commander EINEN kurzen Satz melden."
briefkasten "Idle-Radar" "Stau-Bremse: ${OFFEN_QUEUE} offene Queue-Aufgaben, ${OFFEN_KARTEN} offene Karten — keine neuen Ideen angelegt."
exit 0
fi
# ---------- Material sammeln (erst sammeln, dann kuerzen — SIGPIPE-Falle) ----------
TRAUM_DATEI="$(ls -1t "$VAULT/traeume/"*.md 2>/dev/null | head -1 || true)"
EVID="$(
echo "### JOURNAL-FEHLERMUSTER (user-Dienste, letzte 24 h; Anzahl · Muster, lange Zahlen/IDs normalisiert):"
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
| sed -E 's/^[A-Z][a-z]{2} [0-9 ]{2} [0-9:]{8} [^ ]+ //; s/\[[0-9]+\]//; s/[0-9]{3,}/N/g; s/t_[0-9a-f]+/t_X/g' \
| sort | uniq -c | sort -rn | head -18
echo
echo "### JUENGSTE TRAUM-NOTIZ ($(basename "${TRAUM_DATEI:-keine}") — Funde des naechtlichen Lern-Crons):"
if [ -n "$TRAUM_DATEI" ]; then head -70 "$TRAUM_DATEI"; else echo "(keine Traum-Notiz gefunden)"; fi
echo
echo "### OFFENE SKILL-KANDIDATEN im Vault (nur Titelzeilen):"
ls -1 "$VAULT/skill-kandidaten/"*.md 2>/dev/null | head -8 | while read -r f; do
echo "- $(basename "$f"): $(head -1 "$f" 2>/dev/null)"
done
echo
echo "### SELBST-STECKBRIEF (auto-generiert von selbst-inventur.sh — das EXISTIERT schon; NICHTS davon neu vorschlagen):"
head -80 "$HOME/.hermes/state/selbst-steckbrief.md" 2>/dev/null || echo "(noch keine Selbst-Inventur gelaufen)"
echo
echo "### SEIT GESTERN AN MIR GEAENDERT (Selbst-Inventur-Diff — gute Kandidaten-Quelle, wenn eine kleine Massnahme erkennbar ist):"
head -40 "$HOME/.hermes/state/selbst-steckbrief.diff" 2>/dev/null || echo "(keine Aenderung erkannt)"
echo
echo "### VOM COMMANDER ABGELEHNT (mit Grund — sinngemaess NIE wieder vorschlagen):"
tail -12 "${MC2_ABLEHNUNGEN:-/srv/models/mc2-ablehnungen.jsonl}" 2>/dev/null | python3 -c '
import json, sys
n = 0
for line in sys.stdin:
try:
d = json.loads(line)
except Exception:
continue
n += 1
print("- [{}] {} — Grund: {}".format(d.get("branch", "?"), (d.get("subject") or "")[:80],
d.get("grund") or "(keiner angegeben)"))
if not n:
print("(keine)")
' 2>/dev/null || echo "(keine)"
echo
echo "### SCHON IN DER IDEEN-QUEUE (inkl. archiviert = vom Commander verworfen; NICHT erneut vorschlagen):"
"$HERMES" kanban list --archived --json 2>/dev/null | python3 -c '
import json, sys
t = sys.stdin.read(); i = t.find("[")
try:
d = json.loads(t[i:]) if i >= 0 else []
except Exception:
d = []
for x in d[:40]:
if isinstance(x, dict):
print("- [{}] {}".format(x.get("status", "?"), (x.get("title") or "")[:110]))
' 2>/dev/null || echo "(Queue nicht lesbar)"
echo
echo "### OFFENE KARTEN IM AUFTRAGSBUCH (warten auf Klick; NICHT erneut vorschlagen):"
curl -sf -m 10 http://127.0.0.1:9001/api/auftragsbuch 2>/dev/null | python3 -c '
import json, sys
try:
items = json.load(sys.stdin).get("items") or []
except Exception:
items = []
if not items:
print("(keine)")
for it in items[:15]:
print("- {}:{}".format(it.get("repo", "mc2"), it.get("branch", "?")))
' 2>/dev/null || echo "(Auftragsbuch nicht lesbar)"
echo
echo "### VOM IDLE-RADAR BEREITS GEMELDET (KEYs; NICHT erneut vorschlagen):"
tail -40 "$STATE" 2>/dev/null || true
)"
RASTER='Du bist der IDLE-RADAR einer lokalen KI-Box (Hermes-Agent, MC2, Lucy). Du bekommst naechtliches Material: Journal-Fehlermuster, die juengste Traum-Notiz, offene Skill-Kandidaten sowie Listen dessen, was schon in Arbeit ist. Deine Aufgabe: destilliere daraus 0 bis 2 KLEINE, konkrete Wartungs-Kandidaten, die die Box-Werkstatt selbst umsetzen kann (Patch in 1-2 Dateien, Skript- oder Doku-Aenderung im MC2- oder Lucy-Repo). REGELN: Jeder Kandidat MUSS eine Zeile aus dem Material WOERTLICH zitieren (BELEG) — das Zitat wird mechanisch gegengeprueft, erfundene Zitate fliegen raus. Nichts vorschlagen, was sinngemaess schon in der IDEEN-QUEUE, den OFFENEN KARTEN oder der BEREITS-GEMELDET-Liste steht. Der SELBST-STECKBRIEF sagt dir, was die Box schon HAT und KANN — schlage NIE etwas vor, das dort schon steht (der Fehler vom 12.07.: ein Gateway-Neustart wurde vorgeschlagen, der laengst konfiguriert war). Was der Commander mit Grund ABGELEHNT hat, ist entschieden — nicht neu verpacken. Aenderungen aus dem Inventur-Diff sind gute Kandidaten-Quellen, aber nur mit konkreter kleiner Massnahme. TABU (niemals vorschlagen): Security (approvals/Tokens/ufw/sudoers/ssh), ~/.hermes/config.yaml, systemd-Units, Hermes-Quellcode, Updates/Upgrades, alles was den PC oder einen Menschen braucht. Ein wiederkehrendes Fehlermuster ist NUR dann ein Kandidat, wenn eine konkrete kleine Ursachen-Behebung erkennbar ist — "beobachten", "untersuchen" oder "Monitoring bauen" ist KEIN Auftrag. Qualitaet vor Quote: KANDIDATEN: 0 ist der Normalfall und voellig ok. Antworte auf DEUTSCH, exakt in dieser Struktur: erste Zeile "KANDIDATEN: <0|1|2>". Danach je Kandidat ein Block aus genau vier Zeilen: "TITEL: <max 90 Zeichen, Imperativ, deutsch>" / "KEY: <kurzer-kebab-slug>" / "BELEG: <woertliches Zitat aus dem Material, EINE Zeile>" / "WARUM: <ein Satz>". Bei 0 stattdessen EIN Satz, warum nichts ansteht.'
# Ein-Schuss-Analyst: $1=Modell $2=curl-Timeout $3=max_tokens (Muster chef-gutachter-feed.sh).
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
except Exception:
pass'
}
RICHTER="$ANALYST"
ANALYSE="$(judge "$ANALYST" 420 2600)"
if [ -z "${ANALYSE// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $ANALYST hat nicht geantwortet)"
ANALYSE="$(judge "$VERTRETUNG" 240 1800)"
fi
echo "## IDLE-RADAR-BEFUND (erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo "Offene Queue-Aufgaben vorher: ${OFFEN_QUEUE} · offene Karten: ${OFFEN_KARTEN} · Deckel: ${MAX_NEU}/Nacht"
echo
if [ -z "${ANALYSE// /}" ]; then
echo "### ANALYSE AUSGEFALLEN"
echo "Beide Analysten ($ANALYST, $VERTRETUNG) haben nicht geantwortet — dem Commander EINEN ehrlichen Satz melden; morgen neuer Versuch."
briefkasten "Idle-Radar" "Analyse ausgefallen (kein Analyst erreichbar) — keine neuen Ideen."
exit 0
fi
echo "### ANALYSE (Analyst: $RICHTER)"
echo "$ANALYSE"
echo
# ---------- Ehrlichkeits-Gate + Dedup + Anlegen (deterministisch im Skript) ----------
# Temp-Dateien statt Pipes: Heredoc + Pipe gleichzeitig frisst stdin (Release-Radar-Lehre 10.07.).
TMPD="$(mktemp -d /tmp/idle-radar.XXXXXX)"
printf '%s' "$ANALYSE" > "$TMPD/analyse.txt"
printf '%s' "$EVID" > "$TMPD/material.txt"
PLAN="$(python3 - "$TMPD/analyse.txt" "$TMPD/material.txt" "$STATE" "$MAX_NEU" <<'PY'
import re, sys, unicodedata
ana = open(sys.argv[1], encoding="utf-8", errors="replace").read()
evid = open(sys.argv[2], encoding="utf-8", errors="replace").read()
try:
state = {l.strip() for l in open(sys.argv[3], encoding="utf-8", errors="replace") if l.strip()}
except Exception:
state = set()
max_neu = int(sys.argv[4])
def norm(s):
s = unicodedata.normalize("NFKC", s)
s = "".join(ch for ch in s if ch.isalnum() or ch.isspace())
return " ".join(s.lower().split())
nev = norm(evid)
# Zeilenweise einsammeln (tolerant gegen Markdown-Deko und Leerzeilen zwischen Feldern).
cands, cur = [], {}
for line in ana.splitlines():
line = line.strip().lstrip("*->#• ").strip()
m = re.match(r"(TITEL|KEY|BELEG|WARUM)\s*:\s*(.+)$", line)
if not m:
continue
k, v = m.group(1), m.group(2).strip()
if k == "TITEL" and cur:
cands.append(cur); cur = {}
cur[k] = v
if cur:
cands.append(cur)
neu = 0
SEP = "\x1f"
for c in cands:
titel = (c.get("TITEL") or "").strip()[:120]
warum = (c.get("WARUM") or "").strip()[:300]
beleg = (c.get("BELEG") or "").strip().strip('"„“»«>`').strip()
key = re.sub(r"[^a-z0-9-]+", "-", (c.get("KEY") or "").strip().lower())[:48].strip("-")
if not (titel and key and beleg):
continue
if key in state or ("idle-radar-" + key) in state:
print("SKIP" + SEP + key + SEP + "schon frueher gemeldet (State)")
continue
if not norm(beleg) or norm(beleg) not in nev:
print("SKIP" + SEP + key + SEP + "BELEG nicht woertlich im Material — verworfen (Ehrlichkeits-Gate)")
continue
if neu >= max_neu:
print("SKIP" + SEP + key + SEP + "Deckel erreicht")
continue
neu += 1
print("NEU" + SEP + key + SEP + titel + SEP + beleg + SEP + warum)
PY
)"
ANGELEGT=""; VERWORFEN=""
while IFS=$'\x1f' read -r art key titel beleg warum; do
[ -n "${art:-}" ] || continue
if [ "$art" = "SKIP" ]; then
VERWORFEN="${VERWORFEN}- ${key}: ${titel}"$'\n' # bei SKIP traegt das 3. Feld den Grund
continue
fi
[ "$art" = "NEU" ] || continue
BODY="Automatisch vom Idle-Radar erhoben am $(date '+%d.%m.%Y') (Quelle: Journal-Muster/Traum-Notiz der Nacht).
BELEG (woertlich aus dem Material): ${beleg}
WARUM: ${warum}
Rohe Idee — bitte pruefen, klein schneiden und nur wenn tragfaehig promoten; sonst archivieren."
TASK_ID="$("$HERMES" kanban create "$titel" --body "$BODY" --triage \
--created-by idle-radar --idempotency-key "idle-radar-${key}" --json 2>/dev/null \
| python3 -c '
import json, sys
t = sys.stdin.read(); i = t.find("{")
try:
print(json.loads(t[i:]).get("id", "") if i >= 0 else "")
except Exception:
print("")
' 2>/dev/null || true)"
if [ -n "${TASK_ID// /}" ]; then
echo "$key" >> "$STATE"
ANGELEGT="${ANGELEGT}- ${titel} (${TASK_ID}, Key ${key})"$'\n'
else
VERWORFEN="${VERWORFEN}- ${key}: kanban create fehlgeschlagen"$'\n'
fi
done <<< "$PLAN"
rm -rf "$TMPD"
echo "### ANGELEGT (rohe Ideen, triage — der Specifier uebernimmt):"
echo "${ANGELEGT:-(keine)}"
echo "### NICHT ANGELEGT (Gate/Dedup/Deckel):"
echo "${VERWORFEN:-(keine)}"
N_NEU="$(printf '%s' "$ANGELEGT" | grep -c '^-' || true)"
if [ "${N_NEU:-0}" -gt 0 ]; then
briefkasten "Idle-Radar" "Idle-Radar hat ${N_NEU} neue Idee(n) in die Queue gelegt:
${ANGELEGT}Der Specifier arbeitet sie aus; die Karten-Annahme bleibt beim Commander."
else
briefkasten "Idle-Radar" "Idle-Radar: keine neuen Ideen (Analyst: ${RICHTER})."
fi
-26
View File
@@ -1,26 +0,0 @@
# Idle-Radar — Auftrag für den Boten-Agenten
Du bist der Bote des nächtlichen Idle-Radars. Unten steht ein automatisch erhobener
BEFUND: aus Journal-Fehlermustern und der jüngsten Traum-Notiz destilliert ein
Analysten-Modell kleine Wartungs-Kandidaten; das Skript hat die tragfähigen davon
BEREITS als rohe Ideen (triage) in die Ideen-Queue gelegt — den Rest erledigt der
bewiesene Kreislauf (Specifier → Werkstatt → Karte im Auftragsbuch → Klick des
Commanders).
Deine Aufgabe — NUR berichten, in Lucys Stimme (Anrede „Commander", Alltagssprache,
Fazit zuerst), in höchstens 3 Sätzen:
1. Bei „ANGELEGT: (keine)" oder „KANDIDATEN: 0": GENAU EIN kurzer Satz
(z. B. „Idle-Radar: nichts Belegbares heute Nacht — Queue bleibt wie sie ist.").
2. Bei neuen Ideen: nenne Anzahl und die Titel; sage dazu, dass die Box sie jetzt
selbst ausarbeitet und die Annahme wie immer per Karten-Klick beim Commander liegt.
3. Bei „STAU-BREMSE" oder „AUSGEFALLEN": ein ehrlicher Satz, warum der Radar heute
Nacht nichts angelegt hat.
Harte Regeln:
- NICHTS selbst umsetzen: keine Kanban-Aktionen, nichts bauen, nichts konfigurieren —
das Anlegen hat das Skript schon deterministisch erledigt.
- Erfinde keine Kandidaten über den Befund hinaus; im Ehrlichkeits-Gate verworfene
Vorschläge (BELEG nicht im Material) nicht als Erfolg verkaufen.
- Die stille Briefkasten-Karte hat das Skript bereits geschrieben — du lieferst nur
die kurze Telegram-Meldung.
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env bash
# Karten-Gutachter (naechtlich 04:00 — "Lucy kennt sich", 12.07.2026): stempelt jede neue
# Auftragsbuch-Karte mit einer Empfehlung (ANNEHMEN/ABLEHNEN/UNKLAR + EIN ehrlicher Satz),
# damit der Commander nie blind klickt. Ein-Schuss-Richter gegen llama-swap (:8080,
# fremdblick-Architektur, Muster chef-gutachter-feed.sh) — bekommt Karte (Commit-Text,
# Dateien, Diff) UND den Selbst-Steckbrief (was schon existiert), prueft also genau die
# Falle vom 12.07. (redundanter Gateway-Restart-Vorschlag). Der Stempel ist eine MEINUNG,
# kein Gate: die Karte bleibt klickbar, die Entscheidung bleibt beim Commander.
# Laeuft als Cron mit --no-agent: stdout leer = still; gestempelte Karten = kurze Zeilen.
# Ablage: /srv/models/mc2-karten-gutachten.json (Backend mischt es in die Karten-API).
set -uo pipefail
API="${MC_API:-http://127.0.0.1:9001}"
OUT="${MC2_KARTEN_GUTACHTEN:-/srv/models/mc2-karten-gutachten.json}"
ENDPOINT="${GUTACHTER_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
CHEF="${GUTACHTER_MODEL:-gpt-oss-120b}"
VERTRETUNG="${GUTACHTER_FALLBACK:-GLM-4.7-Flash}"
STECK="$HOME/.hermes/state/selbst-steckbrief.md"
MAX_JE_LAUF=3 # Nacht-GPU-Budget: Rest kommt morgen dran
briefkasten() { # $1=Betreff $2=Text
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 - "$1" "$2" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1],
"source": "karten-gutachter", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
TMPD="$(mktemp -d /tmp/karten-gutachter.XXXXXX)"
trap 'rm -rf "$TMPD"' EXIT
if ! curl -sf -m 20 "$API/api/auftragsbuch" -o "$TMPD/items.json" 2>/dev/null; then
echo "Karten-Gutachter: Auftragsbuch nicht erreichbar — kein Lauf."
exit 0
fi
# Offene, noch ungestempelte Karten ermitteln + verwaiste Stempel aufraeumen (Karte weg
# oder neuer Stand = Stempel weg). Schluessel: "<repo>:<branch>@<ts>" haengt am Commit-
# Zeitstempel — ein nachgeschobener Commit macht den alten Stempel automatisch ungueltig.
python3 - "$TMPD/items.json" "$OUT" > "$TMPD/plan.txt" <<'PY'
import json, sys, os
items = (json.load(open(sys.argv[1], encoding="utf-8")) or {}).get("items") or []
out_path = sys.argv[2]
try:
alt = json.load(open(out_path, encoding="utf-8"))
if not isinstance(alt, dict):
alt = {}
except Exception:
alt = {}
offen = {}
for it in items:
state = (it.get("status") or {}).get("state")
if state in ("eingespielt", "laeuft", "rollback"):
continue # entschieden oder gerade in Arbeit — kein Stempel noetig
key = "{}:{}".format(it.get("repo", "mc2"), it.get("branch", "?"))
offen[key] = it
# Verwaiste/veraltete Stempel raus (atomar zurueckschreiben).
neu = {}
for key, g in alt.items():
it = offen.get(key)
if it and g.get("commit_ts") == it.get("ts"):
neu[key] = g
tmp = out_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(neu, f, ensure_ascii=False)
os.replace(tmp, out_path)
for key, it in offen.items():
if key in neu:
continue
repo, branch = key.split(":", 1)
print("\x1f".join([repo, branch, str(it.get("ts") or 0),
(it.get("subject") or "")[:200],
(it.get("shortstat") or "")[:200]]))
PY
RASTER='Du bist der KARTEN-GUTACHTER einer lokalen KI-Box. Du bekommst EINE Vorschlags-Karte aus dem Auftragsbuch (Commit-Text, Datei-Liste, Diff) und den SELBST-STECKBRIEF der Box (was schon existiert und laeuft). Der Commander ist kein Entwickler — dein Urteil ist seine einzige fachliche Zweitmeinung vor dem Klick. Pruefe NUR anhand des Materials, erfinde nichts: (1) REDUNDANZ — existiert das laut Steckbrief oder Diff schon? Ein Vorschlag, der Bestehendes nur nachbaut oder verschlechtert (z. B. funktionierende Einstellungen ersetzt), ist abzulehnen. (2) RISIKO — beruehrt der Diff Security (Tokens/approvals/ufw/sudoers), loescht er Daten oder aendert er Verhalten, das der Commander nicht bestellt hat? (3) NUTZEN — loest er ein echtes, benanntes Problem? Antworte auf DEUTSCH in exakt zwei Zeilen: "URTEIL: ANNEHMEN" oder "URTEIL: ABLEHNEN" oder "URTEIL: UNKLAR", danach "SATZ: <ein ehrlicher Satz Begruendung in Alltagssprache>". Kein weiterer Text.'
judge() { # $1=Modell $2=Timeout $3=max_tokens (liest $EVID)
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
except Exception:
pass'
}
N=0
while IFS=$'\x1f' read -r repo branch cts subject stat; do
[ -n "${repo:-}" ] || continue
[ "$N" -ge "$MAX_JE_LAUF" ] && { echo "Karten-Gutachter: Tages-Deckel ($MAX_JE_LAUF) erreicht — Rest morgen."; break; }
DIFF="$(curl -sfG -m 30 "$API/api/auftragsbuch/diff" \
--data-urlencode "branch=$branch" --data-urlencode "repo=$repo" 2>/dev/null \
| python3 -c '
import json, sys
try:
print((json.load(sys.stdin).get("diff") or "")[:20000])
except Exception:
pass' || true)"
# Leerer Diff = deterministisch UNKLAR, OHNE Richter (Erstlauf 12.07.: eine Karte mit
# 0 Dateien bekam trotzdem ANNEHMEN — ein LLM kann einen leeren Vorschlag nicht pruefen).
if [ -z "${DIFF//[[:space:]]/}" ]; then
python3 - "$OUT" "$repo" "$branch" "$cts" <<'PY'
import json, os, sys, time
out_path = sys.argv[1]
try:
data = json.load(open(out_path, encoding="utf-8"))
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
data["{}:{}".format(sys.argv[2], sys.argv[3])] = {
"empfehlung": "UNKLAR",
"satz": "Der Vorschlag hat einen LEEREN Diff (0 geaenderte Dateien gegen main) — da gibt es nichts zu pruefen; technisch verdaechtig, im Zweifel ablehnen.",
"richter": "mechanische Regel (leerer Diff)",
"commit_ts": int(sys.argv[4]) if sys.argv[4].isdigit() else None,
"ts": int(time.time())}
tmp = out_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, out_path)
PY
N=$((N + 1))
echo "Karte ${branch}: Empfehlung UNKLAR — leerer Diff (mechanische Regel, kein Richter noetig)."
briefkasten "Karten-Gutachter" "Karte '${branch}' (${repo}): Empfehlung UNKLAR — der Diff ist leer (0 Dateien), technisch verdaechtig."
continue
fi
EVID="KARTE ${repo}:${branch}
COMMIT: ${subject}
UMFANG: ${stat}
DIFF (ggf. gekuerzt):
${DIFF:-(Diff nicht lesbar)}
SELBST-STECKBRIEF DER BOX (was schon existiert):
$(head -90 "$STECK" 2>/dev/null || echo '(noch keine Selbst-Inventur gelaufen)')"
RICHTER="$CHEF"
URTEIL_RAW="$(judge "$CHEF" 420 1600)"
if [ -z "${URTEIL_RAW// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung)"
URTEIL_RAW="$(judge "$VERTRETUNG" 240 1200)"
fi
[ -z "${URTEIL_RAW// /}" ] && { echo "Karte ${branch}: Gutachter ausgefallen (kein Richter erreichbar)."; continue; }
printf '%s' "$URTEIL_RAW" > "$TMPD/urteil.txt"
STAMP="$(python3 - "$TMPD/urteil.txt" "$OUT" "$repo" "$branch" "$cts" "$RICHTER" <<'PY'
import json, os, re, sys, time
raw = open(sys.argv[1], encoding="utf-8", errors="replace").read()
m_u = re.search(r"URTEIL:\s*(ANNEHMEN|ABLEHNEN|UNKLAR)", raw, re.I)
m_s = re.search(r"SATZ:\s*(.+)", raw)
urteil = (m_u.group(1).upper() if m_u else "UNKLAR")
satz = (m_s.group(1).strip() if m_s else "Analyse unklar — Urteil nicht lesbar.")[:300]
out_path = sys.argv[2]
try:
data = json.load(open(out_path, encoding="utf-8"))
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
key = "{}:{}".format(sys.argv[3], sys.argv[4])
data[key] = {"empfehlung": urteil, "satz": satz, "richter": sys.argv[6],
"commit_ts": int(sys.argv[5]) if sys.argv[5].isdigit() else None,
"ts": int(time.time())}
tmp = out_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, out_path)
print(urteil + "\x1f" + satz)
PY
)"
URTEIL="${STAMP%%$'\x1f'*}"; SATZ="${STAMP#*$'\x1f'}"
N=$((N + 1))
echo "Karte ${branch}: Empfehlung ${URTEIL}${SATZ}"
briefkasten "Karten-Gutachter" "Karte '${branch}' (${repo}): Empfehlung ${URTEIL}${SATZ} (Richter: ${RICHTER})"
done < "$TMPD/plan.txt"
# stdout bleibt LEER, wenn nichts zu stempeln war (--no-agent => stille Nacht).
+11 -10
View File
@@ -10,7 +10,7 @@ models:
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf
ttl: 0
aliases:
- hermes
@@ -24,7 +24,7 @@ models:
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
ttl: 5400
aliases:
- coder
capabilities:
@@ -77,21 +77,22 @@ models:
out: [text]
tools: true
context: 131072
GLM-4.7-Flash:
# KRITIKER (07.07., Rollen-Audit): Fremd-Vendor-Zweitmeinung für fremdblick-Prosa +
# Chef-Gutachter-Vertretung — reines Textmodell, 30B-A3B (MIT, Unsloth-Dynamic-Quant).
# Die Vision-Schwester GLM-4.6V-Flash bleibt als scout für Bild-Jobs. --jinja ist
# Pflicht (Chat-Template, sonst kaputte Tool-Calls).
Devstral-Small-2-24B:
# KRITIKER (25.07.2026): Gegenleser aus einer ANDEREN Modellfamilie (Mistral, Apache-2.0,
# 24B DICHT, 256k Kontext, 68,0 % SWE-bench Verified vs. 59,2 % bei GLM-4.7-Flash).
# Loest GLM-4.7-Flash in der kritiker-Rolle ab. Dicht = auf Strix Halo bandbreitengebunden
# und damit langsam beim SCHREIBEN — fuer einen Kritiker egal: er liest viel, schreibt wenig.
# --jinja ist Pflicht (Chat-Template, sonst kaputte Tool-Calls).
cmd: |
llama-server -m /srv/models/GLM-4.7-Flash-GGUF/GLM-4.7-Flash-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --no-mmap --jinja
ttl: 600
llama-server -m /srv/models/Devstral-Small-2-GGUF/mistralai_Devstral-Small-2-24B-Instruct-2512-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 16384 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256
ttl: 1800
aliases:
- kritiker
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
context: 16384
Qwen3-Reranker-0.6B:
# Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Mem0-Suchtreffer nach echter
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
-9
View File
@@ -1,9 +0,0 @@
[Unit]
Description=MC2 Bagatell-Annahme (reine Doku-Vorschläge nachts ohne Klick, mit Fangnetz)
Documentation=file:%h/mission-control-v2/docs/AUFTRAGSBUCH.md
[Service]
Type=oneshot
# Als /tmp-Kopie laufen lassen: die Annahme deployt (git reset --hard) und würde sonst das
# eigene Skript unter dem laufenden bash austauschen (bekannte Selbst-Reset-Falle).
ExecStart=/bin/bash -c 'cp "%h/mission-control-v2/deploy/bagatell-annahme.sh" /tmp/mc2-bagatell-run.sh && exec /bin/bash /tmp/mc2-bagatell-run.sh'
-9
View File
@@ -1,9 +0,0 @@
[Unit]
Description=Täglich 04:10 — Bagatell-Annahme (nach der Nacht-Sicherung 03:30, vor der Morgenlage 04:30)
[Timer]
OnCalendar=*-*-* 04:10:00
Persistent=false
[Install]
WantedBy=timers.target
-7
View File
@@ -1,7 +0,0 @@
[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
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Timer für MC2 Morgen-Digest (07:00 Uhr)
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
-7
View File
@@ -1,7 +0,0 @@
[Unit]
Description=MC2 Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv durchspielen, Alarm bei Rot)
Documentation=file:%h/mission-control-v2/docs/AUTONOMIE_PLAN.md
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/self-smoke.sh
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Täglicher MC2-Selbst-Smoke-Test (07:15, unabhängig von Updates)
[Timer]
OnCalendar=*-*-* 07:15:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
-27
View File
@@ -1,27 +0,0 @@
# systemd-USER-Unit für den MC2-Steward — die Wächter-Loops als eigener Prozess (UMBAU v3 P2).
# Re-Warm-Wächter, Health-Sentry und Mem0-Dedupe überleben damit jeden Steuerpult-Neustart,
# und der Sentry kann erstmals auch das Steuerpult SELBST + den mc2-gateway überwachen
# (MC_SENTRY_WATCH_MC2=1). Meldungen gehen per HTTP in den MC2-Briefkasten (MC_ANNOUNCE_HTTP);
# ist MC2 down, greift weiterhin der Telegram-Direktweg (notify.sh).
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
[Unit]
Description=MC2 Steward (Wächter: Re-Warm, Health-Sentry, Mem0-Dedupe)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python steward.py
Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
Environment=MC_ANNOUNCE_HTTP=http://127.0.0.1:9001
Environment=MC_SENTRY_WATCH_MC2=1
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
-147
View File
@@ -1,147 +0,0 @@
#!/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()
-6
View File
@@ -1,6 +0,0 @@
#!/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
-26
View File
@@ -1,26 +0,0 @@
[Unit]
Description=MC2 Mem0 Sidecar — auto-lernendes, semantisches Gedächtnis (mem0 + chroma)
Documentation=https://github.com/mem0ai/mem0
After=network.target
[Service]
# Mem0 + chromadb laufen nur unter Python 3.12 (~/.mem0/venv) — das MC2-Backend (3.14)
# kann sie nicht importieren. Darum dieser schlanke Sidecar; MC2 spricht ihn per HTTP an.
# Bind 127.0.0.1: nur lokal erreichbar (MC2 proxyt nach außen).
Type=simple
WorkingDirectory=%h/mission-control-v2/mem0_service
Environment=MEM0_PORT=8765
Environment=MEM0_CHROMA_PATH=/srv/models/mem0/chroma
Environment=MEM0_HISTORY_DB=/srv/models/mem0/history.db
Environment=MEM0_EMBED_URL=http://127.0.0.1:8080/v1
Environment=MEM0_LLM_URL=http://127.0.0.1:8080/v1
Environment=MEM0_EMBED_MODEL=embed
Environment=MEM0_LLM_MODEL=fast
Environment=MEM0_EMBED_DIMS=1024
Environment=TOKENIZERS_PARALLELISM=false
ExecStart=%h/.mem0/venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8765
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
-62
View File
@@ -1,62 +0,0 @@
#!/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
-23
View File
@@ -1,23 +0,0 @@
#!/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"
-140
View File
@@ -1,140 +0,0 @@
#!/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"
-82
View File
@@ -1,82 +0,0 @@
# 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 **36 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`
-75
View File
@@ -1,75 +0,0 @@
# 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/`.
-21
View File
@@ -1,21 +0,0 @@
# 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
-88
View File
@@ -1,88 +0,0 @@
{
"$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"
}
}
}
}
-88
View File
@@ -1,88 +0,0 @@
{
"$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"
}
}
}
}
-248
View File
@@ -1,248 +0,0 @@
/**
* MC2-Governor der "Fahrlehrer" im OpenCode-Agenten.
*
* Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die
* Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere jeden
* Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge:
*
* 1. WERKZEUG-ZAUN (tool.execute.before)
* Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push,
* Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser
* Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10
* nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen.
*
* 2. PRUEF-TOR + SCHLEIFE (session.idle)
* Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei
* `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler
* geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu
* MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse.
*
* 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted)
* Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper
* "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md
* zu schreiben Wissen lebt in Datei + git, nicht im schrumpfenden Chat.
*
* 4. STIMME (MC2 /api/voice/announce)
* Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten.
* Lucy pollt ihn ohnehin und spricht ihn ohne eine Zeile Lucy-Code.
*
* Schalter (Umgebungsvariablen):
* MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001)
* MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an)
* MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3)
* MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an)
* MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen
* MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle)
*
* Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt
* am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser.
*/
const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001"
const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0"
const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10)
const SILENT = process.env.MC2_LOOP_SILENT === "1"
const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0"
const FENCE_OFF = process.env.MC2_FENCE_OFF === "1"
/**
* Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile
* ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt.
* Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen
* Entgleisen, keine Abwehr gegen einen boesartigen Akteur.
*/
const FENCE: Array<{ rx: RegExp; why: string }> = [
{ rx: /\bgit\s+push\b/, why: "git push — Veroeffentlichen ist Sache des Menschen (oder der CI-Ampel)." },
{ rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." },
{ rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." },
{ rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." },
{ rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." },
{ rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." },
{ rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." },
{ rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." },
{ rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." },
{ rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." },
{ rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." },
]
/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */
const rounds = new Map<string, number>()
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
const busy = new Set<string>()
async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") {
if (!ANNOUNCE_ON) return
try {
await fetch(`${BOX_URL}/api/voice/announce`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject,
text,
source: "loop",
priority: SILENT ? "silent" : priority,
}),
signal: AbortSignal.timeout(4000),
})
} catch {
/* best effort — eine stumme Lucy darf den Bau nie aufhalten */
}
}
export const MC2Governor = async ({ client, $, directory, worktree }: any) => {
const root: string = worktree || directory || process.cwd()
/**
* Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS.
* Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und
* nachts auf der Box `cat` gibt es auf Windows nicht zuverlaessig.
*/
async function readVerify(): Promise<string | null> {
try {
const { readFile } = await import("node:fs/promises")
const { join } = await import("node:path")
const raw = await readFile(join(root, "VERIFY"), "utf8")
const cmd = raw
.split("\n")
.map((l: string) => l.trim())
.filter((l: string) => l && !l.startsWith("#"))
.join(" && ")
return cmd || null
} catch {
return null
}
}
/**
* Verify ausfuehren. Rueckgabe: {ok, output} Ausgabe auf das Wesentliche gekuerzt.
* `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales
* `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen.
* Buns Shell ist plattformunabhaengig kein `bash -lc`, das auf Windows fehlt.
*/
async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> {
try {
const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet()
const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}`
return { ok: res.exitCode === 0, out: out.slice(-4000) }
} catch (e: any) {
return { ok: false, out: String(e?.message ?? e).slice(-4000) }
}
}
/** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */
async function sendPrompt(sessionID: string, text: string): Promise<boolean> {
try {
await client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text }] },
})
return true
} catch {
return false
}
}
return {
// ── 1. Werkzeug-Zaun ───────────────────────────────────────────────────
"tool.execute.before": async (input: any, output: any) => {
if (FENCE_OFF) return
if (input?.tool !== "bash") return
const cmd: string = output?.args?.command ?? ""
if (!cmd) return
for (const rule of FENCE) {
if (rule.rx.test(cmd)) {
await announce(
"[Zaun]",
`Ich habe einen Befehl geblockt: ${rule.why}`,
"silent",
)
// Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem
// Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders.
throw new Error(
`[MC2-ZAUN] Blockiert: ${rule.why}\n` +
`Befehl war: ${cmd}\n` +
`Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` +
`er macht es selbst.`,
)
}
}
},
// ── 2.-4. Ereignisse ───────────────────────────────────────────────────
event: async ({ event }: any) => {
const type: string = event?.type ?? ""
const props: any = event?.properties ?? event ?? {}
const sessionID: string = props.sessionID || props.sessionId || props.id || ""
// ── Savepoint statt Zusammenfassen ──────────────────────────────────
if (type === "session.compacted" || type === "experimental.session.compacting") {
await announce(
"[Sitzung]",
"Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.",
"silent",
)
if (sessionID) {
await sendPrompt(
sessionID,
"[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " +
"Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " +
"erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " +
"Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.",
)
}
return
}
// ── Pruef-Tor + Selbstreparatur ─────────────────────────────────────
if (type !== "session.idle" || !sessionID) return
if (busy.has(sessionID)) return
const cmd = await readVerify()
if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus.
busy.add(sessionID)
try {
const { ok, out } = await runVerify(cmd)
const round = rounds.get(sessionID) ?? 0
if (ok) {
rounds.delete(sessionID)
await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal")
return
}
if (!AUTOFIX || round >= MAX_ROUNDS) {
rounds.delete(sessionID)
await announce(
"[Pruefung]",
`Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` +
`Hier komme ich allein nicht weiter, Commander.`,
"normal",
)
return
}
rounds.set(sessionID, round + 1)
await announce(
"[Pruefung]",
`Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`,
"silent",
)
await sendPrompt(
sessionID,
`[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` +
`Projekts ist fehlgeschlagen.\n\n` +
`Befehl: ${cmd}\n\n` +
`Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` +
`Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` +
`Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` +
`(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`,
)
} finally {
busy.delete(sessionID)
}
},
}
}
export default MC2Governor
+10 -10
View File
@@ -1,12 +1,12 @@
# Projektstart-Worker (Profil „projektstart") — IDE-Vorbereiter
Du bist der **IDE-Vorbereiter** der AI-Box. Du bearbeitest EINE Kanban-Aufgabe: eine Idee des
Commanders, die er **selbst in seiner IDE (Zed / OpenCode) bauen** will — nicht die Box.
Commanders, die er **selbst in der Agentic IDE bauen** will — nicht die Box.
Dein Ergebnis ist **kein Code**, sondern ein **startklares, leeres Projekt-Repo**: ein
wasserdicht durchdachtes Konzept plus alle Steuerungs-Dokumente, mit denen der Commander in Zed
wasserdicht durchdachtes Konzept plus alle Steuerungs-Dokumente, mit denen der Commander in der Agentic IDE
dem Coder sofort einen sauberen Auftrag geben kann. Du denkst und bereitest vor — **gebaut wird
in Zed, von ihm.**
in der Agentic IDE, von ihm.**
## Schritt 0: WIEDERAUFNAHME — immer zuerst, nie aus Reflex neu anfangen
@@ -84,13 +84,13 @@ genau diese Dateien — sonst **nichts**, kein Code, kein Gerüst:
offene Entscheidungen, was der Advocatus Diaboli geprüft hat).
- **`ROADMAP.md`** — der Etappen-/Meilenstein-Plan aus dem Konzept: jede Etappe für sich
lauffähig, in Reihenfolge, mit klarem „fertig wenn …".
- **`AGENTS.md`** — Arbeits-Konventionen für den Zed-Coder (nach dem `projekt-start`-Ritual):
- **`AGENTS.md`** — Arbeits-Konventionen für die Agentic IDE (nach dem `projekt-start`-Ritual):
Deutsch/Nicht-Entwickler, Plan-vor-Code, kleine Schritte, beweisen statt behaupten, Savepoints,
Box-Modelle je Aufgabe (plan=heavy, build=coder). Kurz, konkret, projektbezogen.
**Ganz oben hinein — wörtlich:** „Dieses Projekt ist von der AI-Box vorbereitet: KONZEPT.md ist
bereits gehärtet, ROADMAP.md ist der Plan. Beim ersten Chat den Skill **`projekt-uebernehmen`**
nutzen (einlesen → nachfragen → Etappe 1) — das Konzept wird NICHT neu geplant."
Das ist die Übergabe an den Agenten in Zed; ohne diese Zeile fängt er womöglich von vorn an.
Das ist die Übergabe an die Agentic IDE; ohne diese Zeile fängt er womöglich von vorn an.
**Dazu diese drei HARTEN Regeln — wörtlich in die AGENTS.md (Wasserdicht-Runde 22.07.,
Lehre aus rippy: Prosa-Regeln wurden komplett ignoriert, darum stehen sie ab jetzt überall):**
„1. Die **CI-Ampel** (Gitea Actions, liegt seit der Repo-Anlage unter `.gitea/workflows/ci.yml`
@@ -100,9 +100,9 @@ genau diese Dateien — sonst **nichts**, kein Code, kein Gerüst:
MakeMKV lossless' wurde still durch lossy HandBrake ersetzt.)
3. Deployment NUR über `./deploy.sh` (idempotent, EIN fester Compose-Projektname) —
nie freihändig per SSH zusammenbauen. (rippy-Lehre: Doppel-Anlage `Rippy`+`rippy` auf der VM.)"
- **`README.md`** — was das Projekt ist, in Alltagssprache; erste Schritte zum Loslegen in Zed.
- **`SAVEPOINT.md`** — Startzustand: „vorbereitet — Bau beginnt in Zed", Stand/nächste Schritte/
offene Fragen (lebendes Übergabe-Dokument für den ersten Zed-Chat).
- **`README.md`** — was das Projekt ist, in Alltagssprache; erste Schritte zum Loslegen in der Agentic IDE.
- **`SAVEPOINT.md`** — Startzustand: „vorbereitet — Bau beginnt in der Agentic IDE", Stand/nächste Schritte/
offene Fragen (lebendes Übergabe-Dokument für den ersten Chat).
- **`.aiexclude`** — gitignore-Syntax: was die KI NICHT lesen soll (`.env`, Secrets, große Daten).
Committe (`git add -A && git commit`) und pushe auf **main dieses NEUEN Repos** — das ist korrekt:
@@ -112,7 +112,7 @@ Prüfe nach dem Push mit `git ls-remote origin main`, dass es wirklich angekomme
### 4. Übergabe — `kanban_complete`
Schließe die Karte mit einer Zusammenfassung ab, die das Auftragsbuch dem Commander zeigt:
> „Projekt **<name>** ist vorbereitet. Repo: `<clone-url>`. Öffne es in Zed und wirf dem Coder
> „Projekt **<name>** ist vorbereitet. Repo: `<clone-url>`. Öffne es in der Agentic IDE und wirf dem Coder
> **KONZEPT.md** vor — der Plan steht in **ROADMAP.md**. Die Box baut ab hier nicht mehr mit."
Nenne knapp, was das Konzept-Härten ergab (Rollen-Cast, was der Skeptiker fand, welche Etappen).
@@ -130,7 +130,7 @@ DURCHDACHT haben — er hat sich NICHT entschieden, sie zu bauen. Dann gilt abwe
`AGENTS.md`, KEIN `SAVEPOINT.md`, KEIN `.aiexclude`, kein Geruest. Das entsteht erst, wenn der
Commander die Idee spaeter als Projekt startet.
- Schritt 4 (Uebergabe) NICHT als Bau-Aufforderung: sag, was die Idee waere, was sie braeuchte und
was dagegen spricht — und dass die Entscheidung bei ihm liegt. Kein „oeffne in Zed und bau".
was dagegen spricht — und dass die Entscheidung bei ihm liegt. Kein „öffne in der IDE und bau".
## Sonderfall: „UEBERARBEITUNG" (der Commander hat das Konzept gelesen)
Steht im Auftrag **`AUFTRAGS-ART: IDEE PRUEFEN — UEBERARBEITUNG`**, existiert das Konzept schon
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env python3
import sys, re, shutil, time
if len(sys.argv) < 5:
print("Nutzung: auto_adopt.py <config_path> <rolle> <new_hf_id> <new_gguf_path> [new_mmproj_path]")
sys.exit(1)
config_path = sys.argv[1]
rolle = sys.argv[2]
new_hf_id = sys.argv[3]
new_gguf_path = sys.argv[4]
new_mmproj_path = sys.argv[5] if len(sys.argv) > 5 else ""
shutil.copy(config_path, config_path + f".bak.{time.strftime('%Y%m%d%H%M%S')}")
content = open(config_path, "r", encoding="utf-8").read()
blocks = re.split(r'\n ([a-zA-Z0-9_.-]+):\n', "\n" + content)
new_content = blocks[0].lstrip("\n")
adopted = False
for i in range(1, len(blocks), 2):
m_name = blocks[i]
m_content = blocks[i+1]
if re.search(r'aliases:\s*\n(?:\s+- [^\n]+\n)*\s+- ' + re.escape(rolle) + r'\b', m_content):
m_content = re.sub(r'(-m\s+)[^\s]+', r'\g<1>' + new_gguf_path, m_content)
if new_mmproj_path:
if "--mmproj" in m_content:
m_content = re.sub(r'(--mmproj\s+)[^\s]+', r'\g<1>' + new_mmproj_path, m_content)
else:
m_content = re.sub(r'(-m\s+[^\s]+)', r'\g<1> --mmproj ' + new_mmproj_path, m_content)
else:
m_content = re.sub(r'\s*--mmproj\s+[^\s]+', '', m_content)
adopted = True
print(f"Auto-Adoption: {rolle} -> {new_hf_id} (ersetzt in {m_name})")
new_content += f" {m_name}:\n{m_content}"
with open(config_path, "w", encoding="utf-8") as f:
f.write(new_content)
if not adopted:
print(f"Warnung: Rolle {rolle} in der config nicht gefunden. Auto-Adoption übersprungen.")
sys.exit(1)
-581
View File
@@ -1,581 +0,0 @@
#!/usr/bin/env python3
# Prüfstand-Harness (17.07.2026): fährt ECHTE Prüfungen gegen ein OpenAI-kompatibles
# Endpoint (llama-swap :8080 für Amtsinhaber, Bench-Port :5899 für Kandidaten) und
# bewertet MECHANISCH — bestanden/durchgefallen statt Bauchgefühl.
#
# Suiten (deploy/pruefstand/suites/):
# coding Aufgabe → Modell schreibt Code → echte Unit-Tests laufen im Sandkasten
# agent Mehrschritt-Aufgabe mit Werkzeugen (Dateien lesen/schreiben/löschen im
# Sandkasten, Tool-Calling-API) → bewertet am ENDZUSTAND + Tool-Fehlerquote
# recherche Fragen gegen mitgelieferte Dokumente → Pflicht-Stichworte + wörtliches
# Zitat, das mechanisch gegen die Dokumente geprüft wird (Ehrlichkeits-Gate)
# deutsch Lucy-Alltags-Turns → Richter-Modell (Zwei-Kritiker-Muster) vergibt Noten
# vision generiertes Testbild beschreiben (nur für Vision-Rollen)
# speed kurze + lange Probe, pp/tg aus den llama.cpp-Timings
#
# Nutzung:
# python3 harness.py --endpoint http://127.0.0.1:8080/v1 --model hermes \
# --suites coding,agent --out ergebnis.json [--judge-model gpt-oss-120b] \
# [--deadline-min 90] [--vision-image pfad.png]
#
# Lehren eingebaut: Reasoning-Modelle brauchen großzügiges max_tokens (Verdikt 14.07.:
# knappes Budget = leerer content, nie "kaputt"); Timings stehen im "timings"-Feld der
# llama.cpp-Antwort; jeder API-Fehler = Aufgabe durchgefallen mit Grund, nie Abbruch.
import argparse
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import unicodedata
import urllib.error
import urllib.request
HIER = os.path.dirname(os.path.abspath(__file__))
SUITES_DIR = os.path.join(HIER, "suites")
# ---------------------------------------------------------------- HTTP-Helfer
def api_chat(endpoint, model, messages, max_tokens=1800, tools=None, timeout=420,
temperature=0.1):
"""Ein Chat-Call. Gibt (message-dict, timings-dict, fehler-string) zurück."""
body = {"model": model, "messages": messages, "max_tokens": max_tokens,
"temperature": temperature}
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
req = urllib.request.Request(
endpoint.rstrip("/") + "/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
d = json.loads(r.read().decode("utf-8", "replace"))
msg = d["choices"][0]["message"]
return msg, d.get("timings") or {}, ""
except Exception as e: # Timeout, HTTP-Fehler, kaputtes JSON — alles ehrlich melden
return None, {}, f"{type(e).__name__}: {e}"
def inhalt_von(msg):
"""content zuerst, reasoning_content als Notnagel (Reasoning-Modelle).
<think>-Blöcke werden entfernt: rohe :8080-Aufrufe an Denk-Modelle (Qwen3.6)
liefern den Grübel-Text im content mit bewertet wird NUR die Antwort
(Baseline-Befund 17.07.: Richter benotete sonst Denk-Monologe)."""
if not msg:
return ""
text = (msg.get("content") or msg.get("reasoning_content") or "")
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
# abgeschnittenes Denken ohne Schluss-Tag: ehrlich als leer werten
text = re.sub(r"<think>.*$", "", text, flags=re.DOTALL)
return text.strip()
def norm(s):
"""Normalisierung fürs Zitat-Gate (identisch zur idle-radar-Mechanik)."""
s = unicodedata.normalize("NFKC", s or "")
s = "".join(ch for ch in s if ch.isalnum() or ch.isspace())
return " ".join(s.lower().split())
def code_block(text, sprache="python"):
"""Letzten passenden Code-Block extrahieren; ohne Zaun: ganzen Text nehmen."""
bloecke = re.findall(rf"```(?:{re.escape(sprache)})?\s*\n(.*?)```",
text or "", re.DOTALL | re.IGNORECASE)
if bloecke:
return bloecke[-1]
bloecke = re.findall(r"```\s*\n(.*?)```", text or "", re.DOTALL)
return bloecke[-1] if bloecke else (text or "")
def lade_task(pfad):
with open(pfad, encoding="utf-8") as f:
return json.load(f)
# ---------------------------------------------------------------- Suite: coding
def suite_coding(cfg, ergebnisse):
basis = os.path.join(SUITES_DIR, "coding", "aufgaben")
for tid in sorted(os.listdir(basis)):
tdir = os.path.join(basis, tid)
if not os.path.isfile(os.path.join(tdir, "task.json")):
continue
if cfg.abgelaufen():
ergebnisse.append({"suite": "coding", "id": tid, "pass": False,
"grund": "Zeitbudget erschöpft — übersprungen"})
continue
task = lade_task(os.path.join(tdir, "task.json"))
t0 = time.time()
prompt = task["prompt"]
for name, inhalt in (task.get("dateien") or {}).items():
prompt += f"\n\n--- Datei `{name}` (Ist-Stand) ---\n```python\n{inhalt}\n```"
prompt += ("\n\nAntworte mit GENAU EINEM ```python-Code-Block, der den "
f"vollständigen Inhalt der Datei `{task['antwort_datei']}` enthält. "
"Keine Erklärungen außerhalb des Blocks.")
msg, tim, err = api_chat(cfg.endpoint, cfg.model,
[{"role": "user", "content": prompt}],
max_tokens=task.get("max_tokens", 2600))
eintrag = {"suite": "coding", "id": tid, "dauer_s": round(time.time() - t0, 1),
"tg_tps": tim.get("predicted_per_second"),
"pp_tps": tim.get("prompt_per_second")}
if err:
eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"})
ergebnisse.append(eintrag)
continue
code = code_block(inhalt_von(msg))
sbx = tempfile.mkdtemp(prefix="pruefstand-coding-")
try:
for name, inhalt in (task.get("dateien") or {}).items():
with open(os.path.join(sbx, name), "w", encoding="utf-8") as f:
f.write(inhalt)
with open(os.path.join(sbx, task["antwort_datei"]), "w",
encoding="utf-8") as f:
f.write(code)
shutil.copy(os.path.join(tdir, "verify.py"), os.path.join(sbx, "verify.py"))
p = subprocess.run([sys.executable, "verify.py"], cwd=sbx,
capture_output=True, text=True,
timeout=task.get("timeout_s", 120))
eintrag["pass"] = (p.returncode == 0)
if p.returncode != 0:
eintrag["grund"] = (p.stdout + p.stderr).strip()[-400:]
except subprocess.TimeoutExpired:
eintrag.update({"pass": False, "grund": "verify-Timeout (Endlosschleife?)"})
except Exception as e:
eintrag.update({"pass": False, "grund": f"Sandkasten-Fehler: {e}"})
finally:
shutil.rmtree(sbx, ignore_errors=True)
ergebnisse.append(eintrag)
# ---------------------------------------------------------------- Suite: agent
WERKZEUGE = [
{"type": "function", "function": {
"name": "liste_dateien",
"description": "Listet alle Dateien im Arbeitsverzeichnis (rekursiv).",
"parameters": {"type": "object", "properties": {}}}},
{"type": "function", "function": {
"name": "lies_datei",
"description": "Liest eine Datei aus dem Arbeitsverzeichnis.",
"parameters": {"type": "object", "properties": {
"pfad": {"type": "string"}}, "required": ["pfad"]}}},
{"type": "function", "function": {
"name": "schreibe_datei",
"description": "Schreibt/überschreibt eine Datei im Arbeitsverzeichnis.",
"parameters": {"type": "object", "properties": {
"pfad": {"type": "string"}, "inhalt": {"type": "string"}},
"required": ["pfad", "inhalt"]}}},
{"type": "function", "function": {
"name": "loesche_datei",
"description": "Löscht eine Datei im Arbeitsverzeichnis.",
"parameters": {"type": "object", "properties": {
"pfad": {"type": "string"}}, "required": ["pfad"]}}},
{"type": "function", "function": {
"name": "fertig",
"description": "Aufgabe abgeschlossen. Kurze Abschluss-Meldung übergeben.",
"parameters": {"type": "object", "properties": {
"meldung": {"type": "string"}}, "required": ["meldung"]}}},
]
def sicherer_pfad(sbx, pfad):
"""Pfad-Ausbruch verhindern — Sandkasten bleibt Sandkasten."""
ziel = os.path.realpath(os.path.join(sbx, pfad or ""))
if not ziel.startswith(os.path.realpath(sbx) + os.sep):
raise ValueError(f"Pfad verlässt den Sandkasten: {pfad}")
return ziel
def werkzeug_ausfuehren(sbx, name, args):
if name == "liste_dateien":
alle = []
for wurzel, _, dateien in os.walk(sbx):
for d in dateien:
alle.append(os.path.relpath(os.path.join(wurzel, d), sbx))
return "\n".join(sorted(alle)) or "(leer)"
if name == "lies_datei":
with open(sicherer_pfad(sbx, args["pfad"]), encoding="utf-8",
errors="replace") as f:
return f.read()[:20000]
if name == "schreibe_datei":
ziel = sicherer_pfad(sbx, args["pfad"])
os.makedirs(os.path.dirname(ziel), exist_ok=True)
with open(ziel, "w", encoding="utf-8") as f:
f.write(args.get("inhalt", ""))
return f"geschrieben: {args['pfad']}"
if name == "loesche_datei":
os.remove(sicherer_pfad(sbx, args["pfad"]))
return f"gelöscht: {args['pfad']}"
raise ValueError(f"unbekanntes Werkzeug: {name}")
def suite_agent(cfg, ergebnisse):
basis = os.path.join(SUITES_DIR, "agent", "aufgaben")
for tid in sorted(os.listdir(basis)):
tdir = os.path.join(basis, tid)
if not os.path.isfile(os.path.join(tdir, "task.json")):
continue
if cfg.abgelaufen():
ergebnisse.append({"suite": "agent", "id": tid, "pass": False,
"grund": "Zeitbudget erschöpft — übersprungen"})
continue
task = lade_task(os.path.join(tdir, "task.json"))
t0 = time.time()
sbx = tempfile.mkdtemp(prefix="pruefstand-agent-")
tool_fehler = 0
schritte = 0
try:
for name, inhalt in (task.get("sandkasten") or {}).items():
ziel = os.path.join(sbx, name)
os.makedirs(os.path.dirname(ziel), exist_ok=True)
with open(ziel, "w", encoding="utf-8") as f:
f.write(inhalt)
messages = [
{"role": "system", "content":
"Du bist ein Arbeits-Agent mit Datei-Werkzeugen in einem "
"Sandkasten-Verzeichnis. Erledige die Aufgabe Schritt für Schritt "
"mit den Werkzeugen und rufe am Ende `fertig` auf."},
{"role": "user", "content": task["prompt"]},
]
fertig_gemeldet = False
for _ in range(task.get("max_schritte", 8)):
schritte += 1
msg, _tim, err = api_chat(cfg.endpoint, cfg.model, messages,
max_tokens=1800, tools=WERKZEUGE)
if err:
break
calls = msg.get("tool_calls") or []
if not calls:
break # Agent redet statt zu handeln — Endzustand zählt trotzdem
messages.append({"role": "assistant",
"content": msg.get("content") or "",
"tool_calls": calls})
for call in calls:
fn = (call.get("function") or {}).get("name") or "?"
try:
args = json.loads((call.get("function") or {})
.get("arguments") or "{}")
except Exception:
args, fn_ok = {}, False
tool_fehler += 1
antwort = "FEHLER: Argumente waren kein gültiges JSON."
else:
fn_ok = True
if fn_ok:
if fn == "fertig":
fertig_gemeldet = True
antwort = "ok"
else:
try:
antwort = werkzeug_ausfuehren(sbx, fn, args)
except Exception as e:
tool_fehler += 1
antwort = f"FEHLER: {e}"
messages.append({"role": "tool",
"tool_call_id": call.get("id") or "0",
"content": antwort})
if fertig_gemeldet:
break
# -------- Endzustand prüfen (die einzige Wahrheit)
fehlschlaege = []
for check in task.get("checks", []):
if "nicht_vorhanden" in check:
if os.path.exists(os.path.join(sbx, check["nicht_vorhanden"])):
fehlschlaege.append(
f"{check['nicht_vorhanden']} existiert noch")
continue
pfad = os.path.join(sbx, check["datei"])
if not os.path.isfile(pfad):
fehlschlaege.append(f"{check['datei']} fehlt")
continue
with open(pfad, encoding="utf-8", errors="replace") as f:
text = f.read()
if check.get("regex") and not re.search(check["regex"], text,
re.IGNORECASE | re.MULTILINE):
fehlschlaege.append(
f"{check['datei']}: Muster fehlt ({check['regex']})")
ergebnisse.append({
"suite": "agent", "id": tid, "pass": not fehlschlaege,
"grund": "; ".join(fehlschlaege)[:400] if fehlschlaege else "",
"schritte": schritte, "tool_fehler": tool_fehler,
"fertig_gemeldet": fertig_gemeldet,
"dauer_s": round(time.time() - t0, 1)})
except Exception as e:
ergebnisse.append({"suite": "agent", "id": tid, "pass": False,
"grund": f"Harness-Fehler: {e}",
"dauer_s": round(time.time() - t0, 1)})
finally:
shutil.rmtree(sbx, ignore_errors=True)
# ---------------------------------------------------------------- Suite: recherche
def suite_recherche(cfg, ergebnisse):
basis = os.path.join(SUITES_DIR, "recherche")
dok_dir = os.path.join(basis, "dokumente")
dokumente = {}
for d in sorted(os.listdir(dok_dir)):
with open(os.path.join(dok_dir, d), encoding="utf-8") as f:
dokumente[d] = f.read()
alle_norm = norm("\n".join(dokumente.values()))
material = "\n\n".join(f"===== DOKUMENT {n} =====\n{t}"
for n, t in dokumente.items())
for tid in sorted(os.listdir(os.path.join(basis, "aufgaben"))):
tpfad = os.path.join(basis, "aufgaben", tid)
if not tpfad.endswith(".json"):
continue
if cfg.abgelaufen():
ergebnisse.append({"suite": "recherche", "id": tid, "pass": False,
"grund": "Zeitbudget erschöpft — übersprungen"})
continue
task = lade_task(tpfad)
t0 = time.time()
prompt = (f"{material}\n\nFRAGE: {task['frage']}\n\n"
"Beantworte die Frage NUR aus den Dokumenten oben, auf Deutsch, "
"in höchstens 4 Sätzen. Letzte Zeile deiner Antwort: "
'ZITAT: "<eine wörtlich aus einem Dokument übernommene Zeile, '
'die deine Antwort belegt>"')
msg, tim, err = api_chat(cfg.endpoint, cfg.model,
[{"role": "user", "content": prompt}],
max_tokens=2800)
eintrag = {"suite": "recherche", "id": task.get("id", tid),
"dauer_s": round(time.time() - t0, 1),
"tg_tps": tim.get("predicted_per_second"),
"pp_tps": tim.get("prompt_per_second")}
if err:
eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"})
ergebnisse.append(eintrag)
continue
antwort = inhalt_von(msg)
fehlschlaege = []
for gruppe in task.get("muss_gruppen", []):
if not any(k.lower() in antwort.lower() for k in gruppe):
fehlschlaege.append(f"Pflicht-Stichwort fehlt: {'/'.join(gruppe)}")
m = re.search(r'ZITAT:\s*[„"]?(.+?)["“”]?\s*$', antwort,
re.MULTILINE | re.DOTALL)
zitat = (m.group(1).strip() if m else "")
if task.get("zitat_pflicht", True):
if not zitat:
fehlschlaege.append("kein ZITAT geliefert")
elif norm(zitat) not in alle_norm:
fehlschlaege.append("ZITAT nicht wörtlich in den Dokumenten "
"(Ehrlichkeits-Gate)")
eintrag["pass"] = not fehlschlaege
if fehlschlaege:
eintrag["grund"] = "; ".join(fehlschlaege)[:400]
eintrag["antwort_auszug"] = antwort[:300]
ergebnisse.append(eintrag)
# ---------------------------------------------------------------- Suite: deutsch
RICHTER_RASTER = (
"Du bist ein strenger Deutsch-Richter für eine Sprach-Assistentin (Lucy): "
"locker, natürlich, Du-Form, kurze gesprochene Sätze, kein Beamten- oder "
"Übersetzungsdeutsch, keine Anglizismen-Ketten, fachlich korrekt. Bewerte die "
"ANTWORT unten auf einer Skala 110 (10 = klingt wie eine deutsche "
"Muttersprachlerin im Alltag). Antworte EXAKT in diesem Format:\n"
"NOTE: <Zahl 1-10>\nBEGRUENDUNG: <ein Satz>")
def suite_deutsch(cfg, ergebnisse):
aufgaben = lade_task(os.path.join(SUITES_DIR, "deutsch", "aufgaben.json"))
for task in aufgaben:
if cfg.abgelaufen():
ergebnisse.append({"suite": "deutsch", "id": task["id"], "pass": False,
"grund": "Zeitbudget erschöpft — übersprungen"})
continue
t0 = time.time()
msg, tim, err = api_chat(cfg.endpoint, cfg.model,
[{"role": "user", "content": task["prompt"]}],
max_tokens=2400)
eintrag = {"suite": "deutsch", "id": task["id"],
"dauer_s": round(time.time() - t0, 1),
"tg_tps": tim.get("predicted_per_second"),
"pp_tps": tim.get("prompt_per_second")}
if err:
eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"})
ergebnisse.append(eintrag)
continue
antwort = inhalt_von(msg)
jmsg, _jt, jerr = api_chat(cfg.judge_endpoint, cfg.judge_model, [
{"role": "system", "content": RICHTER_RASTER},
{"role": "user", "content":
f"AUFGABE AN DIE ASSISTENTIN: {task['prompt']}\n\n"
f"ANTWORT:\n{antwort}"}],
max_tokens=2200, temperature=0.2)
note = None
if not jerr:
m = re.search(r"NOTE:\s*(\d{1,2})", inhalt_von(jmsg))
if m:
note = max(1, min(10, int(m.group(1))))
eintrag["note"] = note
eintrag["pass"] = (note is not None and note >= 6)
if note is None:
eintrag["grund"] = f"Richter ohne Note ({jerr or 'Format'})"
if not eintrag["pass"]:
eintrag["antwort_auszug"] = antwort[:300]
ergebnisse.append(eintrag)
# ---------------------------------------------------------------- Suite: vision
def suite_vision(cfg, ergebnisse):
task = lade_task(os.path.join(SUITES_DIR, "vision", "task.json"))
bild = cfg.vision_image or os.path.join(SUITES_DIR, "vision", "testbild.png")
if not os.path.isfile(bild):
# Testbild deterministisch erzeugen (reines Python, kein PIL nötig)
subprocess.run([sys.executable,
os.path.join(SUITES_DIR, "vision", "gen_testbild.py"), bild],
check=False, timeout=30)
if not os.path.isfile(bild):
ergebnisse.append({"suite": "vision", "id": "testbild", "pass": False,
"grund": "Testbild fehlt und konnte nicht erzeugt werden"})
return
with open(bild, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
t0 = time.time()
msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
{"role": "user", "content": [
{"type": "text", "text": task["prompt"]},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"}}]}],
max_tokens=900, timeout=600)
eintrag = {"suite": "vision", "id": "testbild",
"dauer_s": round(time.time() - t0, 1),
"tg_tps": tim.get("predicted_per_second")}
if err:
eintrag.update({"pass": False, "grund": f"API-Fehler: {err}"})
ergebnisse.append(eintrag)
return
antwort = inhalt_von(msg)
fehlschlaege = [f"nicht erkannt: {'/'.join(g)}"
for g in task.get("muss_gruppen", [])
if not any(k.lower() in antwort.lower() for k in g)]
eintrag["pass"] = not fehlschlaege
if fehlschlaege:
eintrag["grund"] = "; ".join(fehlschlaege)[:300]
eintrag["antwort_auszug"] = antwort[:200]
ergebnisse.append(eintrag)
# ---------------------------------------------------------------- Suite: speed
LANG_BAUSTEIN = ("Die Box protokolliert jeden Dienststart mit Zeitstempel, "
"Dienstname und Ergebnis, damit die Morgenlage Abweichungen "
"gegen die Vorwoche erkennen und einordnen kann. ")
def suite_speed(cfg, ergebnisse):
# Kurz-Probe: misst tg (Alltags-Turn) — 2 Läufe, erster wärmt den Cache an.
for lauf in ("warm", "kurz"):
t0 = time.time()
_msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
{"role": "user", "content":
"Erkläre in drei kurzen Sätzen, was ein Mixture-of-Experts-Modell "
"ist."}], max_tokens=200, temperature=0)
if lauf == "warm":
continue
ergebnisse.append({"suite": "speed", "id": "kurz", "pass": not err,
"grund": err[:200] if err else "",
"dauer_s": round(time.time() - t0, 1),
"tg_tps": tim.get("predicted_per_second"),
"pp_tps": tim.get("prompt_per_second")})
# Lang-Probe: ~12k-Token-Prompt → misst pp bei echtem Kontext (ROCm-Watch-Metrik).
if cfg.abgelaufen():
ergebnisse.append({"suite": "speed", "id": "lang", "pass": False,
"grund": "Zeitbudget erschöpft — übersprungen"})
return
lang = LANG_BAUSTEIN * 550
t0 = time.time()
_msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
{"role": "user", "content":
lang + "\n\nWie oft steht sinngemäß derselbe Satz oben? Antworte in "
"einem Satz."}], max_tokens=80, temperature=0, timeout=900)
ergebnisse.append({"suite": "speed", "id": "lang", "pass": not err,
"grund": err[:200] if err else "",
"dauer_s": round(time.time() - t0, 1),
"tg_tps": tim.get("predicted_per_second"),
"pp_tps": tim.get("prompt_per_second"),
"prompt_tokens": tim.get("prompt_n")})
# ---------------------------------------------------------------- Hauptlauf
SUITE_FUNKS = {"coding": suite_coding, "agent": suite_agent,
"recherche": suite_recherche, "deutsch": suite_deutsch,
"vision": suite_vision, "speed": suite_speed}
class Konfig:
def __init__(self, args):
self.endpoint = args.endpoint
self.model = args.model
self.judge_endpoint = args.judge_endpoint or args.endpoint
self.judge_model = args.judge_model
self.vision_image = args.vision_image
self.deadline = (time.time() + args.deadline_min * 60
if args.deadline_min else None)
def abgelaufen(self):
return self.deadline is not None and time.time() > self.deadline
def main():
ap = argparse.ArgumentParser(description="Prüfstand-Harness")
ap.add_argument("--endpoint", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--suites", required=True,
help="kommagetrennt: coding,agent,recherche,deutsch,vision,speed")
ap.add_argument("--out", required=True)
ap.add_argument("--judge-endpoint", default="")
ap.add_argument("--judge-model", default="gpt-oss-120b")
ap.add_argument("--vision-image", default="")
ap.add_argument("--deadline-min", type=float, default=0)
args = ap.parse_args()
cfg = Konfig(args)
ergebnisse = []
for name in [s.strip() for s in args.suites.split(",") if s.strip()]:
if name not in SUITE_FUNKS:
ergebnisse.append({"suite": name, "id": "-", "pass": False,
"grund": "unbekannte Suite"})
continue
try:
SUITE_FUNKS[name](cfg, ergebnisse)
except Exception as e: # kaputte Suite darf den Gesamtlauf nicht töten
ergebnisse.append({"suite": name, "id": "-", "pass": False,
"grund": f"Suite-Absturz: {type(e).__name__}: {e}"})
# -------- Zusammenfassung je Suite (bestanden/gesamt + Tempo-Mittel)
zsm = {}
for e in ergebnisse:
s = zsm.setdefault(e["suite"], {"bestanden": 0, "gesamt": 0,
"tg": [], "pp": [], "noten": []})
s["gesamt"] += 1
s["bestanden"] += 1 if e.get("pass") else 0
if e.get("tg_tps"):
s["tg"].append(e["tg_tps"])
if e.get("pp_tps"):
s["pp"].append(e["pp_tps"])
if e.get("note") is not None:
s["noten"].append(e["note"])
for s in zsm.values():
s["tg_mittel"] = round(sum(s["tg"]) / len(s["tg"]), 1) if s["tg"] else None
s["pp_mittel"] = round(sum(s["pp"]) / len(s["pp"]), 1) if s["pp"] else None
s["note_mittel"] = (round(sum(s["noten"]) / len(s["noten"]), 1)
if s["noten"] else None)
del s["tg"], s["pp"], s["noten"]
with open(args.out, "w", encoding="utf-8") as f:
json.dump({"modell": args.model, "endpoint": args.endpoint,
"erhoben": time.strftime("%Y-%m-%d %H:%M"),
"zusammenfassung": zsm, "einzeln": ergebnisse},
f, ensure_ascii=False, indent=1)
# Kurzfassung auf stdout (für Runner-Logs)
for name, s in zsm.items():
extra = f" · Note {s['note_mittel']}" if s.get("note_mittel") else ""
tempo = (f" · tg {s['tg_mittel']} t/s" if s.get("tg_mittel") else "")
print(f"{name}: {s['bestanden']}/{s['gesamt']}{extra}{tempo}")
if __name__ == "__main__":
main()
-468
View File
@@ -1,468 +0,0 @@
#!/usr/bin/env bash
# Prüfstand-Runner (Täglich 02:00, ehemals Wöchentlich): Baut die Umgebung für den
# Harness (Modell-Sandkasten, GPU-RAM-Check) und zieht die Fäden.
# Läuft als Cron (Täglich 02:00, nach Radar/Checkout), nimmt Kandidaten aus der Queue
# und vergleicht sie gegen die BASELINE der Amtsinhaber.
#
# Modi:
# (ohne Argument) Cron-Modus: Baseline fehlt → Baseline messen; sonst ältesten
# Kandidaten aus der Queue prüfen; nichts zu tun → still.
# --baseline Amtsinhaber über llama-swap (:8080) neu vermessen.
# --kandidat <spec> einen Kandidaten-Spec (JSON) direkt prüfen.
#
# Leitplanken (User-Entscheid 17.07.): max 1 Kandidat je Lauf · Download-Deckel 35 GB
# (größere Kandidaten → Karte, Download erst nach Klick) · eigener Cache mit
# Auto-Aufräumen · Live-Betrieb bleibt unangetastet (eigener Server auf :5899) ·
# der Prüfstand EMPFIEHLT NUR — Rollen-Wechsel entscheidet der Commander per Karte.
# RAM-Wache vor dem Serverstart; Heredoc+Pipe-Falle vermieden (Temp-Dateien, Lehre 10.07.).
set -uo pipefail
MC="$HOME/mission-control-v2"
PSD="$MC/deploy/pruefstand"
VAULT="$HOME/wissens-vault"
STATE="$HOME/.hermes/state/pruefstand"
CACHE="/srv/models/pruefstand-cache"
HERMES="$HOME/.local/bin/hermes"
BENCH_BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
BENCH_PORT="${BENCH_PORT:-5899}"
LIVE="http://127.0.0.1:8080/v1"
JUDGE_MODEL="${PRUEFSTAND_JUDGE:-gpt-oss-120b}"
DECKEL_GB="${PRUEFSTAND_DECKEL_GB:-100}"
DEADLINE_MIN="${PRUEFSTAND_DEADLINE_MIN:-100}"
mkdir -p "$STATE/queue" "$STATE/done" "$STATE/ergebnisse"
# Suiten je Rolle (Amtsinhaber-Alias = Rollenname in llama-swap)
suiten_fuer() {
case "$1" in
hermes) echo "speed,agent,recherche,deutsch" ;;
coder) echo "speed,coding,agent" ;;
heavy) echo "speed,coding,recherche,deutsch" ;;
vision) echo "speed,vision" ;;
scout) echo "speed,agent,vision" ;;
*) echo "speed,recherche,deutsch" ;;
esac
}
briefkasten() { # $1=Betreff $2=Text (stiller Chronik-Spiegel, source=pruefstand)
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 - "$1" "$2" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1],
"source": "pruefstand", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
engine_build() { # installierte llama.cpp-Build-Nummer (leer, wenn nicht ermittelbar)
LD_LIBRARY_PATH="$(dirname "$BENCH_BIN")" "$BENCH_BIN" --version 2>&1 \
| grep -o 'version: [0-9]*' | grep -o '[0-9]*' || true
}
aggregiere_baseline() { # $1=Engine-Build — baut baseline.json aus den Rollen-Dateien
python3 - "$STATE" "${1:-}" <<'PY'
import glob, json, os, sys, time
state, build = sys.argv[1], sys.argv[2]
basis = {}
for f in glob.glob(os.path.join(state, "ergebnisse", "baseline-*.json")):
rolle = os.path.basename(f)[len("baseline-"):-len(".json")]
try:
basis[rolle] = json.load(open(f)).get("zusammenfassung", {})
except Exception:
pass
json.dump({"erhoben": time.strftime("%Y-%m-%d %H:%M"), "engine": build,
"rollen": basis},
open(os.path.join(state, "baseline.json"), "w"), ensure_ascii=False, indent=1)
print(f"\nBasislinie gespeichert ({len(basis)} Rollen, Engine b{build or '?'}).")
PY
}
# Tempo-Teil der Baseline neu messen (Lücke 2, 17.07.): Engine-Updates (So 04:30)
# verschieben das Tempo der Amtsinhaber — die Qualitäts-Zahlen bleiben gültig, aber
# Tempo-Vergleiche gegen eine alte Engine wären Geister-Zahlen.
speed_refresh() { # $1=neue Engine-Build-Nummer
local BUILD="$1"
echo "## PRÜFSTAND: Tempo-Baseline-Refresh (Engine jetzt b${BUILD}) am $(date '+%d.%m.%Y %H:%M')"
local rolle TMP
for rolle in hermes coder heavy vision scout; do
TMP="$STATE/ergebnisse/refresh-${rolle}.json"
echo "### $rolle"
python3 "$PSD/harness.py" --endpoint "$LIVE" --model "$rolle" --suites speed \
--out "$TMP" --judge-endpoint "$LIVE" --judge-model "$JUDGE_MODEL" \
--deadline-min 10 2>&1 | tail -2
python3 - "$STATE/ergebnisse/baseline-${rolle}.json" "$TMP" <<'PY'
import json, sys
try:
basis = json.load(open(sys.argv[1]))
neu = json.load(open(sys.argv[2]))
except Exception:
raise SystemExit
if "speed" in (neu.get("zusammenfassung") or {}):
basis.setdefault("zusammenfassung", {})["speed"] = neu["zusammenfassung"]["speed"]
basis["einzeln"] = ([e for e in basis.get("einzeln", []) if e.get("suite") != "speed"]
+ [e for e in neu.get("einzeln", []) if e.get("suite") == "speed"])
json.dump(basis, open(sys.argv[1], "w"), ensure_ascii=False, indent=1)
PY
rm -f "$TMP"
done
aggregiere_baseline "$BUILD"
briefkasten "Prüfstand" "Tempo-Baseline nach Engine-Wechsel neu gemessen (jetzt b${BUILD})."
}
zusammenfassung_lesen() { # $1=ergebnis.json → kompakte Zeilen "suite: x/y · Note · tg"
python3 - "$1" <<'PY'
import json, sys
try:
z = json.load(open(sys.argv[1])).get("zusammenfassung", {})
except Exception:
print("(Ergebnis nicht lesbar)"); raise SystemExit
for name, s in z.items():
teile = [f"{s['bestanden']}/{s['gesamt']}"]
if s.get("note_mittel"): teile.append(f"Note {s['note_mittel']}")
if s.get("tg_mittel"): teile.append(f"tg {s['tg_mittel']} t/s")
if s.get("pp_mittel"): teile.append(f"pp {s['pp_mittel']} t/s")
print(f" {name}: " + " · ".join(teile))
PY
}
# ---------------------------------------------------------------- Baseline
baseline_lauf() {
echo "## PRÜFSTAND: Basislinie der Amtsinhaber (erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo "(Echte Prüfungen: Coding mit Unit-Tests, Agenten-Aufgaben, Recherche mit Zitat-Gate, Deutsch-Richter, Tempo.)"
local rollen="hermes coder heavy vision scout"
for rolle in $rollen; do
local out="$STATE/ergebnisse/baseline-${rolle}.json"
echo
echo "### Amtsinhaber \`$rolle\`"
python3 "$PSD/harness.py" --endpoint "$LIVE" --model "$rolle" \
--suites "$(suiten_fuer "$rolle")" --out "$out" \
--judge-endpoint "$LIVE" --judge-model "$JUDGE_MODEL" \
--deadline-min 30 2>&1 | tail -8
done
# embed: nur Funktions- und Latenz-Probe (Suiten ergeben hier keinen Sinn)
echo
echo "### Amtsinhaber \`embed\`"
python3 - "$LIVE" <<'PY'
import json, sys, time, urllib.request
t0 = time.time()
req = urllib.request.Request(sys.argv[1].rstrip("/") + "/embeddings",
data=json.dumps({"model": "embed", "input": "Prüfstand-Probe"}).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
d = json.load(r)
dim = len(d["data"][0]["embedding"])
print(f" ok · Dimension {dim} · {round((time.time()-t0)*1000)} ms")
except Exception as e:
print(f" FEHLER: {e}")
PY
# Sammel-Baseline schreiben (eine Datei, die Kandidaten-Läufe vergleichen)
aggregiere_baseline "$(engine_build)"
briefkasten "Prüfstand" "Basislinie der Amtsinhaber erhoben — künftige Kandidaten werden dagegen verglichen."
}
# ---------------------------------------------------------------- Kandidat
kandidat_lauf() { # $1=spec.json
local SPEC="$1"
local KEY ROLLE HF_ID WARUM
KEY="$(jq -r '.key' "$SPEC")"
ROLLE="$(jq -r '.rolle' "$SPEC")"
HF_ID="$(jq -r '.hf_id' "$SPEC")"
WARUM="$(jq -r '.warum // ""' "$SPEC")"
echo "## PRÜFSTAND: Kandidat \`$HF_ID\` für Rolle \`$ROLLE\` (am $(date '+%d.%m.%Y %H:%M'))"
[ -n "$WARUM" ] && echo "Anlass: $WARUM"
if [ -f "$STATE/done/${KEY}.json" ]; then
echo "Schon geprüft (State done/${KEY}) — nichts zu tun."
return 0
fi
# ---- GGUF im HF-Repo finden (bevorzugt ~Q4, ein Stück, Deckel prüfen)
local TREE_JSON="$STATE/tmp-tree-${KEY}.json"
curl -sf --max-time 60 \
"https://huggingface.co/api/models/${HF_ID}/tree/main?recursive=true" \
-o "$TREE_JSON" || { echo "HF-API nicht erreichbar — Abbruch, Spec bleibt in der Queue."; return 1; }
local AUSWAHL
AUSWAHL="$(python3 - "$TREE_JSON" "$DECKEL_GB" <<'PY'
import json, re, sys
baum = json.load(open(sys.argv[1]))
deckel = float(sys.argv[2]) * 2**30
dateien = [(e.get("path",""), (e.get("lfs") or {}).get("size") or e.get("size") or 0)
for e in baum if isinstance(e, dict)]
ggufs = [(p, g) for p, g in dateien if p.lower().endswith(".gguf")]
mmproj = next((p for p, g in ggufs if "mmproj" in p.lower()), "")
haupt = [(p, g) for p, g in ggufs if "mmproj" not in p.lower()]
# Split-GGUFs (…-00001-of-…) klammern wir in v1 aus — sauberer Einzeldatei-Download
haupt = [(p, g) for p, g in haupt if not re.search(r"-\d{5}-of-\d{5}", p)]
prio = ["q4_k_m", "ud-q4_k_m", "q4_k_s", "iq4", "q5_k_m", "q4", "q8_0"]
def rang(p):
pl = p.lower()
for i, q in enumerate(prio):
if q in pl:
return i
return len(prio)
haupt.sort(key=lambda t: (rang(t[0]), t[1]))
if not haupt:
print("KEINE"); raise SystemExit
pfad, groesse = haupt[0]
status = "OK" if groesse and groesse <= deckel else "ZU_GROSS"
print(status); print(pfad); print(groesse or 0); print(mmproj)
PY
)"
rm -f "$TREE_JSON"
local STATUS PFAD GROESSE MMPROJ
STATUS="$(printf '%s\n' "$AUSWAHL" | sed -n 1p)"
PFAD="$(printf '%s\n' "$AUSWAHL" | sed -n 2p)"
GROESSE="$(printf '%s\n' "$AUSWAHL" | sed -n 3p)"
MMPROJ="$(printf '%s\n' "$AUSWAHL" | sed -n 4p)"
local GB=$(( ${GROESSE:-0} / 1073741824 ))
if [ "$STATUS" = "KEINE" ]; then
echo "Kein brauchbares Einzel-GGUF im Repo (nur Splits oder gar keins) — Karte statt Download."
"$HERMES" kanban create "Prüfstand: ${HF_ID} braucht Handarbeit (kein Einzel-GGUF)" \
--body "Kandidat für Rolle ${ROLLE}. Im HF-Repo liegt kein Einzeldatei-GGUF ≤ ${DECKEL_GB} GB (vermutlich Split-Dateien). Bei Interesse manuell laden und 'bash ~/mission-control-v2/deploy/pruefstand/pruefstand.sh --kandidat <spec>' fahren. Anlass: ${WARUM}" \
--triage --created-by pruefstand --idempotency-key "pruefstand-${KEY}" >/dev/null 2>&1 || true
mv "$SPEC" "$STATE/done/${KEY}.spec.json"
echo "{\"status\":\"kein_gguf\"}" > "$STATE/done/${KEY}.json"
return 0
fi
if [ "$STATUS" = "ZU_GROSS" ]; then
echo "GGUF ${PFAD} ist ${GB} GB > Deckel ${DECKEL_GB} GB — Download wartet auf deinen Klick (Karte liegt bereit)."
"$HERMES" kanban create "Prüfstand: ${HF_ID} (${GB} GB) — Download freigeben?" \
--body "Kandidat für Rolle ${ROLLE}, Datei ${PFAD} (${GB} GB) liegt ÜBER dem Autonomie-Deckel von ${DECKEL_GB} GB (Entscheid 17.07.). Nach Freigabe: Spec wieder in ~/.hermes/state/pruefstand/queue/ legen und PRUEFSTAND_DECKEL_GB erhöhen oder manuell laden. Anlass: ${WARUM}" \
--triage --created-by pruefstand --idempotency-key "pruefstand-${KEY}" >/dev/null 2>&1 || true
mv "$SPEC" "$STATE/done/${KEY}.spec.json"
echo "{\"status\":\"zu_gross\",\"gb\":${GB}}" > "$STATE/done/${KEY}.json"
briefkasten "Prüfstand" "Kandidat ${HF_ID} (${GB} GB) wartet auf Download-Freigabe (Deckel ${DECKEL_GB} GB)."
return 0
fi
# ---- Platz- und RAM-Wache
local FREI_GB
FREI_GB="$(df --output=avail -BG /srv/models | tail -1 | tr -dc '0-9')"
if [ "${FREI_GB:-0}" -lt $(( GB + 20 )) ]; then
echo "Zu wenig Platz auf /srv/models (${FREI_GB} GB frei, gebraucht ~$((GB+20))) — Lauf verschoben, Spec bleibt in der Queue."
return 1
fi
# ---- Download in den Cache (mit Resume, Auto-Aufräumen am Ende)
mkdir -p "$CACHE/$KEY"
local ZIEL="$CACHE/$KEY/$(basename "$PFAD")"
echo "Lade ${PFAD} (${GB} GB) …"
curl -fL --retry 3 -C - --max-time 7200 -o "$ZIEL" \
"https://huggingface.co/${HF_ID}/resolve/main/${PFAD}" \
|| { echo "Download fehlgeschlagen — Spec bleibt in der Queue."; return 1; }
local MM_ZIEL=""
if [ -n "$MMPROJ" ] && { [ "$ROLLE" = "vision" ] || [ "$ROLLE" = "scout" ]; }; then
MM_ZIEL="$CACHE/$KEY/$(basename "$MMPROJ")"
curl -fL --retry 3 -C - --max-time 3600 -o "$MM_ZIEL" \
"https://huggingface.co/${HF_ID}/resolve/main/${MMPROJ}" || MM_ZIEL=""
fi
local MEM_FREI_GB
MEM_FREI_GB="$(awk '/MemAvailable/{printf "%d", $2/1048576}' /proc/meminfo)"
if [ "${MEM_FREI_GB:-0}" -lt $(( GB + 8 )) ]; then
echo "Zu wenig freier RAM (${MEM_FREI_GB} GB) für ein ${GB}-GB-Modell neben dem Warm-Set — Lauf verschoben."
return 1
fi
# ---- Kandidaten-Server auf dem Bench-Port (Live-Betrieb unangetastet)
echo "Starte Kandidaten-Server (:${BENCH_PORT}) …"
local MMFLAG=""
[ -n "$MM_ZIEL" ] && MMFLAG="--mmproj $MM_ZIEL"
$BENCH_BIN -m "$ZIEL" --host 127.0.0.1 --port "$BENCH_PORT" \
-c 32768 -ngl 999 -fa on --no-mmap --jinja $MMFLAG \
>/tmp/pruefstand-server.log 2>&1 &
local SPID=$!
trap 'kill $SPID 2>/dev/null; wait $SPID 2>/dev/null' RETURN
local BEREIT=0
for i in $(seq 1 240); do
curl -s --max-time 2 "http://127.0.0.1:$BENCH_PORT/health" | grep -q ok && { BEREIT=1; break; }
sleep 2
kill -0 $SPID 2>/dev/null || break
done
if [ "$BEREIT" != "1" ]; then
echo "LADE-CRASH — Kandidat startet nicht (Architektur zu neu für die Engine?):"
tail -3 /tmp/pruefstand-server.log
echo "{\"status\":\"lade_crash\"}" > "$STATE/done/${KEY}.json"
mv "$SPEC" "$STATE/done/${KEY}.spec.json"
rm -rf "${CACHE:?}/$KEY"
briefkasten "Prüfstand" "Kandidat ${HF_ID} startet auf unserer Engine nicht (Lade-Crash) — aussortiert."
return 0
fi
# ---- Das volle Programm
local OUT="$STATE/ergebnisse/kandidat-${KEY}.json"
echo "Fahre Suiten: $(suiten_fuer "$ROLLE") (Zeitbudget ${DEADLINE_MIN} min)"
python3 "$PSD/harness.py" --endpoint "http://127.0.0.1:$BENCH_PORT/v1" \
--model "kandidat" --suites "$(suiten_fuer "$ROLLE")" --out "$OUT" \
--judge-endpoint "$LIVE" --judge-model "$JUDGE_MODEL" \
--deadline-min "$DEADLINE_MIN" 2>&1 | tail -8
kill $SPID 2>/dev/null; wait $SPID 2>/dev/null; trap - RETURN
# ---- MTP-Fairness (Lücke 1, 17.07.): das Live-Hirn läuft MIT draft-mtp (+26 %) —
# ein Kandidat mit eingebautem MTP-Kopf würde ohne Zusatzmessung systematisch
# unterschätzt. Erkennung: Dateiname ODER nextn-Tensoren im GGUF-Kopf.
if printf '%s' "$PFAD" | grep -qi 'mtp' || head -c 8000000 "$ZIEL" 2>/dev/null | grep -aq 'nextn'; then
echo "MTP-Kopf erkannt — Zusatzmessung mit draft-mtp (Fairness gegen das Live-Hirn) …"
$BENCH_BIN -m "$ZIEL" --host 127.0.0.1 --port "$BENCH_PORT" \
-c 32768 -ngl 999 -fa on --no-mmap --jinja $MMFLAG \
--spec-type draft-mtp --spec-draft-n-max 3 \
>/tmp/pruefstand-server-mtp.log 2>&1 &
local MPID=$!
trap 'kill $MPID 2>/dev/null; wait $MPID 2>/dev/null' RETURN
local MBEREIT=0
for i in $(seq 1 120); do
curl -s --max-time 2 "http://127.0.0.1:$BENCH_PORT/health" | grep -q ok && { MBEREIT=1; break; }
sleep 2
kill -0 $MPID 2>/dev/null || break
done
if [ "$MBEREIT" = "1" ]; then
local MOUT="$STATE/ergebnisse/kandidat-${KEY}-mtp.json"
python3 "$PSD/harness.py" --endpoint "http://127.0.0.1:$BENCH_PORT/v1" \
--model "kandidat" --suites speed --out "$MOUT" \
--judge-endpoint "$LIVE" --judge-model "$JUDGE_MODEL" --deadline-min 10 2>&1 | tail -2
python3 - "$OUT" "$MOUT" <<'PY'
import json, sys
haupt = json.load(open(sys.argv[1]))
try:
s = (json.load(open(sys.argv[2])).get("zusammenfassung") or {}).get("speed")
except Exception:
s = None
if s:
haupt.setdefault("zusammenfassung", {})["speed_mtp"] = s
json.dump(haupt, open(sys.argv[1], "w"), ensure_ascii=False, indent=1)
PY
else
echo "Kandidat startet mit draft-mtp NICHT — Zusatzmessung entfällt (im Steckbrief zählt der Basis-Wert)."
fi
kill $MPID 2>/dev/null; wait $MPID 2>/dev/null; trap - RETURN
fi
# ---- Steckbrief: Kandidat vs. Amtsinhaber
local BERICHT="$STATE/ergebnisse/kandidat-${KEY}.md"
local SIEGER_FLAG="$STATE/tmp-sieger-${KEY}"
rm -f "$SIEGER_FLAG"
python3 - "$OUT" "$STATE/baseline.json" "$ROLLE" "$HF_ID" "$WARUM" "$BERICHT" "$SIEGER_FLAG" <<'PY'
import json, sys, time
kand = json.load(open(sys.argv[1]))
try:
basis = json.load(open(sys.argv[2]))["rollen"].get(sys.argv[3], {})
except Exception:
basis = {}
rolle, hf_id, warum, ziel = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6]
kz = kand.get("zusammenfassung", {})
zeilen = [f"# Prüfstand-Steckbrief: {hf_id}",
f"Rolle: **{rolle}** · erhoben {time.strftime('%d.%m.%Y %H:%M')}",
f"Anlass: {warum}" if warum else "", "",
"| Suite | Kandidat | Amtsinhaber |", "|---|---|---|"]
punkte_k = punkte_b = faelle = 0
for name in sorted(set(kz) | set(basis)):
def fmt(s):
if not s: return "—"
t = f"{s['bestanden']}/{s['gesamt']}"
if s.get("note_mittel"): t += f" · Note {s['note_mittel']}"
if s.get("tg_mittel"): t += f" · tg {s['tg_mittel']}"
if s.get("pp_mittel"): t += f" · pp {s['pp_mittel']}"
return t
zeilen.append(f"| {name} | {fmt(kz.get(name))} | {fmt(basis.get(name))} |")
if name in kz and name in basis and not name.startswith("speed"):
faelle += 1
punkte_k += kz[name]["bestanden"] / max(1, kz[name]["gesamt"])
punkte_b += basis[name]["bestanden"] / max(1, basis[name]["gesamt"])
# Tempo: best-of speed/speed_mtp — die Baseline ist live MIT MTP gemessen (Fairness)
tg_k = max(((kz.get(s) or {}).get("tg_mittel") or 0) for s in ("speed", "speed_mtp"))
tg_b = (basis.get("speed") or {}).get("tg_mittel") or 0
zeilen.append("")
if faelle:
qk, qb = punkte_k / faelle, punkte_b / faelle
tempo = (f"Tempo {round(100*tg_k/tg_b-100):+d} % vs. Amtsinhaber" if tg_k and tg_b
else "Tempo-Vergleich unvollständig")
if qk >= qb and tg_k >= 0.8 * (tg_b or tg_k):
urteil = "KANDIDAT SIEHT STARK AUS — Auto-Adoption greift (Etappe E5)."
open(sys.argv[7], "w").write("1")
elif qk >= qb:
urteil = "Qualität hält mit, aber deutlich langsamer — vermutlich kein Aufsteiger."
else:
urteil = "Qualität unter Amtsinhaber — kein Aufsteiger."
zeilen.append(f"**Einordnung:** Bestehensquote {qk:.0%} vs. {qb:.0%} · {tempo}. {urteil}")
zeilen.append("")
zeilen.append("_Auto-Adoption (Etappe E5): Der Prüfstand tauscht Sieger-Modelle nun selbstständig aus._")
text = "\n".join(z for z in zeilen if z is not None)
open(ziel, "w", encoding="utf-8").write(text + "\n")
print(text)
PY
# ---- Vault-Bericht + Karte + stille Chronik, dann Cache aufräumen
mkdir -p "$VAULT/pruefstand"
cp "$BERICHT" "$VAULT/pruefstand/${KEY}.md" 2>/dev/null || true
( cd "$VAULT" 2>/dev/null && git add "pruefstand/${KEY}.md" >/dev/null 2>&1 \
&& git commit -q -m "pruefstand: Steckbrief ${KEY}" >/dev/null 2>&1 ) || true
"$HERMES" kanban create "Prüfstand-Ergebnis: ${HF_ID} für Rolle ${ROLLE}" \
--body "$(cat "$BERICHT")" \
--triage --created-by pruefstand --idempotency-key "pruefstand-${KEY}" >/dev/null 2>&1 || true
briefkasten "Prüfstand" "Kandidat ${HF_ID} (Rolle ${ROLLE}) geprüft — Steckbrief im Auftrags-Kanban und Vault (pruefstand/${KEY}.md)."
mv "$SPEC" "$STATE/done/${KEY}.spec.json" 2>/dev/null || true
cp "$OUT" "$STATE/done/${KEY}.json" 2>/dev/null || echo "{\"status\":\"geprueft\"}" > "$STATE/done/${KEY}.json"
# ---- Auto-Adoption (Etappe E5)
if [ -f "$SIEGER_FLAG" ]; then
echo "Kandidat hat gewonnen — starte Auto-Adoption für Rolle $ROLLE..."
local FINAL_DIR="/srv/models/${HF_ID//\//-}-GGUF"
sudo -n mkdir -p "$FINAL_DIR"
sudo -n mv "$ZIEL" "$FINAL_DIR/"
local FINAL_GGUF="$FINAL_DIR/$(basename "$PFAD")"
local FINAL_MMPROJ=""
if [ -n "$MM_ZIEL" ]; then
sudo -n mv "$MM_ZIEL" "$FINAL_DIR/"
FINAL_MMPROJ="$FINAL_DIR/$(basename "$MMPROJ")"
fi
sudo -n chown -R hitonabi:hitonabi "$FINAL_DIR" 2>/dev/null || true
# Config updaten
local CONFIG="$MC/deploy/llama-swap.config.yaml"
python3 "$PSD/auto_adopt.py" "$CONFIG" "$ROLLE" "$HF_ID" "$FINAL_GGUF" "$FINAL_MMPROJ"
# Reload llama-swap (via worker oder systemctl)
if systemctl --user is-active --quiet llama-swap; then
systemctl --user restart llama-swap
briefkasten "Prüfstand Auto-Adoption" "Rolle '$ROLLE' wurde erfolgreich durch '$HF_ID' ersetzt (Neustart erledigt)."
else
briefkasten "Prüfstand Auto-Adoption" "Rolle '$ROLLE' wurde durch '$HF_ID' ersetzt (llama-swap war inaktiv)."
fi
rm -f "$SIEGER_FLAG"
fi
rm -rf "${CACHE:?}/$KEY"
echo
echo "(Cache aufgeräumt; Roh-Ergebnisse: $STATE/ergebnisse/kandidat-${KEY}.*)"
}
# ---------------------------------------------------------------- Einstieg
case "${1:-}" in
--baseline)
baseline_lauf
;;
--kandidat)
[ -n "${2:-}" ] || { echo "Nutzung: pruefstand.sh --kandidat <spec.json>"; exit 1; }
kandidat_lauf "$2"
;;
*)
if [ ! -f "$STATE/baseline.json" ]; then
baseline_lauf
exit 0
fi
# Engine gewechselt seit der Baseline? → Tempo-Teil neu messen (Geister-Zahlen-Schutz)
BUILD="$(engine_build)"
BASE_BUILD="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("engine") or "")' "$STATE/baseline.json" 2>/dev/null || true)"
if [ -n "$BUILD" ] && [ "$BUILD" != "$BASE_BUILD" ]; then
speed_refresh "$BUILD"
fi
NAECHSTER="$(ls -1tr "$STATE/queue/"*.json 2>/dev/null | head -1 || true)"
if [ -n "$NAECHSTER" ]; then
kandidat_lauf "$NAECHSTER"
fi
# keine Queue + keine Engine-Drift → LEERER stdout → --no-agent-Cron bleibt still
;;
esac
@@ -1,13 +0,0 @@
{
"prompt": "Im Arbeitsverzeichnis liegen Notiz-Dateien im Ordner `notizen/`. Sammle ALLE Zeilen, die mit `TODO:` beginnen, und schreibe sie (ohne das `TODO:`-Präfix, eine je Zeile, Reihenfolge egal) in eine neue Datei `offene-punkte.txt` im Wurzelverzeichnis. Rufe danach `fertig` auf.",
"max_schritte": 8,
"sandkasten": {
"notizen/montag.txt": "Besprechung war ok.\nTODO: Backup-Log prüfen\nSonst nichts.\n",
"notizen/dienstag.txt": "TODO: Firmware-Update Router\nWetter schlecht.\n",
"notizen/mittwoch.txt": "Nichts offen heute.\n"
},
"checks": [
{"datei": "offene-punkte.txt", "regex": "Backup-Log prüfen"},
{"datei": "offene-punkte.txt", "regex": "Firmware-Update Router"}
]
}
@@ -1,13 +0,0 @@
{
"prompt": "Die Datei `dienst/config.ini` ist kaputt. In `anleitung.txt` steht, welche Werte gelten sollen. Repariere die config.ini entsprechend (nur die falschen Werte ändern, Struktur beibehalten) und rufe danach `fertig` auf.",
"max_schritte": 8,
"sandkasten": {
"anleitung.txt": "Sollwerte für dienst/config.ini:\n- port muss 9001 sein (Zahl, nicht Text!)\n- loglevel muss info sein\n- Der Eintrag host bleibt unverändert.\n",
"dienst/config.ini": "[server]\nhost = 127.0.0.1\nport = abc\nloglevel = debug\n"
},
"checks": [
{"datei": "dienst/config.ini", "regex": "^port\\s*=\\s*9001\\s*$"},
{"datei": "dienst/config.ini", "regex": "^loglevel\\s*=\\s*info\\s*$"},
{"datei": "dienst/config.ini", "regex": "^host\\s*=\\s*127\\.0\\.0\\.1\\s*$"}
]
}
@@ -1,11 +0,0 @@
{
"prompt": "Lies die Messwerte in `messwerte.csv` (Spalten: zeit;temperatur_c). Erstelle einen Bericht `bericht.md` mit genau zwei Zeilen: Zeile 1 `Messungen: <Anzahl der Datenzeilen>`, Zeile 2 `Maximum: <höchste Temperatur> °C` (Zahl wie in der Quelldatei geschrieben). Rufe danach `fertig` auf.",
"max_schritte": 8,
"sandkasten": {
"messwerte.csv": "zeit;temperatur_c\n03:00;61,5\n04:00;72,25\n05:00;58,0\n06:00;69,75\n"
},
"checks": [
{"datei": "bericht.md", "regex": "Messungen:\\s*4"},
{"datei": "bericht.md", "regex": "Maximum:\\s*72,25\\s*°?C"}
]
}
@@ -1,17 +0,0 @@
{
"prompt": "In `regeln.txt` steht, welche Dateien im Arbeitsverzeichnis gelöscht werden dürfen. Lösche GENAU die Dateien, die laut Regeln weg dürfen — nichts anderes. Rufe danach `fertig` auf.",
"max_schritte": 10,
"sandkasten": {
"regeln.txt": "Aufräum-Regeln:\n1. Alle Dateien mit Endung .tmp dürfen gelöscht werden.\n2. Dateien, deren Name mit wichtig beginnt, dürfen NIE gelöscht werden.\n3. Alles andere bleibt liegen.\n",
"cache-alt.tmp": "wegwerf-inhalt\n",
"sitzung.tmp": "wegwerf-inhalt\n",
"wichtig-vertrag.txt": "bleibt!\n",
"notiz.md": "bleibt auch.\n"
},
"checks": [
{"nicht_vorhanden": "cache-alt.tmp"},
{"nicht_vorhanden": "sitzung.tmp"},
{"datei": "wichtig-vertrag.txt", "regex": "bleibt!"},
{"datei": "notiz.md", "regex": "bleibt auch"}
]
}
@@ -1,5 +0,0 @@
{
"prompt": "Schreibe eine Python-Funktion `summiere_umsaetze(text)` in der Datei `loesung.py`. Eingabe ist ein CSV-Text mit Kopfzeile `kategorie;betrag` — Trennzeichen Semikolon, Beträge mit DEUTSCHEM Dezimalkomma (z. B. `12,50`). Die Funktion gibt ein dict zurück, das je Kategorie die Summe der Beträge als float enthält. Leere Zeilen und Zeilen ohne Semikolon werden ignoriert; unparsebare Beträge lösen einen ValueError mit der Zeilennummer aus.",
"antwort_datei": "loesung.py",
"timeout_s": 60
}
@@ -1,28 +0,0 @@
import sys
import unittest
from loesung import summiere_umsaetze
class Tests(unittest.TestCase):
def test_summen(self):
text = ("kategorie;betrag\n"
"essen;12,50\n"
"essen;7,50\n"
"\n"
"strom;30,00\n")
self.assertEqual(summiere_umsaetze(text),
{"essen": 20.0, "strom": 30.0})
def test_ohne_semikolon_ignoriert(self):
text = "kategorie;betrag\nnur eine notizzeile\nessen;1,00\n"
self.assertEqual(summiere_umsaetze(text), {"essen": 1.0})
def test_kaputter_betrag(self):
with self.assertRaises(ValueError):
summiere_umsaetze("kategorie;betrag\nessen;zwoelf\n")
if __name__ == "__main__":
r = unittest.main(exit=False).result
sys.exit(0 if r.wasSuccessful() else 1)
@@ -1,8 +0,0 @@
{
"prompt": "In der Datei `zeitfenster.py` steckt mindestens ein Fehler: `im_wartungsfenster(stunde)` soll True liefern, wenn die Stunde im nächtlichen Wartungsfenster von 23 Uhr bis einschließlich 4 Uhr liegt (also 23, 0, 1, 2, 3, 4) — für alle anderen Stunden False. Ungültige Stunden (<0 oder >23) sollen einen ValueError auslösen. Korrigiere die Datei und gib sie VOLLSTÄNDIG zurück, ohne die Funktionssignatur zu ändern.",
"antwort_datei": "zeitfenster.py",
"dateien": {
"zeitfenster.py": "def im_wartungsfenster(stunde):\n \"\"\"True, wenn die Stunde im Wartungsfenster 23-4 Uhr liegt.\"\"\"\n if stunde < 0 or stunde > 24:\n raise ValueError(\"ungueltige Stunde\")\n return 23 <= stunde or stunde < 4\n"
},
"timeout_s": 60
}
@@ -1,24 +0,0 @@
import sys
import unittest
from zeitfenster import im_wartungsfenster
class Tests(unittest.TestCase):
def test_im_fenster(self):
for h in (23, 0, 1, 2, 3, 4):
self.assertTrue(im_wartungsfenster(h), f"Stunde {h}")
def test_ausserhalb(self):
for h in (5, 6, 12, 18, 22):
self.assertFalse(im_wartungsfenster(h), f"Stunde {h}")
def test_ungueltig(self):
for h in (-1, 24, 99):
with self.assertRaises(ValueError):
im_wartungsfenster(h)
if __name__ == "__main__":
r = unittest.main(exit=False).result
sys.exit(0 if r.wasSuccessful() else 1)
@@ -1,5 +0,0 @@
{
"prompt": "Schreibe in `loesung.py` eine Funktion `fehler_zeilen(log_text)`. Sie bekommt Logtext, in dem relevante Zeilen so aussehen: `2026-07-17T03:15:02 ERROR dienst-name: Meldungstext` (Level kann auch INFO oder WARN sein). Die Funktion gibt eine Liste von Tupeln `(zeitstempel, dienst, meldung)` NUR für ERROR-Zeilen zurück, in Original-Reihenfolge. Zeilen in anderem Format werden still ignoriert. Der Dienstname steht vor dem Doppelpunkt und enthält keine Leerzeichen.",
"antwort_datei": "loesung.py",
"timeout_s": 60
}
@@ -1,29 +0,0 @@
import sys
import unittest
from loesung import fehler_zeilen
LOG = """2026-07-17T03:15:02 ERROR llama-swap: Modell nicht gefunden
2026-07-17T03:15:03 INFO mc2: Start ok
kaputte zeile ohne format
2026-07-17T03:16:10 WARN voice: Latenz hoch
2026-07-17T03:17:00 ERROR mem0-service: Timeout nach 30s
"""
class Tests(unittest.TestCase):
def test_nur_error(self):
erg = fehler_zeilen(LOG)
self.assertEqual(len(erg), 2)
self.assertEqual(erg[0][0], "2026-07-17T03:15:02")
self.assertEqual(erg[0][1], "llama-swap")
self.assertEqual(erg[0][2], "Modell nicht gefunden")
self.assertEqual(erg[1][1], "mem0-service")
def test_leer(self):
self.assertEqual(fehler_zeilen("nur INFO hier\n"), [])
if __name__ == "__main__":
r = unittest.main(exit=False).result
sys.exit(0 if r.wasSuccessful() else 1)
@@ -1,5 +0,0 @@
{
"prompt": "Schreibe in `loesung.py` eine Klasse `Lager`. Konstruktor ohne Argumente. Methoden: `einlagern(artikel, menge)` (menge > 0, sonst ValueError), `entnehmen(artikel, menge)` (löst ValueError aus, wenn der Bestand nicht reicht oder der Artikel unbekannt ist), `bestand(artikel)` (0 für unbekannte Artikel). Bestände sind ganze Zahlen je Artikelname.",
"antwort_datei": "loesung.py",
"timeout_s": 60
}
@@ -1,31 +0,0 @@
import sys
import unittest
from loesung import Lager
class Tests(unittest.TestCase):
def test_ablauf(self):
l = Lager()
l.einlagern("kabel", 10)
l.entnehmen("kabel", 4)
self.assertEqual(l.bestand("kabel"), 6)
self.assertEqual(l.bestand("unbekannt"), 0)
def test_unterbestand(self):
l = Lager()
l.einlagern("kabel", 2)
with self.assertRaises(ValueError):
l.entnehmen("kabel", 3)
with self.assertRaises(ValueError):
l.entnehmen("gibtsnicht", 1)
def test_menge_positiv(self):
l = Lager()
with self.assertRaises(ValueError):
l.einlagern("kabel", 0)
if __name__ == "__main__":
r = unittest.main(exit=False).result
sys.exit(0 if r.wasSuccessful() else 1)
@@ -1,5 +0,0 @@
{
"prompt": "Schreibe in `loesung.py` eine Funktion `flach(d, praefix=\"\")`, die ein beliebig tief verschachteltes dict (Werte: dicts oder Skalare) in ein flaches dict verwandelt. Schlüsselpfade werden mit Punkt verbunden, z. B. macht `{\"a\": {\"b\": 1}, \"c\": 2}` daraus `{\"a.b\": 1, \"c\": 2}`. Leere dicts verschwinden ersatzlos. Nicht-dict-Eingaben lösen einen TypeError aus.",
"antwort_datei": "loesung.py",
"timeout_s": 60
}
@@ -1,22 +0,0 @@
import sys
import unittest
from loesung import flach
class Tests(unittest.TestCase):
def test_verschachtelt(self):
self.assertEqual(flach({"a": {"b": 1, "c": {"d": 2}}, "e": 3}),
{"a.b": 1, "a.c.d": 2, "e": 3})
def test_leere_dicts(self):
self.assertEqual(flach({"a": {}, "b": 1}), {"b": 1})
def test_typfehler(self):
with self.assertRaises(TypeError):
flach([1, 2])
if __name__ == "__main__":
r = unittest.main(exit=False).result
sys.exit(0 if r.wasSuccessful() else 1)
@@ -1,5 +0,0 @@
{
"prompt": "Schreibe in `loesung.py` eine Funktion `slug(text)`: wandelt deutschen Text in einen URL-Slug. Regeln: Kleinbuchstaben; ä→ae, ö→oe, ü→ue, ß→ss (auch Großbuchstaben-Varianten); alle anderen Nicht-Alphanumerischen Zeichen werden zu einzelnen Bindestrichen zusammengefasst; keine Bindestriche am Anfang/Ende. Beispiel: `Größte \"Änderung\" 2026!` → `groesste-aenderung-2026`.",
"antwort_datei": "loesung.py",
"timeout_s": 60
}
@@ -1,21 +0,0 @@
import sys
import unittest
from loesung import slug
class Tests(unittest.TestCase):
def test_beispiel(self):
self.assertEqual(slug('Größte "Änderung" 2026!'),
"groesste-aenderung-2026")
def test_umlaute(self):
self.assertEqual(slug("Müßiggänger öfter"), "muessiggaenger-oefter")
def test_raender(self):
self.assertEqual(slug(" --Hallo Welt-- "), "hallo-welt")
if __name__ == "__main__":
r = unittest.main(exit=False).result
sys.exit(0 if r.wasSuccessful() else 1)
@@ -1,19 +0,0 @@
[
{
"id": "alltag-erklaerung",
"prompt": "Du bist eine deutsche Sprach-Assistentin. Dein Mensch fragt beim Frühstück: »Sag mal, warum wird mein Handy-Akku im Winter eigentlich so schnell leer?« Antworte gesprochen, locker, in 23 kurzen Sätzen, Du-Form."
},
{
"id": "schlechte-nachricht",
"prompt": "Du bist eine deutsche Sprach-Assistentin. Du musst deinem Menschen sagen, dass das nächtliche Backup fehlgeschlagen ist, aber kein Datenverlust entstanden ist und du es um 9 Uhr erneut versuchst. Sag es gesprochen, ruhig und ohne Technik-Kauderwelsch, in 23 Sätzen, Du-Form."
},
{
"id": "terminplanung",
"prompt": "Du bist eine deutsche Sprach-Assistentin. Dein Mensch sagt: »Leg mir bitte was mit Zahnarzt nächste Woche Dienstag Nachmittag an, so gegen drei.« Bestätige gesprochen und natürlich in 12 Sätzen, Du-Form, und nenne dabei Wochentag und Uhrzeit.",
"hinweis_fuer_richter": "Muss Dienstag und 15 Uhr / drei Uhr nachmittags korrekt bestätigen."
},
{
"id": "smalltalk-wetter",
"prompt": "Du bist eine deutsche Sprach-Assistentin. Dein Mensch kommt durchnässt rein und sagt nur: »Boah, sag nichts.« Reagiere gesprochen, warm und mit leichtem Humor, in 12 Sätzen, Du-Form — ohne aufdringlich zu sein."
}
]
@@ -1,6 +0,0 @@
{
"id": "zeitserver-ausfall",
"frage": "Was passiert, wenn der Zeitserver der Werkhalle länger ausfällt, und wie heißt er?",
"muss_gruppen": [["chronos-w"], ["Haltemodus"], ["90"]],
"zitat_pflicht": true
}
@@ -1,6 +0,0 @@
{
"id": "band-schluessel",
"frage": "Warum kann ein Dieb mit den Sicherungsbändern aus Nordhof nichts anfangen?",
"muss_gruppen": [["AES-256", "AES"], ["Schlüsseltresor", "Verwaltung"], ["verschlüsselt"]],
"zitat_pflicht": true
}
@@ -1,6 +0,0 @@
{
"id": "pv-anteil",
"frage": "Wie viel Strom lieferte die Photovoltaik-Anlage im zweiten Quartal und welchen Anteil am Verbrauch deckte das?",
"muss_gruppen": [["96"], ["Viertel", "25", "24", "23"]],
"zitat_pflicht": true
}
@@ -1,6 +0,0 @@
{
"id": "quer-wartungszugriff",
"frage": "Ein externer Techniker will nachts um 01:45 Uhr auf eine Maschinensteuerung zugreifen. Über welchen Weg muss er gehen, und warum ist dieser Zeitpunkt zusätzlich ungünstig?",
"muss_gruppen": [["falke"], ["Zwei-Faktor", "Zwei Faktor", "2-Faktor", "zweifaktor"], ["01:30", "Vollsicherung", "Sicherung"]],
"zitat_pflicht": true
}
@@ -1,17 +0,0 @@
# Backup-Konzept Weststadt-Werkhalle (Auszug)
Gesichert wird dreistufig. Stufe 1: stündliche Schnappschüsse der
Leitstand-Datenbank, Aufbewahrung 48 Stunden. Stufe 2: nächtliche
Vollsicherung aller Server um 01:30 Uhr auf den Sicherungsknoten arche-3
im Keller der Halle. Stufe 3: wöchentliche Auslagerung verschlüsselter
Sicherungsbänder in den Standort Nordhof, jeden Donnerstag per Kurier.
Die Rücksicherung der Leitstand-Datenbank dauert im Normalfall unter
20 Minuten. Eine vollständige Wiederherstellung aller Server aus arche-3
wurde zuletzt im Frühjahr geprobt und benötigte sechseinhalb Stunden.
Wichtig: Die Sicherungsbänder für Nordhof werden mit dem Verfahren
AES-256 verschlüsselt; die Schlüssel liegen NICHT in der Halle, sondern
im Schlüsseltresor der Verwaltung. Ohne Rückholung des Schlüssels aus
der Verwaltung ist eine Band-Rücksicherung unmöglich — das ist die
bewusste Absicherung gegen Diebstahl der Bänder samt Halle-Infrastruktur.
@@ -1,16 +0,0 @@
# Energie-Quartalsbericht Weststadt-Werkhalle (Auszug)
Der Stromverbrauch der Halle lag im zweiten Quartal bei 412 Megawattstunden
und damit acht Prozent unter dem Vorjahresquartal. Haupttreiber der
Einsparung war die Umrüstung der Hallenbeleuchtung auf gesteuerte
LED-Felder, die nur bei Anwesenheit auf voller Stufe laufen.
Die Photovoltaik-Anlage auf dem Süddach lieferte 96 Megawattstunden und
deckte damit knapp ein Viertel des Verbrauchs. An sonnenreichen Wochenenden
speist die Halle Überschüsse ins Netz zurück; die Vergütung dafür wird im
Jahresabschluss gesondert ausgewiesen.
Für das dritte Quartal ist die Inbetriebnahme des Batteriespeichers
(Kapazität 180 Kilowattstunden) geplant. Er soll Lastspitzen der großen
Pressen abfangen, die bisher teure Spitzenlast-Tarife auslösen. Die
Wirtschaftlichkeitsrechnung erwartet eine Amortisation nach vier Jahren.
@@ -1,17 +0,0 @@
# Netzwerk-Handbuch Weststadt-Werkhalle (Auszug)
Die Werkhalle Weststadt betreibt zwei getrennte Netze: das Betriebsnetz
(10.40.0.0/16) für Maschinensteuerungen und das Büronetz (192.168.20.0/24)
für Arbeitsplätze. Ein Übergang zwischen beiden Netzen existiert nur über
die Koppelstelle KS-2, die jede Verbindung protokolliert.
Der zentrale Zeitserver der Halle heißt chronos-w und steht im Betriebsnetz.
Alle Steuerungen gleichen ihre Uhren viertelstündlich mit chronos-w ab.
Fällt der Zeitserver länger als 90 Minuten aus, wechseln die Steuerungen in
den Haltemodus und müssen einzeln von Hand quittiert werden.
Für Wartungszugriffe von außen gilt: Zugang ausschließlich über das
Sprungsystem falke, das eine Zwei-Faktor-Anmeldung verlangt. Direkte
Zugriffe aus dem Büronetz auf Steuerungen sind technisch gesperrt.
Die Sperrliste pflegt das Team Leitstand, Änderungen brauchen zwei
Freigaben und werden erst nach dem Wochenwechsel wirksam.
@@ -1,37 +0,0 @@
#!/usr/bin/env python3
# Erzeugt das Prüfstand-Testbild deterministisch OHNE Zusatz-Pakete (reines
# zlib/struct-PNG): weißer Grund 256x256, rotes Rechteck links oben,
# blaues Rechteck rechts unten. Die Vision-Suite prüft genau diese Aussagen.
import struct
import sys
import zlib
B, H = 256, 256
def pixel(x, y):
if 24 <= x < 104 and 24 <= y < 104:
return (220, 30, 30) # rotes Rechteck links oben
if 152 <= x < 232 and 152 <= y < 232:
return (30, 60, 220) # blaues Rechteck rechts unten
return (255, 255, 255)
def chunk(typ, daten):
c = typ + daten
return struct.pack(">I", len(daten)) + c + struct.pack(">I", zlib.crc32(c))
roh = b""
for y in range(H):
roh += b"\x00" + b"".join(bytes(pixel(x, y)) for x in range(B))
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", B, H, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(roh, 9))
+ chunk(b"IEND", b""))
ziel = sys.argv[1] if len(sys.argv) > 1 else "testbild.png"
with open(ziel, "wb") as f:
f.write(png)
print(f"Testbild geschrieben: {ziel}")

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