Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6637332b3e | |||
| 76d2d7c74b | |||
| 7638f0244d | |||
| 4985977492 | |||
| f65ff48599 | |||
| 87f446c5ae | |||
| f524ef4375 | |||
| 605c9d7dd1 | |||
| 9791f44644 | |||
| 1e3e0066af | |||
| 2365f7aac6 | |||
| 56b374a0aa | |||
| eef7fdb4df | |||
| cd60974ff9 | |||
| 20d4f40ed9 | |||
| cb649aac34 | |||
| d18aac0e1b | |||
| 6c14705798 | |||
| 991c9a2f82 | |||
| ad49dd65ca | |||
| c28aa4a6a2 | |||
| 8b76c0f749 | |||
| a934deee41 | |||
| 543eee95d0 | |||
| 59d6be8c4a | |||
| 7fac17ed9a | |||
| 00f2b71a63 | |||
| 2cecd06c5b | |||
| 775e862652 | |||
| 47f7a85510 | |||
| e6502f676a | |||
| 554c87aaee | |||
| 798de0be8f | |||
| c13cfd2bd0 |
@@ -11,6 +11,10 @@
|
||||
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
||||
deploy/agent-hooks/*.py text eol=lf
|
||||
deploy/*.py text eol=lf
|
||||
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
|
||||
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
|
||||
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
|
||||
deploy/**/*.py text eol=lf
|
||||
|
||||
# Windows-Batch-Wrapper bleiben CRLF.
|
||||
*.cmd text eol=crlf
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
|
||||
#
|
||||
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
|
||||
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
|
||||
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
|
||||
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
|
||||
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
|
||||
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
|
||||
#
|
||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
||||
name: Ampel
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ampel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
|
||||
shell: bash
|
||||
run: |
|
||||
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
|
||||
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
|
||||
set -u +e
|
||||
rot=0
|
||||
|
||||
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
|
||||
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
|
||||
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
|
||||
ruff check . || rot=1
|
||||
|
||||
echo "== Python: Import/Syntax (compileall) =="
|
||||
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
|
||||
|
||||
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
|
||||
if [ -f frontend/package.json ]; then
|
||||
if [ ! -f frontend/package-lock.json ]; then
|
||||
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
|
||||
rot=1
|
||||
else
|
||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $rot -ne 0 ]; then
|
||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||
else
|
||||
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
|
||||
fi
|
||||
exit $rot
|
||||
@@ -20,3 +20,6 @@ frontend/dist/avatar.vrm
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
|
||||
@@ -1,72 +1,150 @@
|
||||
# Mission Control 2.0
|
||||
|
||||
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
|
||||
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren** —
|
||||
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
|
||||
„Werkstatt"), nicht mehr manuelle Releases.
|
||||
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
||||
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
|
||||
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
|
||||
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
||||
|
||||
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
|
||||
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
|
||||
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
|
||||
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
|
||||
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
|
||||
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
||||
|
||||
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
|
||||
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
|
||||
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
|
||||
## Die drei Bahnen
|
||||
|
||||
## Status: **live + eingefroren (MC2 Final)**
|
||||
| Bahn | Was sie tut | Wo |
|
||||
|---|---|---|
|
||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
||||
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
|
||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
||||
|
||||
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
|
||||
ohne Betreuung monatelang weiter):
|
||||
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
|
||||
Bau-Sitzung und wird nie unterbrochen.
|
||||
|
||||
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
|
||||
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
|
||||
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
|
||||
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
|
||||
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
|
||||
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
|
||||
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
|
||||
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
|
||||
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
|
||||
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
|
||||
Branch → Gate → Deploy selbst).
|
||||
## Ports & Dienste
|
||||
|
||||
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
|
||||
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
|
||||
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
|
||||
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
|
||||
| Port | Dienst | Erreichbar |
|
||||
|---|---|---|
|
||||
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
||||
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
|
||||
| `8080` | **llama-swap** — Modell-Router | LAN |
|
||||
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
|
||||
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
|
||||
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
|
||||
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
|
||||
|
||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
|
||||
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
||||
|
||||
## Der Weg einer Idee
|
||||
|
||||
```
|
||||
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Zed bauen → Ampel → main
|
||||
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
|
||||
```
|
||||
|
||||
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
||||
Wächtern geboren**:
|
||||
|
||||
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
||||
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
|
||||
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
|
||||
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||
|
||||
## Modelle & Rollen
|
||||
|
||||
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
|
||||
|
||||
| Rolle | Modell | Tempo | Aufgabe |
|
||||
|---|---|---|---|
|
||||
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
|
||||
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
|
||||
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
|
||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
|
||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
|
||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
||||
|
||||
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
||||
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
||||
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
||||
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
||||
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
||||
|
||||
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
|
||||
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
|
||||
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
||||
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
||||
|
||||
## Qualitäts-Tore
|
||||
|
||||
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
|
||||
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
|
||||
2. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
|
||||
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
|
||||
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
|
||||
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
|
||||
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
|
||||
|
||||
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
||||
Kontext-Komprimierung löscht still die Regeln mit.
|
||||
|
||||
## Selbsterhaltung
|
||||
|
||||
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
|
||||
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
|
||||
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
|
||||
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
|
||||
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
|
||||
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
|
||||
|
||||
## API (Auswahl)
|
||||
|
||||
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
||||
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
||||
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
||||
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||
|
||||
## Entwickeln
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
|
||||
.venv/Scripts/python -m uvicorn app:app --port 9000
|
||||
|
||||
# Frontend (Dev, proxyt /api → :9000)
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||
|
||||
# Frontend (Build → wird vom Backend ausgeliefert)
|
||||
cd frontend && npm run build # → frontend/dist
|
||||
```
|
||||
|
||||
**Frontend (Dev, proxyt /api → :9000):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
**Frontend (Build → wird vom Backend ausgeliefert):**
|
||||
```bash
|
||||
cd frontend && npm run build # → frontend/dist
|
||||
```
|
||||
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
|
||||
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
|
||||
|
||||
## Env-Vars (Auswahl)
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
|---|---|---|
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
||||
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
|
||||
| `MC_PORT` | `9000` | MC-Backend-Port |
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
|
||||
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
|
||||
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
|
||||
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
|
||||
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
||||
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
||||
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
||||
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
||||
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
||||
|
||||
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
|
||||
[`deploy/governor/README.md`](deploy/governor/README.md).
|
||||
|
||||
## Weiterlesen
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
||||
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
||||
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
||||
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
||||
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
||||
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
||||
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
|
||||
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
|
||||
|
||||
+7
-5
@@ -9,17 +9,16 @@ Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.requests import Request
|
||||
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
from routers import (
|
||||
agent,
|
||||
auftragsbuch,
|
||||
@@ -29,6 +28,7 @@ from routers import (
|
||||
eigenleben,
|
||||
events,
|
||||
gateway_proxy,
|
||||
governor,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
@@ -42,8 +42,9 @@ from routers import (
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import memory as memory_svc
|
||||
from services import ketten_digest, metrics_history, reminders, sentry, warmer
|
||||
from services import memory as memory_svc
|
||||
from starlette.requests import Request
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
@@ -141,6 +142,7 @@ 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
|
||||
|
||||
@@ -15,13 +15,12 @@ durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from config import LLAMA_SWAP_URL, VERSION
|
||||
from fastapi import FastAPI, Request
|
||||
from routers import gateway_proxy
|
||||
|
||||
logging.basicConfig(
|
||||
|
||||
@@ -4,8 +4,9 @@ from pathlib import Path
|
||||
# Add backend directory to sys.path so we can import services
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
|
||||
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
|
||||
from config import CONFIG_PATH
|
||||
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
||||
|
||||
|
||||
def migrate():
|
||||
print(f"Reading config from {CONFIG_PATH}...")
|
||||
|
||||
@@ -55,6 +55,17 @@
|
||||
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
||||
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
|
||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
||||
},
|
||||
|
||||
{
|
||||
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
|
||||
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
|
||||
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||
},
|
||||
{
|
||||
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
|
||||
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
|
||||
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import auftragsbuch
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -3,10 +3,8 @@ Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wäc
|
||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -14,11 +14,10 @@ import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from config import BOX_CONSOLE_UPSTREAM
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from config import BOX_CONSOLE_UPSTREAM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,7 +27,7 @@ def _register(base: str, upstream: str) -> None:
|
||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
@router.websocket(f"/{base}/ws")
|
||||
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
|
||||
async def _proxy_ws(ws: WebSocket) -> None:
|
||||
await ws.accept(subprotocol="tty")
|
||||
try:
|
||||
async with websockets.connect(
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from pydantic import BaseModel
|
||||
from services import eigenleben
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -27,11 +27,10 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -11,11 +11,10 @@ entfernen → app.py bindet wieder den lokalen Gateway ein.
|
||||
|
||||
import logging
|
||||
|
||||
from config import V1_UPSTREAM
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from config import V1_UPSTREAM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
@@ -2,11 +2,9 @@ import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from config import LLAMA_SWAP_URL
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from config import LLAMA_SWAP_URL
|
||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Governor — Fenster auf den Token-Waechter (:8100).
|
||||
|
||||
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
|
||||
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
|
||||
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
|
||||
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
|
||||
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
|
||||
zweiten Port ausweichen muss.
|
||||
|
||||
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
|
||||
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
|
||||
|
||||
|
||||
@router.get("/governor")
|
||||
async def governor_status() -> dict:
|
||||
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as c:
|
||||
r = await c.get(f"{GOVERNOR_URL}/governor/status")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
data["reachable"] = True
|
||||
return data
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"url": GOVERNOR_URL,
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from config import VERSION
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import gateway, llamaswap
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -22,11 +22,10 @@ import re
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuc
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import ideen
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -27,6 +26,15 @@ class AntwortIn(BaseModel):
|
||||
antwort: str
|
||||
|
||||
|
||||
class ArchivAlleIn(BaseModel):
|
||||
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
|
||||
projekt: str = ""
|
||||
|
||||
|
||||
class EinordnenIn(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.get("/ideen")
|
||||
def list_queue() -> dict:
|
||||
return ideen.list_queue()
|
||||
@@ -115,6 +123,22 @@ def archive(body: TaskIn) -> dict:
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/einordnen")
|
||||
def einordnen(body: EinordnenIn) -> dict:
|
||||
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
|
||||
und die Oberfläche behält ihre Voreinstellung."""
|
||||
return ideen.einordnen(body.text)
|
||||
|
||||
|
||||
@router.post("/ideen/archivieren-alle")
|
||||
def archive_alle(body: ArchivAlleIn) -> dict:
|
||||
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
|
||||
res = ideen.archive_done(body.projekt)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/log")
|
||||
def get_log(id: str) -> dict:
|
||||
return ideen.log_of(id)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import maintenance
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -4,7 +4,6 @@ import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import memory
|
||||
from services.voice_metrics import park, record_stage
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
||||
|
||||
import psutil
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from services import budget, discover, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
|
||||
@@ -140,8 +139,7 @@ def install(req: InstallReq) -> dict:
|
||||
|
||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||
args = [hf.hf_bin(), "download", repo]
|
||||
for f in info["files"]:
|
||||
args.append(f)
|
||||
args.extend(info["files"])
|
||||
if info["mmproj"]:
|
||||
args.append(info["mmproj"])
|
||||
args += ["--local-dir", str(target)]
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
|
||||
LAN-only wie alle MC2-Endpoints."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from pydantic import BaseModel
|
||||
from services import reminders
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import gateway
|
||||
from services.routing_policy import policy_meta, save_policy
|
||||
|
||||
|
||||
@@ -9,12 +9,10 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
import httpx
|
||||
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from services import backup as backup_svc
|
||||
from services import maintenance
|
||||
from services.agent import agent_status
|
||||
@@ -67,7 +65,7 @@ def _user_unit_active(unit: str) -> bool:
|
||||
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
return r.stdout.strip() == "active"
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -135,7 +133,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from services import announce
|
||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||
Timer,
|
||||
@@ -29,8 +31,6 @@ from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (inter
|
||||
record_stage,
|
||||
)
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||
if _GUARD_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _GUARD_DIR)
|
||||
@@ -155,7 +155,7 @@ def voice_health() -> dict:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
out["sidecar"] = r.status_code == 200
|
||||
out["detail"] = r.json() if r.status_code == 200 else None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
@@ -180,7 +180,7 @@ def voice_voices() -> dict:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ def voice_get_reference() -> dict:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"active": False, "error": str(exc)}
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import backup as backup_svc
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -10,12 +10,10 @@ Permission errors are handled gracefully.
|
||||
Uses user journal (systemctl --user) to query logs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOG_DIR = Path.home() / "logs"
|
||||
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
||||
SERVICE_NAME = "mission-control-2.service"
|
||||
|
||||
@@ -9,11 +9,15 @@ import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
|
||||
from config import (BOX_CONSOLE_UPSTREAM,
|
||||
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
|
||||
from config import (
|
||||
BOX_CONSOLE_PATH,
|
||||
BOX_CONSOLE_UPSTREAM,
|
||||
HERMES_API_URL,
|
||||
HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_BUILTIN_UI_UPSTREAM,
|
||||
HERMES_HOME,
|
||||
PC_EXECUTOR_URL,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -194,7 +198,7 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||
from services.llamaswap import set_ttl, DEFAULT_TTL
|
||||
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||
set_ttl(model_id, 0)
|
||||
if old:
|
||||
set_ttl(old, DEFAULT_TTL)
|
||||
@@ -253,7 +257,7 @@ def update_brain_model(new_model: str) -> bool:
|
||||
|
||||
# Restart the user-space service to apply changes
|
||||
try:
|
||||
import services.maintenance as maintenance
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
@@ -20,7 +20,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,7 +51,7 @@ def backup_now() -> dict:
|
||||
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
||||
|
||||
latest = _latest()
|
||||
|
||||
@@ -10,7 +10,6 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
||||
|
||||
DEFAULT_HOST = "192.168.178.151"
|
||||
@@ -139,7 +138,7 @@ def check_health() -> dict:
|
||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||
else:
|
||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
@@ -148,7 +147,7 @@ def check_health() -> dict:
|
||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
@@ -157,7 +156,7 @@ def check_health() -> dict:
|
||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||
|
||||
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
import logging
|
||||
|
||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||
|
||||
from services import catalog
|
||||
from services.caps import capabilities
|
||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||
|
||||
@@ -5,8 +5,8 @@ V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from config import PORT, V1_UPSTREAM
|
||||
|
||||
from services.llamaswap import engine_reachable
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
@@ -50,7 +50,7 @@ def gateway_reachable() -> bool:
|
||||
if V1_UPSTREAM:
|
||||
try:
|
||||
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
return False
|
||||
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
|
||||
return engine_reachable()
|
||||
|
||||
+107
-9
@@ -22,6 +22,9 @@ import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from config import GATEWAY_URL, V1_UPSTREAM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
|
||||
@@ -109,7 +112,7 @@ def _kurz(text: str, max_len: int = 80) -> str:
|
||||
return (schnitt or t[:max_len]) + "…"
|
||||
|
||||
|
||||
_TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-–]?\s*", re.I)
|
||||
_TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-–]?\s*", re.IGNORECASE)
|
||||
|
||||
|
||||
def _projekt_titel(titles: list[str]) -> str:
|
||||
@@ -220,7 +223,7 @@ def _ketten_anreichern(items: list[dict]) -> list[dict]:
|
||||
for it in items:
|
||||
familien.setdefault(boss(it["id"]), []).append(it)
|
||||
projekte = []
|
||||
for wurzel, mitglieder in familien.items():
|
||||
for mitglieder in familien.values():
|
||||
if len(mitglieder) < 2:
|
||||
continue
|
||||
mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
|
||||
@@ -509,6 +512,101 @@ def archive_task(task_id: str) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
_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: 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 "
|
||||
"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"
|
||||
"nur_denken=true: der Text stellt eine FRAGE oder waegt ab (\"lohnt sich\", \"waere es sinnvoll\", "
|
||||
"\"brauche ich\") statt einen Auftrag zu geben."
|
||||
)
|
||||
|
||||
|
||||
def einordnen(text: str) -> dict:
|
||||
"""Grober Einordnungs-VORSCHLAG für eine frisch getippte Idee.
|
||||
|
||||
Nur eine Vorauswahl in der Oberfläche — der Mensch sieht und überschreibt sie. Deshalb
|
||||
bewusst anspruchslos: ein einziger kurzer Aufruf ans dauerwarme Hirn (69,6 t/s gemessen),
|
||||
Schnellspur-Marker gegen das Nachdenken, hartes Timeout. Scheitert irgendetwas, kommt
|
||||
`{"ok": False}` zurück und die Oberfläche behält schlicht ihre Voreinstellung — eine
|
||||
Einordnung darf das Tippen NIE blockieren oder eine Fehlermeldung erzeugen.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if len(text) < 12:
|
||||
return {"ok": False}
|
||||
ziel_url = (V1_UPSTREAM or GATEWAY_URL).rstrip("/")
|
||||
marker = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
|
||||
try:
|
||||
r = httpx.post(f"{ziel_url}/v1/chat/completions", timeout=12.0, json={
|
||||
"model": "hermes", "temperature": 0, "max_tokens": 60, "stream": False,
|
||||
"messages": [
|
||||
{"role": "system", "content": _EINORDNEN_SYS},
|
||||
{"role": "user", "content": f"{marker} {text[:600]}"},
|
||||
],
|
||||
})
|
||||
r.raise_for_status()
|
||||
inhalt = (r.json()["choices"][0]["message"]["content"] or "").strip()
|
||||
except Exception:
|
||||
log.debug("ideen: einordnen fehlgeschlagen", exc_info=True)
|
||||
return {"ok": False}
|
||||
|
||||
# Modelle packen JSON gern in einen Codezaun oder schreiben einen Satz davor.
|
||||
m = re.search(r"\{.*\}", inhalt, re.DOTALL)
|
||||
if not m:
|
||||
return {"ok": False}
|
||||
try:
|
||||
d = json.loads(m.group(0))
|
||||
except Exception:
|
||||
return {"ok": False}
|
||||
|
||||
wer = d.get("wer") if d.get("wer") in ("box", "zed") 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",
|
||||
"nur_denken": bool(d.get("nur_denken"))}
|
||||
|
||||
|
||||
def archive_done(projekt: str = "") -> dict:
|
||||
"""Alle FERTIGEN Karten wegräumen — optional nur die eines Projekts.
|
||||
|
||||
Sammel-Aufräumen (25.07.2026): Bei fünf abgeschlossenen Projekt-Ketten wären das
|
||||
sonst ~18 Einzelklicks. Wie beim Einzel-Archivieren wird nichts gelöscht — die
|
||||
Karten wandern ins Kanban-Archiv und bleiben dort lesbar.
|
||||
|
||||
‼️ Bewusst NUR `done`. Laufende, hängende, wartende und in Ausarbeitung befindliche
|
||||
Karten werden nie angefasst, auch nicht bei leerem `projekt`-Filter — ein Sammel-
|
||||
Knopf darf niemals versehentlich Arbeit abräumen.
|
||||
"""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
q = list_queue()
|
||||
if not q.get("available"):
|
||||
return {"ok": False, "error": q.get("fehler") or "Queue nicht lesbar."}
|
||||
|
||||
ziel = [i for i in q.get("items", [])
|
||||
if i.get("status") == "done" and (not projekt or i.get("projekt") == projekt)]
|
||||
if not ziel:
|
||||
return {"ok": True, "archiviert": 0, "fehlgeschlagen": []}
|
||||
|
||||
ok, fehler = 0, []
|
||||
for item in ziel:
|
||||
r = archive_task(item.get("id", ""))
|
||||
if r.get("ok"):
|
||||
ok += 1
|
||||
else:
|
||||
# Die Items aus list_queue tragen "titel" (deutsch), nicht das rohe "title".
|
||||
fehler.append({"id": item.get("id"), "titel": _kurz(item.get("titel") or "", 60),
|
||||
"error": r.get("error", "")})
|
||||
_list_cache["ts"] = 0.0
|
||||
return {"ok": True, "archiviert": ok, "fehlgeschlagen": fehler}
|
||||
|
||||
|
||||
def ergebnis_of(task_id: str) -> dict:
|
||||
"""Das ERGEBNIS einer fertigen Aufgabe — die Abschluss-Zusammenfassung des Workers.
|
||||
|
||||
@@ -752,8 +850,8 @@ def _lokales_konzept(text: str, titel: str = "") -> tuple | None:
|
||||
if not kandidaten and titel and _KONZEPT_DIR.is_dir():
|
||||
worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())}
|
||||
treffer = [p.name for p in _KONZEPT_DIR.glob("*.md")
|
||||
if (lambda g: len(g) >= 2 or any(len(w) >= 6 for w in g))(
|
||||
worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower())))]
|
||||
if len(g := worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower()))) >= 2
|
||||
or any(len(w) >= 6 for w in g)]
|
||||
if len(treffer) == 1:
|
||||
kandidaten.append(treffer[0])
|
||||
for name in kandidaten:
|
||||
@@ -935,7 +1033,7 @@ def _konzept_und_name(task_id: str) -> tuple:
|
||||
quell_titel = _TITEL_PRAEFIX_RX.sub("", str(t.get("title") or "").strip())
|
||||
if not quell_titel:
|
||||
return {}, "", "Quell-Karte hat keinen Titel."
|
||||
h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.M)
|
||||
h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.MULTILINE)
|
||||
kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0]
|
||||
return konz, _kurz(kurz, 85), ""
|
||||
|
||||
@@ -1052,9 +1150,9 @@ def konzept_ueberarbeiten(task_id: str, hinweis: str) -> dict:
|
||||
"als `KONZEPT.md` hinein (klonen, schreiben, pushen, Push beweisen)."
|
||||
if konz.get("repo") else ""))
|
||||
|
||||
teile = [f"{_UEBERARBEITEN_KOPF}\n{quelle}\n"
|
||||
f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n „{hinweis}“\n"
|
||||
f"{_UEBERARBEITEN_FUSS}",
|
||||
teile = [(f"{_UEBERARBEITEN_KOPF}\n{quelle}\n"
|
||||
f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n „{hinweis}“\n"
|
||||
f"{_UEBERARBEITEN_FUSS}"),
|
||||
_INFRA_LXC]
|
||||
titel = f"Konzept nachschärfen: {kurz}"[:200]
|
||||
args = ["create", titel, "--body", "\n\n".join(teile)[:4000], "--assignee", "projektstart",
|
||||
@@ -1098,7 +1196,7 @@ def log_of(task_id: str) -> dict:
|
||||
|
||||
try:
|
||||
r = _hermes(["log", task_id])
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
log.warning("ideen: kanban log fehlgeschlagen", exc_info=True)
|
||||
return {"available": True, "lines": []}
|
||||
if r.returncode != 0:
|
||||
|
||||
@@ -90,7 +90,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
|
||||
log_str = "\n".join(job["log"])
|
||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||
job["sudo_failed"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["returncode"] = -1
|
||||
@@ -101,7 +101,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
|
||||
if cb and job["state"] == "done":
|
||||
try:
|
||||
cb()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
j["rate_bps"] = rate
|
||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||
prev_t, prev_b = now, cur
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
j = JOBS.get(job_id)
|
||||
@@ -182,7 +182,7 @@ def cancel_job(job_id: str) -> bool:
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
job["state"] = "canceled"
|
||||
|
||||
@@ -87,7 +87,6 @@ def _tick() -> None:
|
||||
return
|
||||
items = data.get("items") or []
|
||||
projekte = data.get("projekte") or []
|
||||
by_id = {i["id"]: i for i in items if i.get("id")}
|
||||
state = _load_state()
|
||||
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
||||
projekt_state: dict = state.setdefault("projekte", {})
|
||||
|
||||
@@ -11,12 +11,17 @@ import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from config import (
|
||||
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
|
||||
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
|
||||
CMD_TEMPLATE,
|
||||
CONFIG_PATH,
|
||||
DEFAULT_TTL,
|
||||
DRAFTS_DIR,
|
||||
LLAMA_SWAP_URL,
|
||||
SPEC_DRAFT_MODEL_PATH,
|
||||
SPEC_DRAFT_N_MAX,
|
||||
SPEC_TYPE,
|
||||
)
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -143,13 +148,13 @@ def model_id_from_path(model_path: str) -> str:
|
||||
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
||||
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
||||
d = os.path.basename(os.path.dirname(model_path))
|
||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
|
||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
|
||||
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
|
||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
|
||||
if not name:
|
||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
|
||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
|
||||
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
||||
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
|
||||
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
|
||||
return name or "modell"
|
||||
|
||||
|
||||
@@ -222,7 +227,7 @@ def write_config(cfg: dict) -> None:
|
||||
try:
|
||||
from services import warmer
|
||||
warmer.nudge()
|
||||
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
|
||||
except Exception:
|
||||
pass
|
||||
except PermissionError as exc:
|
||||
raise PermissionError(
|
||||
|
||||
@@ -35,7 +35,7 @@ _engine_cache = {"ts": 0.0, "avail": False}
|
||||
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
|
||||
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
|
||||
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
|
||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
|
||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _latest_engine_asset_release() -> dict | None:
|
||||
@@ -289,7 +289,7 @@ def model_upgrades() -> list[dict]:
|
||||
continue
|
||||
|
||||
base = rec.split("/")[-1].lower()
|
||||
stem = base[:-5] if base.endswith("-gguf") else base
|
||||
stem = base.removesuffix("-gguf")
|
||||
if base in cmds or (stem and stem in cmds):
|
||||
continue # schon installiert
|
||||
out.append({"role": role, "title": c["title"], "repo": rec})
|
||||
@@ -343,7 +343,7 @@ def _os_held_back() -> list[dict]:
|
||||
held.append({"name": name, "reason": reason})
|
||||
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
||||
reason = None
|
||||
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
|
||||
except Exception:
|
||||
pass
|
||||
held.sort(key=lambda p: p["name"])
|
||||
return held
|
||||
@@ -368,7 +368,7 @@ def os_update_details() -> dict:
|
||||
out_pkgs.sort(key=lambda p: p["name"])
|
||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
||||
"held_back": _os_held_back()}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ def engine_update_details() -> dict:
|
||||
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
||||
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
||||
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -418,7 +418,7 @@ def swap_update_details() -> dict:
|
||||
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
||||
"verteilt und Sprachmodelle heiß nachlädt).")
|
||||
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -496,7 +496,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
|
||||
if text:
|
||||
cache.update(key=key, data=data)
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
|
||||
except Exception as exc:
|
||||
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
||||
"action_needed": None, "action_text": ""}
|
||||
|
||||
@@ -534,7 +534,7 @@ def hermes_update_details() -> dict:
|
||||
info["behind"] = len(commits)
|
||||
if commits:
|
||||
info.update(_summarize_hermes_commits(commits))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -575,7 +575,7 @@ def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
||||
|
||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
import httpx
|
||||
|
||||
from config import MEM0_SERVICE_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,7 +51,7 @@ def _load() -> None:
|
||||
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 — kaputte Datei = leer starten, nie crashen
|
||||
except Exception:
|
||||
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
@@ -77,19 +77,19 @@ def _sample() -> None:
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||
disk = du.percent
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
gpu = None
|
||||
try:
|
||||
g = _gpu_sysfs()
|
||||
gpu = g.get("busy_percent") if g else None
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
tp = tc = None
|
||||
try:
|
||||
ts = get_stats()
|
||||
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
||||
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
||||
@@ -101,12 +101,12 @@ async def sampler_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_sample)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
||||
if time.time() - _last_flush >= FLUSH_S:
|
||||
try:
|
||||
await asyncio.to_thread(_flush)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(SAMPLE_S)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
from services import announce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,8 +20,8 @@ import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
|
||||
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
from services import announce, llamaswap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -100,7 +100,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
||||
|
||||
|
||||
class _Watch:
|
||||
__slots__ = ("fails", "alerted", "alert_ts")
|
||||
__slots__ = ("alert_ts", "alerted", "fails")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fails = 0 # Fehl-Ticks in Folge
|
||||
|
||||
@@ -12,7 +12,6 @@ import subprocess
|
||||
import threading
|
||||
|
||||
import psutil
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from config import HERMES_HOME
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
|
||||
from config import LLAMA_SWAP_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -74,7 +73,18 @@ def _run_warmup() -> bool:
|
||||
|
||||
|
||||
def _warmset_members() -> set[str]:
|
||||
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
|
||||
"""Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
|
||||
|
||||
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
|
||||
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
|
||||
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
|
||||
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
|
||||
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
|
||||
nur die erste ausdrücken.
|
||||
"""
|
||||
explizit = os.environ.get("MC_WARMSET", "").split()
|
||||
if explizit:
|
||||
return set(explizit)
|
||||
try:
|
||||
from services import llamaswap
|
||||
groups = llamaswap.list_groups() or {}
|
||||
|
||||
@@ -154,7 +154,7 @@ async def volume_control(req: dict):
|
||||
async def open_target(req: dict):
|
||||
target = req.get("target", "")
|
||||
try:
|
||||
if target.startswith("http://") or target.startswith("https://"):
|
||||
if target.startswith(("http://", "https://")):
|
||||
webbrowser.open(target)
|
||||
else:
|
||||
os.startfile(target)
|
||||
|
||||
@@ -20,7 +20,10 @@ nur Schreib-Operationen.
|
||||
|
||||
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
||||
"""
|
||||
import sys, json, os, re
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def out(obj):
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# curator-monitor.sh
|
||||
# Prüft den Curator-Idle-Status mit 7-Tage-Schwellenwert.
|
||||
# GIBT AUS: PASS oder FAIL mit Detaillierung.
|
||||
# EXIT 0 = OK (Curator aktiv innerhalb von 7 Tagen)
|
||||
# EXIT 1 = FAIL (Curator >7 Tage idle) –Monitoring-Skript erkannt Problem
|
||||
#
|
||||
# --dry-run DAYS Simuliert eine >DAYS-alte Curator-Auszeit (für Tests)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
|
||||
LOG_FILE="$LOG_DIR/monitor-$(date +%Y%m%d).log"
|
||||
THRESHOLD_DAYS=7
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# --- Argumente parsen ---
|
||||
DRY_RUN=false
|
||||
DRY_RUN_DAYS=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
|
||||
DRY_RUN_DAYS="$2"
|
||||
shift
|
||||
else
|
||||
DRY_RUN_DAYS=8 # Default-Testwert
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- LIVE-PRÜFUNG oder DRY-RUN ---
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log "DRY-RUN: Simuliere Curator-Auszeit von $DRY_RUN_DAYS Tagen"
|
||||
DAYS_SINCE_LAST_RUN="$DRY_RUN_DAYS"
|
||||
else
|
||||
# Live-Status abfragen
|
||||
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
|
||||
log "FEHLER: hermes curator status fehlgeschlagen"
|
||||
echo "FAIL: hermes curator status fehlgeschlagen"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Prüfen, ob Curator ENABLED ist
|
||||
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
|
||||
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
|
||||
echo "PASS: Curator ist nicht ENABLED (keine Aktivität nötig)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Letzten Lauf extrahieren
|
||||
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
|
||||
if [[ -z "$LAST_RUN_LINE" ]]; then
|
||||
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
|
||||
echo "FAIL: Konnte 'last run' nicht extrahieren"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
|
||||
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
|
||||
|
||||
# Tage extrahieren
|
||||
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
|
||||
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
|
||||
else
|
||||
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen)"
|
||||
echo "PASS: Curator ist aktuell aktiv oder <1 Tag idle"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
|
||||
|
||||
# --- MONITORING-LOGIK (keine Reaktivierung!) ---
|
||||
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
|
||||
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage)"
|
||||
echo "PASS: Curator ist aktiv ($DAYS_SINCE_LAST_RUN Tage idle)"
|
||||
exit 0
|
||||
else
|
||||
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS)"
|
||||
echo "FAIL: Curator-Cron ist für $DAYS_SINCE_LAST_RUN Tage stumm (>= $THRESHOLD_DAYS-Tage-Schwellenwert)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -8,7 +8,6 @@ und verschiebt in .archive/<Kategorie>/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -89,6 +89,9 @@ cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.t
|
||||
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
|
||||
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"
|
||||
@@ -105,6 +108,8 @@ cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
|
||||
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"
|
||||
@@ -248,6 +253,7 @@ systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
|
||||
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
|
||||
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
|
||||
|
||||
@@ -15,10 +15,10 @@ verschlucken und den Tabu-Guard lautlos deaktivieren.
|
||||
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
|
||||
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Alte Crons löschen
|
||||
hermes cron remove traum || true
|
||||
hermes cron remove chef-gutachter || true
|
||||
hermes cron remove release-radar || true
|
||||
hermes cron remove idle-radar || true
|
||||
hermes cron remove selbst-inventur || true
|
||||
hermes cron remove karten-gutachter || true
|
||||
hermes cron remove pruefstand || true
|
||||
hermes cron remove trend-radar || true
|
||||
|
||||
# Neue Crons registrieren mit Wrapper und --deliver origin.
|
||||
# Dadurch schweigt Hermes cron selbst, und der Wrapper nutzt notify.sh für den Morgen-Digest.
|
||||
hermes cron create --name traum --deliver origin "15 3 * * *" "Traum dreaming-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name chef-gutachter --deliver origin "30 4 * * *" "Chef-Gutachter chef-gutachter-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name release-radar --deliver origin "15 5 * * 1" "Release-Radar hermes-release-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name idle-radar --deliver origin "45 3 * * *" "Idle-Radar idle-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name selbst-inventur --deliver origin "35 3 * * *" "Selbst-Inventur selbst-inventur.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name karten-gutachter --deliver origin "0 4 * * *" "Karten-Gutachter karten-gutachter.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name pruefstand --deliver origin "0 2 * * *" "Prüfstand pruefstand.sh" --script night-cron-wrapper.sh --no-agent
|
||||
hermes cron create --name trend-radar --deliver origin "15 5 * * 6" "Trend-Radar trend-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
|
||||
|
||||
echo "Crons erfolgreich gefixt!"
|
||||
+23
-12
@@ -79,26 +79,37 @@ case "$CODE" in
|
||||
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
|
||||
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
|
||||
# dedizierte Anlage-Token hat u.U. nur write:user).
|
||||
AMPEL="$REALHOME/mission-control-v2/deploy/ampel-ci.yml"
|
||||
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
|
||||
if [ -r "$AMPEL" ]; then
|
||||
SEED_CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/.gitea/workflows/ci.yml" \
|
||||
# saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
|
||||
saat () {
|
||||
local QUELLE="$1" ZIEL="$2" MSG="$3" NAME="$4" CODE
|
||||
if [ ! -r "$QUELLE" ]; then
|
||||
echo "WARNUNG: Vorlage fehlt ($QUELLE) — Repo ohne $NAME angelegt." >&2
|
||||
return
|
||||
fi
|
||||
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/$ZIEL" \
|
||||
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
|
||||
--data "$(python3 - "$AMPEL" <<'PY'
|
||||
--data "$(python3 - "$QUELLE" "$MSG" <<'PY'
|
||||
import base64, json, sys
|
||||
inhalt = open(sys.argv[1], "rb").read()
|
||||
print(json.dumps({"content": base64.b64encode(inhalt).decode(),
|
||||
"message": "CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)"}))
|
||||
print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
|
||||
PY
|
||||
)")"
|
||||
if [ "$SEED_CODE" = "201" ]; then
|
||||
echo "CI-Ampel eingepflanzt (.gitea/workflows/ci.yml)."
|
||||
if [ "$CODE" = "201" ]; then
|
||||
echo "$NAME eingepflanzt ($ZIEL)."
|
||||
else
|
||||
echo "WARNUNG: CI-Ampel-Seed antwortete HTTP $SEED_CODE (Repo ist trotzdem da)." >&2
|
||||
echo "WARNUNG: $NAME-Seed antwortete HTTP $CODE (Repo ist trotzdem da)." >&2
|
||||
fi
|
||||
else
|
||||
echo "WARNUNG: Ampel-Vorlage fehlt ($AMPEL) — Repo ohne CI-Ampel angelegt." >&2
|
||||
fi
|
||||
}
|
||||
# JEDES neue Repo wird mit beiden Wächtern geboren:
|
||||
# ci.yml = die AUSSEN-Prüfung (Gitea Actions nach dem Push, Wasserdicht-Runde 22.07.)
|
||||
# VERIFY = die INNEN-Prüfung (das OpenCode-Plugin führt sie nach jeder Etappe aus und
|
||||
# gibt rote Tests dem Agenten sofort zurück, statt sie erst der CI zu zeigen)
|
||||
# Defensiv: scheitert ein Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
|
||||
saat "$REALHOME/mission-control-v2/deploy/ampel-ci.yml" ".gitea/workflows/ci.yml" \
|
||||
"CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)" "CI-Ampel"
|
||||
saat "$REALHOME/mission-control-v2/deploy/opencode/VERIFY.template" "VERIFY" \
|
||||
"Pruef-Tor (automatisch bei Repo-Anlage eingepflanzt)" "Pruef-Tor"
|
||||
echo "Repo '$FULL' angelegt (privat, main initialisiert)."
|
||||
echo "CLONE ${CLONE}"
|
||||
exit 0 ;;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
|
||||
|
||||
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
|
||||
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
|
||||
|
||||
## 1. Zu Beginn: erst SAVEPOINT.md lesen
|
||||
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
|
||||
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
|
||||
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
|
||||
ist, sag es, statt es zu erfinden.
|
||||
|
||||
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
|
||||
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
|
||||
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
|
||||
|
||||
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
|
||||
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
|
||||
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
|
||||
- **Offene Fragen** — Entscheidungen, die noch anstehen.
|
||||
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
|
||||
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
|
||||
|
||||
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
|
||||
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
|
||||
|
||||
## 3. Kleine, überprüfbare Schritte
|
||||
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
|
||||
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
|
||||
|
||||
## 4. Wenn der Governor das Sitzungs-Limit meldet
|
||||
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
|
||||
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
|
||||
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Governor — Phase 0
|
||||
|
||||
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
|
||||
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
|
||||
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
|
||||
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
|
||||
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
|
||||
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
|
||||
|
||||
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
|
||||
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
|
||||
|
||||
## Bausteine (dieses Verzeichnis)
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
|
||||
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
|
||||
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
|
||||
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
|
||||
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
|
||||
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
|
||||
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
|
||||
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
|
||||
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
|
||||
|
||||
## Verhalten des Governors
|
||||
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
|
||||
kalibriert auf CPT **3.5** → `est ≈ echte prompt_tokens` auf ~1–3 % bei echten Sessions):
|
||||
|
||||
- **est < Soft** → unverändert durchreichen.
|
||||
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
|
||||
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
|
||||
ehrlichen Abschluss-Savepoint.
|
||||
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
|
||||
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
|
||||
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
|
||||
|
||||
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
|
||||
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
|
||||
mitgeloggt.
|
||||
|
||||
### Sprach-Signal an Lucy (Phase 2)
|
||||
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
|
||||
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
|
||||
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
|
||||
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
|
||||
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
|
||||
|
||||
### Umgebungsvariablen
|
||||
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
|
||||
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
|
||||
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
|
||||
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
|
||||
|
||||
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
|
||||
```bash
|
||||
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
|
||||
pipx install uv && uv tool install --python 3.12 aider-chat
|
||||
|
||||
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
|
||||
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
|
||||
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
|
||||
|
||||
# Aider-Sitzung durch den Governor:
|
||||
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
|
||||
```
|
||||
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
|
||||
`openai/Qwen3-Coder-Next` auf den Governor.
|
||||
|
||||
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
|
||||
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
|
||||
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
|
||||
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
|
||||
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
|
||||
|
||||
## Ergebnisse & Befunde (24.07.2026)
|
||||
|
||||
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
|
||||
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
|
||||
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
|
||||
→ `HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
|
||||
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
|
||||
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
|
||||
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
|
||||
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
|
||||
Schritt) und verweigerte neuen Code.
|
||||
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
|
||||
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
|
||||
Tests laufen grün** gegen die echte API. Kein Erfinden.
|
||||
|
||||
### Kalibrierung
|
||||
`CHARS_PER_TOKEN = 3.5` → `est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
|
||||
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
|
||||
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
|
||||
|
||||
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
|
||||
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
|
||||
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
|
||||
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
|
||||
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
|
||||
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
|
||||
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
|
||||
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
|
||||
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
|
||||
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
|
||||
|
||||
### Weitere Befunde / Fallen
|
||||
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
|
||||
Aider selbst und der Governor greift nie. Siehe oben.
|
||||
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
|
||||
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
|
||||
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
|
||||
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
|
||||
Python 3.12 installiert.
|
||||
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
|
||||
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
|
||||
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
|
||||
|
||||
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
|
||||
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
|
||||
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
|
||||
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
|
||||
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
|
||||
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
|
||||
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
|
||||
lokalen `:8080`-Endpoint irrelevant.
|
||||
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
|
||||
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
|
||||
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
|
||||
|
||||
## Nächste Schritte (Phase 1+)
|
||||
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
|
||||
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
|
||||
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
|
||||
@@ -0,0 +1,23 @@
|
||||
# SAVEPOINT
|
||||
|
||||
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
|
||||
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
|
||||
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
|
||||
|
||||
## Ziel
|
||||
_(noch nichts — von der ersten Sitzung zu füllen)_
|
||||
|
||||
## Erledigt
|
||||
- _(noch nichts)_
|
||||
|
||||
## Nächster Schritt
|
||||
- Auftrag des Nutzers entgegennehmen und beginnen.
|
||||
|
||||
## Offene Fragen
|
||||
- _(keine)_
|
||||
|
||||
## Stolpersteine
|
||||
- _(keine bekannt)_
|
||||
|
||||
## Dateien
|
||||
- `SAVEPOINT.md` — diese Übergabe-Datei.
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
|
||||
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
|
||||
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
|
||||
set -u
|
||||
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||
|
||||
python3 - <<'PY' > /tmp/gov_fire.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
|
||||
PY
|
||||
|
||||
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
|
||||
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
|
||||
|
||||
sleep 1.5 # Announce-Thread durchlassen
|
||||
echo "=== Governor-Log (letzte 4 Zeilen) ==="
|
||||
tail -4 ~/governor-p0/governor.log
|
||||
|
||||
echo "=== Announce-Queue: Governor-Eintraege ==="
|
||||
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
|
||||
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
|
||||
|
||||
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
|
||||
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was
|
||||
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
|
||||
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
|
||||
|
||||
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
|
||||
.../aider-chat/bin/python driver.py <messages-file>
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from aider.coders import Coder
|
||||
from aider.io import InputOutput
|
||||
from aider.models import Model
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit).
|
||||
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
|
||||
os.environ.setdefault("OPENAI_API_KEY", "dummy")
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: driver.py <messages-file>", file=sys.stderr)
|
||||
return 2
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
|
||||
|
||||
model = Model("openai/Qwen3-Coder-Next")
|
||||
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
|
||||
coder = Coder.create(
|
||||
main_model=model,
|
||||
io=io,
|
||||
fnames=["SAVEPOINT.md"], # editierbar
|
||||
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
|
||||
auto_commits=True, # jede Etappe -> git-Commit
|
||||
stream=False, # deterministische Logs fuer P0
|
||||
map_tokens=512,
|
||||
use_git=True,
|
||||
)
|
||||
|
||||
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
|
||||
try:
|
||||
coder.summarizer.max_tokens = 10 ** 9
|
||||
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
|
||||
|
||||
for i, msg in enumerate(messages, 1):
|
||||
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
|
||||
print(f">> {msg[:140]}", flush=True)
|
||||
try:
|
||||
coder.run(with_message=msg)
|
||||
except Exception as exc:
|
||||
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
|
||||
break
|
||||
sent = getattr(coder, "total_tokens_sent", "?")
|
||||
recv = getattr(coder, "total_tokens_received", "?")
|
||||
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
|
||||
|
||||
print("\n===== SESSION ENDE =====", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
|
||||
# Nutzung:
|
||||
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
|
||||
# gov-ctl.sh stop
|
||||
# gov-ctl.sh status
|
||||
set -u
|
||||
DIR="$HOME/governor-p0"
|
||||
PIDF="$DIR/governor.pid"
|
||||
LOG="$DIR/governor.log"
|
||||
OUT="$DIR/governor.stdout"
|
||||
|
||||
start() {
|
||||
stop
|
||||
local thr="${1:-25000}"
|
||||
cd "$DIR"
|
||||
: > "$LOG"
|
||||
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
|
||||
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
|
||||
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
|
||||
local hardenv=()
|
||||
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
|
||||
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
|
||||
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
|
||||
GOV_LOG="$LOG" GOV_PORT=8100 \
|
||||
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
|
||||
echo $! > "$PIDF"
|
||||
sleep 1.2
|
||||
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
|
||||
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
|
||||
}
|
||||
|
||||
stop() {
|
||||
pkill -f "$DIR/governor.py" 2>/dev/null || true
|
||||
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
|
||||
sleep 0.6
|
||||
rm -f "$PIDF"
|
||||
}
|
||||
|
||||
status() {
|
||||
if pgrep -f "$DIR/governor.py" >/dev/null; then
|
||||
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
|
||||
ss -tlnp 2>/dev/null | grep ":8100" || true
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
start) shift; start "${1:-25000}" "${2:-}" ;;
|
||||
stop) stop; echo stopped ;;
|
||||
status) status ;;
|
||||
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
|
||||
|
||||
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
|
||||
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
|
||||
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
|
||||
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
|
||||
|
||||
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
|
||||
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
|
||||
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
|
||||
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
|
||||
|
||||
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
|
||||
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
|
||||
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
|
||||
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
|
||||
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
|
||||
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
|
||||
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
|
||||
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
|
||||
einem einmal geratenen Wert festzuhaengen.
|
||||
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
|
||||
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
|
||||
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
|
||||
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
|
||||
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
|
||||
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
|
||||
und Lucys `loop_status`.
|
||||
|
||||
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
|
||||
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
|
||||
|
||||
Konfiguration per Umgebungsvariablen (alle optional):
|
||||
GOV_PORT Listen-Port (Default 8100)
|
||||
GOV_HOST Listen-Adresse (Default 0.0.0.0)
|
||||
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
|
||||
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
|
||||
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
|
||||
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
|
||||
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
|
||||
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
|
||||
GOV_DIRECTIVE Text des Soft-Einschubs
|
||||
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
|
||||
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
|
||||
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
|
||||
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
|
||||
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
|
||||
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# ---- Konfiguration ---------------------------------------------------------
|
||||
|
||||
PORT = int(os.environ.get("GOV_PORT", "8100"))
|
||||
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
|
||||
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
|
||||
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
|
||||
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
|
||||
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
|
||||
_hard_env = os.environ.get("GOV_HARD_CEILING")
|
||||
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
|
||||
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
|
||||
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
|
||||
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
|
||||
|
||||
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
|
||||
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
|
||||
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
|
||||
EXEMPT_MODELS = {m.strip().lower() for m in
|
||||
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
|
||||
|
||||
DEFAULT_DIRECTIVE = (
|
||||
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
|
||||
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
|
||||
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
|
||||
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
|
||||
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
|
||||
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
|
||||
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
|
||||
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
|
||||
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
|
||||
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
|
||||
)
|
||||
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
|
||||
|
||||
DEFAULT_HARDSTOP = (
|
||||
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
|
||||
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
|
||||
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
|
||||
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
|
||||
)
|
||||
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
|
||||
|
||||
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
|
||||
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
|
||||
DEFAULT_ANNOUNCE = (
|
||||
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
|
||||
"Stand im Savepoint; am besten fangen wir gleich frisch an."
|
||||
)
|
||||
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
|
||||
|
||||
up = urlparse(UPSTREAM)
|
||||
UP_HOST = up.hostname or "127.0.0.1"
|
||||
UP_PORT = up.port or 80
|
||||
|
||||
HOP_BY_HOP = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade",
|
||||
}
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
|
||||
_announce_lock = threading.Lock()
|
||||
_last_announce = 0.0
|
||||
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
|
||||
|
||||
# ---- Zustand (klein, im Speicher) ------------------------------------------
|
||||
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
|
||||
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
|
||||
_state_lock = threading.Lock()
|
||||
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
|
||||
_cpt_n: dict = {} # modell -> Anzahl Messungen
|
||||
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
|
||||
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
|
||||
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
|
||||
|
||||
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
|
||||
|
||||
|
||||
def log(line: str) -> None:
|
||||
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
|
||||
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
msg = f"{stamp} {line}"
|
||||
with _log_lock:
|
||||
print(msg, flush=True)
|
||||
try:
|
||||
with open(LOG_PATH, "a", encoding="utf-8") as fh:
|
||||
fh.write(msg + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def cpt_for(model: str) -> float:
|
||||
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
|
||||
with _state_lock:
|
||||
return _cpt.get(model, CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def calibrate(model: str, chars: int, exact: int) -> None:
|
||||
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
|
||||
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
|
||||
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
|
||||
return
|
||||
ratio = chars / exact
|
||||
if not (CPT_MIN <= ratio <= CPT_MAX):
|
||||
return
|
||||
with _state_lock:
|
||||
n = _cpt_n.get(model, 0)
|
||||
old = _cpt.get(model, CHARS_PER_TOKEN)
|
||||
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
|
||||
w = max(0.08, 1.0 / (n + 2))
|
||||
_cpt[model] = old * (1 - w) + ratio * w
|
||||
_cpt_n[model] = n + 1
|
||||
|
||||
|
||||
def body_chars(data: dict) -> int:
|
||||
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
|
||||
|
||||
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
|
||||
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
|
||||
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
|
||||
Wir serialisieren einfach alles, was ans Modell geht.
|
||||
"""
|
||||
payload = {k: v for k, v in data.items()
|
||||
if k in ("messages", "tools", "tool_choice", "system", "functions")}
|
||||
try:
|
||||
return len(json.dumps(payload, ensure_ascii=False))
|
||||
except (TypeError, ValueError):
|
||||
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
|
||||
chars = 0
|
||||
for m in data.get("messages") or []:
|
||||
c = m.get("content") if isinstance(m, dict) else None
|
||||
if isinstance(c, str):
|
||||
chars += len(c) + 4
|
||||
elif isinstance(c, list):
|
||||
for p in c:
|
||||
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
||||
chars += len(p["text"])
|
||||
return chars
|
||||
|
||||
|
||||
def safe_to_append(messages) -> bool:
|
||||
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
|
||||
|
||||
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
|
||||
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
|
||||
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
|
||||
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
|
||||
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
|
||||
"""
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
if not isinstance(last, dict):
|
||||
return False
|
||||
role = last.get("role")
|
||||
if role == "tool":
|
||||
return False
|
||||
return not (role == "assistant" and last.get("tool_calls"))
|
||||
|
||||
|
||||
def _post_announce(est) -> None:
|
||||
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
|
||||
try:
|
||||
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
||||
.replace("{threshold}", str(THRESHOLD)))
|
||||
body = json.dumps({"text": text, "subject": "[Governor]",
|
||||
"source": "governor", "priority": "normal"}).encode("utf-8")
|
||||
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
|
||||
_an.port or 80, timeout=4)
|
||||
conn.request("POST", _an.path or "/api/voice/announce", body=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Content-Length": str(len(body))})
|
||||
resp = conn.getresponse()
|
||||
resp.read()
|
||||
conn.close()
|
||||
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
|
||||
except Exception as exc:
|
||||
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
|
||||
|
||||
|
||||
def maybe_announce(est) -> None:
|
||||
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
|
||||
if not _an:
|
||||
return
|
||||
global _last_announce
|
||||
now = time.time()
|
||||
with _announce_lock:
|
||||
if now - _last_announce < ANNOUNCE_THROTTLE:
|
||||
return
|
||||
_last_announce = now
|
||||
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
|
||||
|
||||
|
||||
def status_payload() -> dict:
|
||||
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
|
||||
with _state_lock:
|
||||
return {
|
||||
"ok": True,
|
||||
"upstream": UPSTREAM,
|
||||
"soft": THRESHOLD,
|
||||
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
|
||||
"uptime_s": int(time.time() - _counters["started"]),
|
||||
"counters": {k: v for k, v in _counters.items() if k != "started"},
|
||||
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
|
||||
for m, v in _cpt.items()},
|
||||
"calibration_default": CHARS_PER_TOKEN,
|
||||
"exempt_models": sorted(EXEMPT_MODELS),
|
||||
"recent": list(_recent),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "Governor/2.0"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
|
||||
self._send_json(200, status_payload())
|
||||
return
|
||||
self._proxy()
|
||||
|
||||
def do_POST(self):
|
||||
self._proxy()
|
||||
|
||||
def do_PUT(self):
|
||||
self._proxy()
|
||||
|
||||
def do_DELETE(self):
|
||||
self._proxy()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self._proxy()
|
||||
|
||||
# -- Kern ---------------------------------------------------------------
|
||||
def _send_json(self, status: int, obj) -> None:
|
||||
try:
|
||||
data = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _read_body(self) -> bytes:
|
||||
length = self.headers.get("Content-Length")
|
||||
if length is None:
|
||||
return b""
|
||||
try:
|
||||
return self.rfile.read(int(length))
|
||||
except (ValueError, OSError):
|
||||
return b""
|
||||
|
||||
def _proxy(self) -> None:
|
||||
body = self._read_body()
|
||||
path = self.path
|
||||
clean_path = path.split("?", 1)[0]
|
||||
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
|
||||
|
||||
action = "passthrough"
|
||||
est = None
|
||||
streaming = False
|
||||
model = ""
|
||||
chars = 0
|
||||
if is_chat and body:
|
||||
action, body, est, streaming, model, chars = self._decide(body)
|
||||
if action in ("soft", "hard"):
|
||||
maybe_announce(est)
|
||||
|
||||
if action == "hard":
|
||||
self._send_canned_stop(model, streaming, est)
|
||||
with _state_lock:
|
||||
_counters["chat"] += 1
|
||||
_counters["hard"] += 1
|
||||
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||
"exact": None, "action": "hard"})
|
||||
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
|
||||
f"HARDSTOP stream={streaming} status=200")
|
||||
return
|
||||
|
||||
out_headers = {}
|
||||
for k, v in self.headers.items():
|
||||
kl = k.lower()
|
||||
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
|
||||
continue
|
||||
out_headers[k] = v
|
||||
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
|
||||
out_headers["Accept-Encoding"] = "identity"
|
||||
if body:
|
||||
out_headers["Content-Length"] = str(len(body))
|
||||
out_headers["Connection"] = "close"
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
|
||||
conn.request(self.command, path, body=body or None, headers=out_headers)
|
||||
resp = conn.getresponse()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
log(f"ERROR upstream {self.command} {path}: {exc!r}")
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
self._safe_error(502, f"governor upstream: {exc}")
|
||||
return
|
||||
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.getheaders():
|
||||
kl = k.lower()
|
||||
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
|
||||
continue
|
||||
self.send_header(k, v)
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
|
||||
tail = bytearray()
|
||||
try:
|
||||
while True:
|
||||
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
|
||||
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
|
||||
chunk = resp.read1(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
tail.extend(chunk)
|
||||
if len(tail) > 16384:
|
||||
del tail[:-16384]
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
exact = self._scan_prompt_tokens(tail)
|
||||
if is_chat:
|
||||
if exact:
|
||||
calibrate(model, chars, exact)
|
||||
with _state_lock:
|
||||
_counters["chat"] += 1
|
||||
_counters["soft" if action == "soft" else "passthrough"] += 1
|
||||
if exact:
|
||||
_counters["tokens_prompt"] += exact
|
||||
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||
"exact": exact, "action": action})
|
||||
exact_s = str(exact) if exact is not None else "-"
|
||||
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
|
||||
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
|
||||
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
|
||||
|
||||
def _decide(self, body: bytes):
|
||||
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return "passthrough", body, None, False, "", 0
|
||||
if not isinstance(data, dict):
|
||||
return "passthrough", body, None, False, "", 0
|
||||
|
||||
messages = data.get("messages")
|
||||
streaming = bool(data.get("stream"))
|
||||
model = (data.get("model") or "").strip()
|
||||
chars = body_chars(data)
|
||||
est = int(chars / max(cpt_for(model), 0.1))
|
||||
|
||||
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
|
||||
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
|
||||
base = model.split("/")[-1].lower()
|
||||
if base in EXEMPT_MODELS:
|
||||
return "passthrough", body, est, streaming, model, chars
|
||||
|
||||
if not isinstance(messages, list):
|
||||
return "passthrough", body, est, streaming, model, chars
|
||||
|
||||
has_warned = False
|
||||
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
|
||||
has_warned = True
|
||||
break
|
||||
|
||||
is_safe = safe_to_append(messages)
|
||||
|
||||
# 1. Absolutes Not-Aus bei komplettem Amoklauf
|
||||
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
|
||||
return "hard", body, est, streaming, model, chars
|
||||
|
||||
# 2. Sind wir am Limit?
|
||||
if est >= THRESHOLD:
|
||||
if not is_safe:
|
||||
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
|
||||
return "defer", body, est, streaming, model, chars
|
||||
|
||||
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
|
||||
if HARD_CEILING > 0 and est >= HARD_CEILING:
|
||||
if has_warned:
|
||||
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
|
||||
return "hard", body, est, streaming, model, chars
|
||||
else:
|
||||
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
|
||||
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
|
||||
pass
|
||||
|
||||
# Soft-Limit greift
|
||||
if not has_warned:
|
||||
directive = (DIRECTIVE.replace("{est}", str(est))
|
||||
.replace("{threshold}", str(THRESHOLD)))
|
||||
messages.append({"role": "user", "content": directive})
|
||||
data["messages"] = messages
|
||||
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
|
||||
|
||||
return "passthrough", body, est, streaming, model, chars
|
||||
|
||||
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
|
||||
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
|
||||
created = int(time.time())
|
||||
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
|
||||
"total_tokens": est or 0}
|
||||
try:
|
||||
if streaming:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
|
||||
def sse(obj):
|
||||
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
|
||||
"created": created, "model": model}
|
||||
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
|
||||
"finish_reason": None}]})
|
||||
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
|
||||
"finish_reason": None}]})
|
||||
sse({**base, "choices": [{"index": 0, "delta": {},
|
||||
"finish_reason": "stop"}], "usage": usage})
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
else:
|
||||
payload = {
|
||||
"id": "governor-hardstop", "object": "chat.completion",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
|
||||
"usage": usage,
|
||||
}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
self.wfile.flush()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _safe_error(self, status: int, msg: str) -> None:
|
||||
self._send_json(status, {"error": msg})
|
||||
|
||||
@staticmethod
|
||||
def _scan_prompt_tokens(tail: bytearray):
|
||||
if not tail:
|
||||
return None
|
||||
try:
|
||||
text = tail.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
matches = _PROMPT_TOKENS_RE.findall(text)
|
||||
if not matches:
|
||||
return None
|
||||
try:
|
||||
return int(matches[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
log_dir = os.path.dirname(LOG_PATH)
|
||||
if log_dir and not os.path.isdir(log_dir):
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
server.daemon_threads = True
|
||||
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
|
||||
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
|
||||
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
|
||||
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("Governor beendet (SIGINT).")
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
|
||||
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
|
||||
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
|
||||
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
|
||||
After=network-online.target mission-control-2.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/governor
|
||||
ExecStart=/usr/bin/python3 %h/governor/governor.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# --- Schwellen -------------------------------------------------------------
|
||||
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
|
||||
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
|
||||
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
|
||||
Environment=GOV_THRESHOLD=45000
|
||||
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
|
||||
Environment=GOV_LOG=%h/governor/governor.log
|
||||
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
|
||||
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
|
||||
# klein -> passthrough (Modell antwortet normal)
|
||||
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
|
||||
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
|
||||
set -u
|
||||
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||
|
||||
python3 - <<'PY' > /tmp/gov_small.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
|
||||
PY
|
||||
python3 - <<'PY' > /tmp/gov_mid.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
|
||||
PY
|
||||
python3 - <<'PY' > /tmp/gov_big.json
|
||||
import json
|
||||
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
|
||||
PY
|
||||
|
||||
echo "=== TEST A: klein (passthrough) ==="
|
||||
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
|
||||
|
||||
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
|
||||
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
|
||||
|
||||
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
|
||||
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
|
||||
@@ -0,0 +1,13 @@
|
||||
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
|
||||
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
|
||||
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
|
||||
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
|
||||
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
|
||||
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
|
||||
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
|
||||
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
|
||||
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
|
||||
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
|
||||
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
|
||||
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
|
||||
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
|
||||
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
|
||||
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
|
||||
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
|
||||
set -u
|
||||
REPO="${1:?repo dir}"
|
||||
MSG="${2:?message}"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||
export OPENAI_API_KEY="dummy"
|
||||
cd "$REPO"
|
||||
aider \
|
||||
--model openai/Qwen3-Coder-Next \
|
||||
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
|
||||
--map-tokens 512 \
|
||||
--read CONVENTIONS.md --read todo.py --read cli.py \
|
||||
SAVEPOINT.md \
|
||||
--message "$MSG"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
|
||||
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
|
||||
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
|
||||
set -u
|
||||
MSGS="${1:?Nachrichtendatei angeben}"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||
export OPENAI_API_KEY="dummy"
|
||||
cd "$HOME/governor-p0/testrepo"
|
||||
aider \
|
||||
--model openai/Qwen3-Coder-Next \
|
||||
--no-check-update \
|
||||
--no-show-model-warnings \
|
||||
--no-analytics \
|
||||
--yes-always \
|
||||
--map-tokens 512 \
|
||||
--read CONVENTIONS.md \
|
||||
SAVEPOINT.md \
|
||||
< "$MSGS"
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
|
||||
set -u
|
||||
MSGS="${1:?Nachrichtendatei angeben}"
|
||||
REPO="${2:-$HOME/governor-p0/testrepo}"
|
||||
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
|
||||
cd "$REPO"
|
||||
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
|
||||
@@ -234,8 +234,8 @@ def main() -> int:
|
||||
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?)"))
|
||||
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:
|
||||
|
||||
@@ -23,7 +23,7 @@ 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]]":
|
||||
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
|
||||
@@ -32,7 +32,7 @@ def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return []
|
||||
candidates: "dict[int, str]" = {}
|
||||
candidates: dict[int, str] = {}
|
||||
try:
|
||||
entries = os.listdir("/proc")
|
||||
except OSError:
|
||||
@@ -53,7 +53,7 @@ def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
||||
|
||||
task_ids = list(set(candidates.values()))
|
||||
placeholders = ",".join("?" * len(task_ids))
|
||||
running: "set[str]" = set()
|
||||
running: set[str] = set()
|
||||
try:
|
||||
with connect_closing() as conn:
|
||||
running = {
|
||||
@@ -67,7 +67,7 @@ def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
killed: "list[tuple[int, str]]" = []
|
||||
killed: list[tuple[int, str]] = []
|
||||
for pid, task_id in candidates.items():
|
||||
if task_id in running:
|
||||
continue
|
||||
|
||||
@@ -72,12 +72,22 @@ fi
|
||||
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
|
||||
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
|
||||
if [ -n "$API_KEY" ]; then
|
||||
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
|
||||
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \
|
||||
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
|
||||
# Mit Retry — GENAU wie der Voice-Smoke darunter, und aus demselben Grund: direkt nach
|
||||
# einem Update ist das Modell kalt. Ein Kaltstart kostet ~20-30 s Laden PLUS ~37 s
|
||||
# Prefill für Hermes' ~70-KB-Systemprompt (33 Tool-Schemas) — zusammen mit zwei
|
||||
# Inferenz-Runden sprengt das die 90 s des ersten Versuchs. Warm gemessen: 12 s.
|
||||
# Ein Versuch allein war ein Fehlalarm-Generator (live erlebt 25.07., Job faf1137a).
|
||||
TOOL_RESP=""
|
||||
for try in 1 2 3; do
|
||||
TOOL_RESP=$(curl -sf -m 120 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
|
||||
-H "X-Hermes-Session-Id: postcheck-tool-smoke-$try" \
|
||||
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
|
||||
echo "$TOOL_RESP" | grep -qi "postcheck-ok" && break
|
||||
[ "$try" -lt 3 ] && sleep 10
|
||||
done
|
||||
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
|
||||
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis"
|
||||
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis (Versuch $try)"
|
||||
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
|
||||
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
|
||||
else
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=MC2 Morgen-Digest (Nächtliche Telegram-Zusammenfassung)
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/bash %h/mission-control-v2/deploy/morgen-digest.sh
|
||||
TimeoutStartSec=300
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Timer für MC2 Morgen-Digest (07:00 Uhr)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 07:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Morgen-Digest: Sammelt die zurückgehaltenen nächtlichen Meldungen (00:00 - 06:59)
|
||||
# und lässt Lucy (LLM) eine Telegram-Zusammenfassung daraus bauen.
|
||||
set -uo pipefail
|
||||
|
||||
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
|
||||
if [ ! -f "$NIGHT_QUEUE" ]; then
|
||||
# Keine nächtlichen Meldungen
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wenn die Datei existiert, aber leer ist, löschen und beenden.
|
||||
if [ ! -s "$NIGHT_QUEUE" ]; then
|
||||
rm -f "$NIGHT_QUEUE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EVIDENCE="$(cat "$NIGHT_QUEUE")"
|
||||
|
||||
PROMPT="Du bist Lucy, die KI-Assistenz der Mission-Control-Box.
|
||||
Hier sind die Systemmeldungen der vergangenen Nacht.
|
||||
Der Commander möchte ordentlich Futter und Details zum Frühstück! Fasse die Meldungen zu einem gehaltvollen Morgen-Digest zusammen.
|
||||
Beachte dabei:
|
||||
- Nenne konkrete Fakten, Namen und Versionen.
|
||||
- Welche Modelle wurden z.B. vom Radar entdeckt oder evaluiert? Was waren die Ergebnisse?
|
||||
- Welche Updates liefen genau und was haben sie Neues gebracht? Was muss sich der Commander noch anschauen?
|
||||
- Strukturiere die Nachricht übersichtlich (z.B. mit kleinen Emojis oder Bulletpoints), aber bleibe in deiner Rolle als Lucy.
|
||||
|
||||
Nächtliche Meldungen:
|
||||
$EVIDENCE"
|
||||
|
||||
# LLM aufrufen (llama-swap / gpt-oss-120b)
|
||||
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
|
||||
MODEL="${CHEF_MODEL:-gpt-oss-120b}"
|
||||
|
||||
REQ="$(jq -n --arg m "$MODEL" --arg sys "Du bist Lucy." --arg usr "$PROMPT" --argjson mt 1200 \
|
||||
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
|
||||
max_tokens:$mt, temperature:0.4}')"
|
||||
|
||||
DIGEST="$(curl -s -m 180 "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$REQ" \
|
||||
| python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
msg = d["choices"][0]["message"]
|
||||
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
|
||||
except Exception:
|
||||
pass'
|
||||
)"
|
||||
|
||||
if [ -n "$DIGEST" ]; then
|
||||
# Sende den Digest via hermes send (Telegram)
|
||||
bash -lc 'hermes send --to telegram --subject "[🌅 Morgen-Digest]" -- "$1"' _ "$DIGEST"
|
||||
if [ $? -eq 0 ]; then
|
||||
# Erfolgreich gesendet, Queue leeren
|
||||
rm -f "$NIGHT_QUEUE"
|
||||
else
|
||||
echo "Fehler beim Senden des Morgen-Digests an Telegram. Queue bleibt erhalten." >&2
|
||||
fi
|
||||
else
|
||||
echo "Leerer Digest vom LLM. Queue bleibt erhalten." >&2
|
||||
fi
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Erwartet den Job-Namen und das Skript als ein String oder zwei Argumente
|
||||
if [ $# -eq 1 ]; then
|
||||
read -r JOB_NAME SCRIPT_NAME <<< "$1"
|
||||
else
|
||||
JOB_NAME="$1"
|
||||
SCRIPT_NAME="$2"
|
||||
fi
|
||||
|
||||
if [ -z "$SCRIPT_NAME" ]; then
|
||||
SCRIPT_NAME="$JOB_NAME"
|
||||
fi
|
||||
|
||||
# Pfad zusammenbauen
|
||||
SCRIPT_PATH="$HOME/mission-control-v2/deploy/$SCRIPT_NAME"
|
||||
if [ ! -f "$SCRIPT_PATH" ]; then
|
||||
# Versuche pruefstand/
|
||||
SCRIPT_PATH="$HOME/mission-control-v2/deploy/pruefstand/$SCRIPT_NAME"
|
||||
fi
|
||||
|
||||
bash "$SCRIPT_PATH" | bash "$HOME/mission-control-v2/deploy/notify.sh" -s "$JOB_NAME"
|
||||
+19
-2
@@ -22,8 +22,8 @@ shift $((OPTIND - 1))
|
||||
MSG="${1:-}"
|
||||
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
|
||||
if [ -z "$MSG" ]; then
|
||||
echo "usage: notify.sh [-s subject] <nachricht>" >&2
|
||||
exit 2
|
||||
# Bei leerer Pipe-Eingabe lautlos beenden (wichtig für Cron-Skripte ohne Ausgabe)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TS="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
@@ -41,6 +41,23 @@ PY
|
||||
)" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Nachtweiche: Zwischen 00:00 und 06:59 Uhr (inklusive) fangen wir Telegram-Nachrichten ab
|
||||
# und sammeln sie für den Morgen-Digest, ES SEI DENN es ist ein Alarm.
|
||||
HOUR="$(date +%H)"
|
||||
if [ "$HOUR" -ge 0 ] && [ "$HOUR" -lt 7 ]; then
|
||||
case "${SUBJECT,,}" in
|
||||
*alarm*|*notfall*)
|
||||
;; # Alarme gehen immer sofort durch
|
||||
*)
|
||||
# Sammeln für Morgen-Digest
|
||||
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
|
||||
echo "- ${SUBJECT:+$SUBJECT: }$MSG" >> "$NIGHT_QUEUE"
|
||||
echo "$TS QUEUED für Morgen-Digest: $MSG" >> "$LOG"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
|
||||
if [ -n "$SUBJECT" ]; then
|
||||
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# opencode-lauf.sh — EIN begrenzter Agentenlauf auf der Box, mit denselben Regeln wie in Zed.
|
||||
#
|
||||
# Das ist die Nacht-Seite von „ein Regelwerk, zwei Ausloeser": tagsueber tippst du in Zed,
|
||||
# nachts ruft ein Hermes-Cron dieses Skript. Beide Wege benutzen
|
||||
# * dieselbe OpenCode-Version,
|
||||
# * dieselbe Mannschaft (~/.config/opencode/opencode.json: coder/hermes/kritiker),
|
||||
# * dasselbe Plugin (~/.config/opencode/plugin/mc2-governor.ts: Zaun + Pruef-Tor),
|
||||
# * denselben Token-Waechter (:8100).
|
||||
#
|
||||
# Unterschied zu deploy/worker.sh: worker.sh ist EIN zustandsloser Completion-Aufruf
|
||||
# (Hermes schreibt die Dateien selbst). Hier laeuft ein ECHTER Agent mit Datei-Haenden,
|
||||
# Subagenten und Pruef-Tor — fuer ganze Karten statt fuer Schnipsel.
|
||||
#
|
||||
# Nutzung:
|
||||
# opencode-lauf.sh <repo-pfad> "<auftrag>"
|
||||
# opencode-lauf.sh ~/projekte/foo "Baue X. Halte dich an AGENTS.md."
|
||||
#
|
||||
# Env:
|
||||
# LAUF_TIMEOUT Sekunden Hoechstdauer (Default 3600)
|
||||
# LAUF_LAUT 1 = Lucy spricht mit (Default 0 = still, Nachtbetrieb)
|
||||
# LAUF_AGENT OpenCode-Agent (Default build)
|
||||
set -uo pipefail
|
||||
|
||||
REPO="${1:-}"
|
||||
AUFTRAG="${2:-}"
|
||||
TIMEOUT="${LAUF_TIMEOUT:-3600}"
|
||||
AGENT="${LAUF_AGENT:-build}"
|
||||
OC="$HOME/.opencode/bin/opencode"
|
||||
ANNOUNCE="${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}"
|
||||
|
||||
melde () { # melde <betreff> <text> [prioritaet]
|
||||
curl -sf -m 5 -X POST "$ANNOUNCE" -H 'Content-Type: application/json' \
|
||||
--data "$(python3 -c 'import json,sys; print(json.dumps({"subject":sys.argv[1],"text":sys.argv[2],"source":"loop","priority":sys.argv[3]}))' \
|
||||
"$1" "$2" "${3:-silent}")" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if [ -z "$REPO" ] || [ -z "$AUFTRAG" ]; then
|
||||
echo "Nutzung: $0 <repo-pfad> \"<auftrag>\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -d "$REPO" ]; then
|
||||
echo "FEHLER: '$REPO' ist kein Verzeichnis." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -x "$OC" ]; then
|
||||
echo "FEHLER: OpenCode nicht gefunden ($OC)." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Der Governor MUSS stehen — ohne ihn liefe der Lauf ohne Sitzungs-Bremse.
|
||||
if ! curl -sf -m 5 -o /dev/null "http://127.0.0.1:8100/governor/status"; then
|
||||
echo "FEHLER: Governor (:8100) antwortet nicht — Lauf abgebrochen (keine Sitzungs-Bremse)." >&2
|
||||
melde "[Lauf]" "Ich habe einen Nachtlauf abgebrochen: der Token-Waechter antwortet nicht." "normal"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Nachts still: Meldungen landen im Briefkasten, Lucy spricht sie aber nicht aus.
|
||||
# Das Plugin liest diese Variable; die Morgen-Zusammenfassung kommt vom Daily-Briefing.
|
||||
if [ "${LAUF_LAUT:-0}" = "1" ]; then export MC2_LOOP_SILENT=0; else export MC2_LOOP_SILENT=1; fi
|
||||
|
||||
LOGDIR="$HOME/.hermes/logs"; mkdir -p "$LOGDIR"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
LOG="$LOGDIR/opencode-lauf-$STAMP.log"
|
||||
MAXRUNDEN="${LAUF_MAX_RUNDEN:-3}"
|
||||
|
||||
cd "$REPO" || exit 2
|
||||
NAME="$(basename "$REPO")"
|
||||
melde "[Lauf]" "Ich fange an zu bauen: $NAME." "silent"
|
||||
echo "=== Lauf $STAMP · Repo $REPO · Agent $AGENT · Timeout ${TIMEOUT}s ===" | tee "$LOG"
|
||||
|
||||
# Verify-Befehl des Projekts lesen (gleiche Datei und gleiche Regeln wie im Plugin).
|
||||
verify_cmd () {
|
||||
[ -r "$REPO/VERIFY" ] || return 1
|
||||
grep -vE '^\s*(#|$)' "$REPO/VERIFY" | paste -sd' && ' -
|
||||
}
|
||||
|
||||
START=$(date +%s)
|
||||
CODE=0
|
||||
RUNDE=0
|
||||
AUFGABE="$AUFTRAG"
|
||||
|
||||
# ── Bau-Schleife ────────────────────────────────────────────────────────────
|
||||
# Warum hier UND im Plugin? Das Plugin haengt am Ereignis `session.idle` — in Zed
|
||||
# laeuft der Prozess weiter und alles ist gut. Bei `opencode run` beendet sich der
|
||||
# Prozess aber, bevor das Pruef-Tor fertig ist (gemessen 25.07.). Fuer unbeaufsichtigte
|
||||
# Laeufe muss die Schleife deshalb HIER liegen, wo sie den Prozess ueberlebt.
|
||||
while :; do
|
||||
RUNDE=$((RUNDE + 1))
|
||||
echo "--- Runde $RUNDE/$MAXRUNDEN ---" | tee -a "$LOG"
|
||||
timeout "$TIMEOUT" "$OC" run --agent "$AGENT" "$AUFGABE" >>"$LOG" 2>&1
|
||||
CODE=$?
|
||||
[ "$CODE" -eq 124 ] && { echo "ZEITUEBERSCHREITUNG" | tee -a "$LOG"; break; }
|
||||
|
||||
VCMD="$(verify_cmd)" || { echo "Kein VERIFY — Pruef-Tor aus, Lauf endet." | tee -a "$LOG"; break; }
|
||||
|
||||
echo "--- Pruef-Tor: $VCMD ---" | tee -a "$LOG"
|
||||
VOUT="$(cd "$REPO" && eval "$VCMD" 2>&1)"; VCODE=$?
|
||||
printf '%s\n' "$VOUT" | tail -20 >> "$LOG"
|
||||
|
||||
if [ "$VCODE" -eq 0 ]; then
|
||||
echo "PRUEF-TOR GRUEN" | tee -a "$LOG"
|
||||
melde "[Pruefung]" "$NAME: Tests gruen nach $RUNDE Runde(n)." "normal"
|
||||
CODE=0
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$RUNDE" -ge "$MAXRUNDEN" ]; then
|
||||
echo "PRUEF-TOR ROT — Reparaturrunden aufgebraucht." | tee -a "$LOG"
|
||||
melde "[Pruefung]" "$NAME: Tests bleiben rot nach $RUNDE Runden. Hier komme ich allein nicht weiter." "normal"
|
||||
CODE=1
|
||||
break
|
||||
fi
|
||||
|
||||
echo "PRUEF-TOR ROT — Runde $((RUNDE + 1)) folgt." | tee -a "$LOG"
|
||||
melde "[Pruefung]" "$NAME: Tests rot, ich repariere selbst weiter (Runde $((RUNDE + 1))/$MAXRUNDEN)." "silent"
|
||||
AUFGABE="[MC2-PRUEFTOR] Der Verify-Befehl des Projekts ist fehlgeschlagen.
|
||||
|
||||
Befehl: $VCMD
|
||||
|
||||
Ausgabe (Ende):
|
||||
$(printf '%s' "$VOUT" | tail -c 3000)
|
||||
|
||||
Behebe die URSACHE — nicht das Symptom. Schalte keinen Test ab und aendere keine Tests.
|
||||
Urspruenglicher Auftrag war: $AUFTRAG"
|
||||
done
|
||||
|
||||
DAUER=$(( $(date +%s) - START ))
|
||||
# Nachweis statt Behauptung: was hat der Lauf im Arbeitsbaum tatsaechlich veraendert?
|
||||
GEAENDERT="$(git -C "$REPO" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||
|
||||
case "$CODE" in
|
||||
0) ERG="fertig, Pruefung bestanden" ;;
|
||||
124) ERG="ZEITUEBERSCHREITUNG nach ${TIMEOUT}s" ;;
|
||||
*) ERG="Pruefung NICHT bestanden (Exitcode $CODE)" ;;
|
||||
esac
|
||||
|
||||
echo "=== Ergebnis: $ERG · ${DAUER}s · $RUNDE Runde(n) · $GEAENDERT geaenderte Dateien · Log $LOG ===" | tee -a "$LOG"
|
||||
melde "[Lauf]" "$NAME: $ERG nach $((DAUER/60)) Minuten, $RUNDE Runde(n), $GEAENDERT Dateien angefasst." "silent"
|
||||
exit "$CODE"
|
||||
@@ -0,0 +1,82 @@
|
||||
# Persönliche Regeln (gelten für ALLE Projekte)
|
||||
|
||||
## So arbeitest du mit mir
|
||||
- Antworte auf **Deutsch**, Fazit zuerst, Technik in Alltagssprache. Ich bin **kein Entwickler**.
|
||||
- **Plan vor Code** bei allem Größeren: erst Vorgehen zeigen, auf mein **„los"** warten.
|
||||
- **Kleine Schritte**, eine Sache zur Zeit. Nichts Ungefragtes ausführen; **frag**, bevor du löschst
|
||||
oder überschreibst.
|
||||
- **Beweisen statt behaupten** — am Ende live testen, nicht „sollte gehen".
|
||||
- Nimm **Standard-Wege**, keine exotische Technik, außer ich will es ausdrücklich anders.
|
||||
- **Geheimnisse** (Schlüssel/Passwörter/Tokens) nie in den Code — in `.env` (+ `.gitignore`); warnen,
|
||||
bevor so etwas in einen Commit wandert.
|
||||
- **Nicht festfahren:** klemmt dieselbe Sache nach 2 Versuchen, anhalten, sichern, erklären und fragen.
|
||||
- **Bau-Prinzipien sind DEIN Job, nicht meine Frage:** KISS, YAGNI, Zuständigkeiten trennen, DRY,
|
||||
sprechende Namen, schlank starten. Frag mich nie danach — frag mich nur nach Zielen in Alltagssprache.
|
||||
- **Faulheits-Leiter vor jeder neuen Zeile/Abhängigkeit:** (1) muss das überhaupt sein? (2) gibt's das
|
||||
schon (wiederverwenden)? (3) reicht Bordmittel/Standard-Bibliothek? (4) erst dann minimal selbst bauen.
|
||||
Faul bei Lösungen, nie beim Verstehen; Sicherheitsnetze (Prüfung/Fehlerbehandlung/keine Geheimnisse)
|
||||
bleiben. „Prüf auf Über-Engineering" = Diff auf unnötige Abhängigkeiten/Abstraktion/Verdachts-Features durchsehen.
|
||||
|
||||
## Deine Mannschaft — delegiere, statt alles selbst zu tun
|
||||
- **`explore`** (Subagent): Code durchsuchen, Dateien finden, Fragen zum Bestand beantworten. Läuft auf
|
||||
dem ohnehin warmen Hirn und kostet praktisch nichts. **Nutze ihn**, statt dich selbst durch den Baum
|
||||
zu wühlen — dein eigener Kontext bleibt dadurch sauber und du hältst länger durch.
|
||||
- **`review`** (Subagent): kritisches Gegenlesen nach jeder Etappe. Läuft bewusst auf einer **fremden
|
||||
Modellfamilie**, damit er andere blinde Flecken hat als du. Nimm seine Befunde ernst.
|
||||
- **`plan`**: nur lesen, keine Änderungen.
|
||||
- Es gibt **keinen Modellwechsel mehr**. Früher galt „zum Bauen oben auf Coder umschalten" — Planen und
|
||||
Bauen laufen inzwischen auf demselben Modell. Bitte mich nie, etwas umzustellen.
|
||||
|
||||
## Das Prüf-Tor (`VERIFY`)
|
||||
- Im Projekt liegt eine Datei `VERIFY` mit **einer Zeile: wie man dieses Projekt testet**. Sobald du
|
||||
„fertig" meldest, wird sie automatisch ausgeführt.
|
||||
- **Rot heißt nicht fertig.** Der Fehler kommt zu dir zurück, du behebst die **Ursache** — nicht das
|
||||
Symptom. Einen Test abschwächen, überspringen oder umschreiben, damit er grün wird, gilt als Betrug.
|
||||
- Fehlt die Datei, ist das Prüf-Tor aus. Dann **frag mich**, was der richtige Testbefehl wäre, statt
|
||||
ungeprüft weiterzubauen.
|
||||
|
||||
## Kontext-Wächter
|
||||
- Ein Token-Wächter zählt jede Sitzung mit und schneidet sie bei ~45.000 Tokens. Du musst nichts messen
|
||||
— die Ansage kommt von allein.
|
||||
- Kommt sie: **`SAVEPOINT.md` ehrlich fertigschreiben und anhalten.** Ehrlich heißt: nur was **wirklich
|
||||
im Code steht**. Nichts aus Absicht, Plan oder git-Nachrichten ableiten. Dazu der genaue nächste
|
||||
Schritt, offene Fragen und Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung
|
||||
allein damit weitermachen kann.
|
||||
- Merkst du vorher schon, dass du dich wiederholst oder abdriftest: sag es proaktiv und schlag einen
|
||||
frischen Chat vor.
|
||||
|
||||
## Wenn niemand da ist (unbeaufsichtigte Läufe)
|
||||
Nachts startet ein Cron dieselbe Umgebung ohne Menschen davor. Erkennst du das — der Auftrag kam als
|
||||
ein Block, auf Rückfragen antwortet niemand: **warte nicht auf ein „los"**. Halte dich an `KONZEPT`/
|
||||
`SAVEPOINT.md`, bau in kleinen Etappen und lass das Prüf-Tor entscheiden. Was du ohne Antwort nicht
|
||||
sauber entscheiden kannst, schreibst du als offene Frage in `SAVEPOINT.md`, statt zu raten.
|
||||
|
||||
## Was du nie tust
|
||||
`git push`, Historie umschreiben, rekursiv löschen, `sudo`, Pakete veröffentlichen. Das ist gezäunt und
|
||||
wird geblockt — nicht weil du es falsch machst, sondern weil Veröffentlichen meine Entscheidung ist.
|
||||
Brauchst du so etwas wirklich: sag es mir, ich mache es selbst.
|
||||
|
||||
## Bestehendes Projekt fortsetzen?
|
||||
Ordner nicht leer / neue Sitzung: zuerst **`SAVEPOINT.md` + `AGENTS.md` lesen**, mir in 2 Sätzen sagen
|
||||
„hier stehen wir / nächster Schritt", dann weitermachen — nicht neu planen.
|
||||
|
||||
## Neues Projekt? → Projekt-Start-Ablauf
|
||||
Wenn ein **neues** Projekt beginnt (leerer/neuer Ordner, „bau mir…", „neues Projekt…", „ich hätte
|
||||
gern…"): folge diesem Ablauf. **Keine Datei, kein Code vor meiner Freigabe.**
|
||||
1. **Verstehen:** erst herausfinden, *was für ein* Projekt (Web-App / kleines Skript /
|
||||
Box-Lucy-Erweiterung / Experiment / Datensache), dann **3–6 typ-passende Fragen** + „woran ist
|
||||
es fertig?".
|
||||
2. **Plan vorlegen** (Ziel · Vorgehen · Technik + warum · Fertig-Kriterien · Risiken) → **STOPP**,
|
||||
auf mein „los" warten.
|
||||
3. **Erst dann Gerüst:** `git init` + die Projektdateien (`AGENTS.md`, `SAVEPOINT.md`, `.aiexclude`,
|
||||
`VERIFY`) + `README`, dann **Sicherungspunkt „start"** (erster Commit).
|
||||
4. In **kleinen Schritten** bauen, an jedem Meilenstein + vor riskanten Änderungen einen
|
||||
**Sicherungspunkt** (Commit mit klarem Namen) setzen, am Ende **live testen**.
|
||||
|
||||
## Bei großem Meilenstein UND Session-Ende („Feierabend"/„sichern")
|
||||
Immer fortschreiben: **`SAVEPOINT.md`** (Stand · Fertiges · nächste Schritte · offene Fragen — die
|
||||
Übergabe an die nächste Sitzung), **`AGENTS.md`** (falls Konventionen sich änderten), **`.aiexclude`**
|
||||
(neue Geheimnisse/große Dateien), **`VERIFY`** (falls sich der Testbefehl geändert hat), dann
|
||||
Sicherungspunkt setzen.
|
||||
|
||||
Volle Fassung des Projekt-Start-Ablaufs: `F:\Coding Stuff\projekt-start\SKILL.md`
|
||||
@@ -0,0 +1,75 @@
|
||||
# OpenCode-Seite: ein Regelwerk, zwei Auslöser
|
||||
|
||||
Tagsüber tippst du in **Zed** (PC), nachts ruft ein **Hermes-Cron** dasselbe (Box).
|
||||
Beide Wege benutzen dieselbe OpenCode-Version, dieselbe Mannschaft, dasselbe Plugin
|
||||
und denselben Token-Wächter. Der einzige Unterschied ist die Adresse des Governors.
|
||||
|
||||
## Was wohin gehört
|
||||
|
||||
| Datei hier | Ziel auf dem PC | Ziel auf der Box |
|
||||
|---|---|---|
|
||||
| `opencode.pc.json` | `~/.config/opencode/opencode.json` | — |
|
||||
| `opencode.box.json` | — | `~/.config/opencode/opencode.json` |
|
||||
| `AGENTS.global.md` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode/AGENTS.md` |
|
||||
| `plugin/mc2-governor.ts` | `~/.config/opencode/plugin/` | `~/.config/opencode/plugin/` |
|
||||
| `VERIFY.template` | — | wird von `gitea-repo-create.sh` in **jedes neue Repo** als `VERIFY` gesät |
|
||||
|
||||
### Warum die Regeln hier liegen und nicht in Zed
|
||||
|
||||
Die persönlichen Arbeitsregeln standen bis 25.07.2026 in `AppData\Roaming\Zed\AGENTS.md` — und
|
||||
haben dort **nie den Coding-Agenten erreicht**. Zeds eigene Doku ist eindeutig: `AGENTS.md` und
|
||||
Skills gelten **nur für Zeds nativen Agenten**, nicht für ACP-Agenten wie OpenCode. Da hier über
|
||||
ACP gearbeitet wird, liefen die Regeln jahrelang ins Leere.
|
||||
|
||||
OpenCode liest stattdessen (in dieser Reihenfolge): `AGENTS.md` im Projekt (aufwärts gesucht) und
|
||||
`~/.config/opencode/AGENTS.md` global. Beide gelten zusammen — Projektregeln ergänzen die globalen.
|
||||
|
||||
Beim Umzug bewusst geändert:
|
||||
- **Zeds Commit-Vorlage entfernt.** Sie war in dieselbe Datei gerutscht und enthielt „Only return
|
||||
the commit message in your response" — in einer dauerhaft aktiven Regeldatei ein Fehlerherd.
|
||||
- **Modellwähler-Absatz gestrichen.** „Zum Bauen oben auf Coder umschalten" stimmt seit der
|
||||
Mannschafts-Umstellung nicht mehr: `plan` und `build` laufen auf demselben Modell.
|
||||
- **Kontext-Wächter umgeschrieben** auf den Governor, der den Füllstand wirklich kennt.
|
||||
- **Neu:** Prüf-Tor (`VERIFY`), die Subagenten-Mannschaft und ein Abschnitt für **unbeaufsichtigte
|
||||
Läufe** — nachts sagt niemand „los", also darf der Agent dort nicht auf Freigabe warten.
|
||||
|
||||
Der Governor selbst liegt in `../governor/` (Proxy + systemd-Unit), der unbeaufsichtigte
|
||||
Läufer in `../opencode-lauf.sh`.
|
||||
|
||||
## Die Mannschaft
|
||||
|
||||
| Rolle | Modell | Gemessen (25.07.2026) | Warum |
|
||||
|---|---|---|---|
|
||||
| `plan` + `build` | `coder` — Qwen3-Coder-Next | **51,5 t/s** | Hält den Faden, verteilt Zuarbeit. MoE mit 3B aktiv → schnell auf Strix Halo. |
|
||||
| `explore` | `hermes` — Qwen3.6-35B | **69,6 t/s** | Ohnehin dauerwarm → kostet **null** zusätzlichen Speicher. Sucht, liest, meldet kurz zurück. |
|
||||
| `review` | `kritiker` — Devstral-Small-2 | **15,0 t/s** | Bewusst eine **fremde Modellfamilie** (Mistral statt Qwen) → andere blinde Flecken. Dicht = langsam beim Schreiben, aber ein Kritiker liest viel und schreibt wenig. |
|
||||
|
||||
`heavy` (gpt-oss-120b, 63 GB) ist **nicht** mehr in der Tagesrolle: es würde beim Laden
|
||||
das ganze warme Set verdrängen. Es bleibt der Nacht-Gutachter (4:30-Cron).
|
||||
|
||||
## Nach einer Änderung
|
||||
|
||||
Die Dateien hier sind **Vorlagen**, keine Live-Konfiguration. Nach einer Änderung
|
||||
verteilen:
|
||||
|
||||
```bash
|
||||
# Box
|
||||
scp deploy/opencode/opencode.box.json hitonabi@192.168.178.151:~/.config/opencode/opencode.json
|
||||
scp deploy/opencode/plugin/*.ts hitonabi@192.168.178.151:~/.config/opencode/plugin/
|
||||
# PC (aus dem Repo heraus)
|
||||
cp deploy/opencode/opencode.pc.json ~/.config/opencode/opencode.json
|
||||
cp deploy/opencode/plugin/*.ts ~/.config/opencode/plugin/
|
||||
```
|
||||
|
||||
**Zed muss danach neu gestartet werden** — OpenCode liest seine Konfiguration nur beim Start.
|
||||
|
||||
## Fallen, die Zeit gekostet haben
|
||||
|
||||
- **Kein `_comment`-Schlüssel in `opencode.json`.** OpenCode validiert streng und
|
||||
verweigert den Start mit „Unrecognized key". Kommentare gehören in dieses README.
|
||||
- **Das Plugin läuft auf Windows UND Linux.** Deshalb `node:fs` statt `cat` und Buns
|
||||
`${{ raw: cmd }}` statt `bash -lc` — beides fehlt auf Windows bzw. verschluckt den Befehl.
|
||||
- **Bei `opencode run` beendet sich der Prozess, bevor `session.idle` fertig ist**
|
||||
(gemessen 25.07.). Für unbeaufsichtigte Läufe liegt die Prüf-Schleife deshalb
|
||||
zusätzlich in `opencode-lauf.sh`, wo sie den Prozess überlebt. In Zed greift das Plugin.
|
||||
- **Plugin-Verzeichnis:** `plugin/` und `plugins/` werden beide erkannt; wir nutzen `plugin/`.
|
||||
@@ -0,0 +1,21 @@
|
||||
# VERIFY — wie man dieses Projekt prueft.
|
||||
#
|
||||
# Diese Datei ist das Pruef-Tor. Das MC2-Governor-Plugin fuehrt sie aus, sobald der
|
||||
# Agent "fertig" sagt. Gruen -> Etappe gilt als fertig. Rot -> der Fehler geht
|
||||
# automatisch als naechster Auftrag an den Agenten zurueck, bis zu 3 Runden.
|
||||
#
|
||||
# Regeln:
|
||||
# * Eine Zeile = ein Befehl. Alle Zeilen werden mit && verkettet.
|
||||
# * Zeilen mit # sind Kommentare.
|
||||
# * KEINE Datei VERIFY im Projekt = Pruef-Tor aus (nichts passiert).
|
||||
# * Der Befehl muss ohne Rueckfragen durchlaufen und mit 0 enden, wenn alles gut ist.
|
||||
#
|
||||
# Beispiele (unzutreffende Zeilen loeschen):
|
||||
#
|
||||
# Python: ruff check . && pytest -q
|
||||
# Node/TS: npm run lint && npm test
|
||||
# Frontend: npm run build
|
||||
# Nur Syntax: python -m compileall -q .
|
||||
# Nichts da: git diff --stat (laeuft immer gruen — Platzhalter)
|
||||
|
||||
git diff --stat
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"aibox": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "AI-Box ueber Governor (lokal)",
|
||||
"options": {
|
||||
"baseURL": "http://127.0.0.1:8100/v1",
|
||||
"apiKey": "local"
|
||||
},
|
||||
"models": {
|
||||
"coder": {
|
||||
"name": "coder — Bauen + Planen (Qwen3-Coder-Next, 51,5 t/s)",
|
||||
"limit": {
|
||||
"context": 131072,
|
||||
"output": 16384
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"name": "hermes — Suchen (Qwen3.6-35B, immer warm, 69,6 t/s)",
|
||||
"limit": {
|
||||
"context": 65536,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"kritiker": {
|
||||
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
|
||||
"limit": {
|
||||
"context": 16384,
|
||||
"output": 4096
|
||||
}
|
||||
},
|
||||
"heavy": {
|
||||
"name": "heavy — Nacht-Gutachter (gpt-oss-120b, verdraengt das warme Set!)",
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 8192
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "aibox/coder",
|
||||
"small_model": "aibox/hermes",
|
||||
"agent": {
|
||||
"plan": {
|
||||
"model": "aibox/coder",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"model": "aibox/coder",
|
||||
"permission": {
|
||||
"edit": "allow",
|
||||
"webfetch": "allow",
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"ssh *": "deny",
|
||||
"scp *": "deny",
|
||||
"sftp *": "deny",
|
||||
"ssh arcane@192.168.178.162 *": "allow",
|
||||
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||
"scp *arcane@192.168.178.162*": "allow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"mode": "subagent",
|
||||
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, laeuft auf dem immer warmen Hirn",
|
||||
"model": "aibox/hermes",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"mode": "subagent",
|
||||
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, aendert nichts. Laeuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
|
||||
"model": "aibox/kritiker",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"aibox": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "AI-Box ueber Governor (192.168.178.151:8100)",
|
||||
"options": {
|
||||
"baseURL": "http://192.168.178.151:8100/v1",
|
||||
"apiKey": "local"
|
||||
},
|
||||
"models": {
|
||||
"heavy": {
|
||||
"name": "heavy — Planer (gpt-oss-120b, 32k)",
|
||||
"limit": {
|
||||
"context": 32768,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"coder": {
|
||||
"name": "coder — Bauen (Qwen3-Coder-Next, 131k)",
|
||||
"limit": {
|
||||
"context": 131072,
|
||||
"output": 16384
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"name": "hermes — Erkunden (Qwen3.6, immer warm, 69,6 t/s)",
|
||||
"limit": {
|
||||
"context": 65536,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"kritiker": {
|
||||
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
|
||||
"limit": {
|
||||
"context": 16384,
|
||||
"output": 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "aibox/coder",
|
||||
"small_model": "aibox/hermes",
|
||||
"agent": {
|
||||
"plan": {
|
||||
"model": "aibox/coder",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"model": "aibox/coder",
|
||||
"permission": {
|
||||
"edit": "allow",
|
||||
"webfetch": "allow",
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"ssh *": "deny",
|
||||
"scp *": "deny",
|
||||
"sftp *": "deny",
|
||||
"ssh arcane@192.168.178.162 *": "allow",
|
||||
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||
"scp *arcane@192.168.178.162*": "allow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"mode": "subagent",
|
||||
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, läuft auf dem immer warmen Hirn",
|
||||
"model": "aibox/hermes",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"mode": "subagent",
|
||||
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, ändert nichts. Läuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
|
||||
"model": "aibox/kritiker",
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/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)
|
||||
@@ -84,7 +84,7 @@ def norm(s):
|
||||
|
||||
def code_block(text, sprache="python"):
|
||||
"""Letzten passenden Code-Block extrahieren; ohne Zaun: ganzen Text nehmen."""
|
||||
bloecke = re.findall(r"```(?:%s)?\s*\n(.*?)```" % re.escape(sprache),
|
||||
bloecke = re.findall(rf"```(?:{re.escape(sprache)})?\s*\n(.*?)```",
|
||||
text or "", re.DOTALL | re.IGNORECASE)
|
||||
if bloecke:
|
||||
return bloecke[-1]
|
||||
@@ -469,7 +469,7 @@ 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, [
|
||||
_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)
|
||||
@@ -487,7 +487,7 @@ def suite_speed(cfg, ergebnisse):
|
||||
return
|
||||
lang = LANG_BAUSTEIN * 550
|
||||
t0 = time.time()
|
||||
msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
|
||||
_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)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prüfstand (17.07.2026): das "volle Programm" für Modell-Kandidaten — echte Coding-,
|
||||
# Agenten-, Recherche-, Deutsch- und Vision-Prüfungen (harness.py) plus Tempo, verglichen
|
||||
# gegen die BASELINE der Amtsinhaber. Läuft als Cron (So 01:00, --no-agent: stdout wird
|
||||
# wörtlich zugestellt; LEERER stdout = stille Nacht).
|
||||
# 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
|
||||
@@ -27,7 +27,7 @@ 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:-35}"
|
||||
DECKEL_GB="${PRUEFSTAND_DECKEL_GB:-100}"
|
||||
DEADLINE_MIN="${PRUEFSTAND_DEADLINE_MIN:-100}"
|
||||
|
||||
mkdir -p "$STATE/queue" "$STATE/done" "$STATE/ergebnisse"
|
||||
@@ -342,7 +342,9 @@ PY
|
||||
|
||||
# ---- Steckbrief: Kandidat vs. Amtsinhaber
|
||||
local BERICHT="$STATE/ergebnisse/kandidat-${KEY}.md"
|
||||
python3 - "$OUT" "$STATE/baseline.json" "$ROLLE" "$HF_ID" "$WARUM" "$BERICHT" <<'PY'
|
||||
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:
|
||||
@@ -378,14 +380,15 @@ if 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 — Karte prüfen lohnt sich."
|
||||
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("_Der Prüfstand empfiehlt nur — der Rollen-Wechsel bleibt Commander-Entscheid (Karte)._")
|
||||
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)
|
||||
@@ -402,6 +405,35 @@ PY
|
||||
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}.*)"
|
||||
|
||||
@@ -15,6 +15,11 @@ set -u
|
||||
URL="${MC_LLAMA_SWAP_URL:-http://127.0.0.1:8080}"
|
||||
BRAINS="${MC_WARMUP_MODELS:-fast}"
|
||||
EMBEDS="${MC_WARMUP_EMBED:-embed}"
|
||||
# Reranker laufen im --reranking-Modus und antworten NUR auf /v1/rerank — weder Chat noch
|
||||
# Embeddings wecken sie. Solange das hier fehlte, galt der Reranker dem Waechter dauerhaft
|
||||
# als "fehlend": er rief warmup.sh alle 90 s auf, ohne dass sich je etwas aendern konnte
|
||||
# (600 vergebliche Laeufe in 24 h, aelteste Meldung 15.07.2026).
|
||||
RERANKS="${MC_WARMUP_RERANK:-reranker}"
|
||||
|
||||
if [ "${1:-}" != "--inner" ]; then
|
||||
setsid "$0" --inner >/dev/null 2>&1 &
|
||||
@@ -41,6 +46,13 @@ for m in $EMBEDS; do # Embedding-Modelle über /v1/emb
|
||||
>/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
for m in $RERANKS; do # Reranker über /v1/rerank (eigener Endpunkt!)
|
||||
curl -s -m 120 -X POST "$URL/v1/rerank" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"model\":\"$m\",\"query\":\"ping\",\"documents\":[\"ping\"]}" \
|
||||
>/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# Agenten-Prompt vorkauen: Hermes' Systemprompt (~70 KB, 33 Tool-Schemas) kostet nach
|
||||
# jedem Modell-(Neu-)Laden sonst 30-40 s Prefill BEIM ERSTEN USER-CALL (gemessen 03.07.:
|
||||
# kalt 37 s, mit gefülltem Prompt-Cache ~6 s). Ein Wegwerf-Turn füllt den -cram-Cache.
|
||||
|
||||
@@ -129,6 +129,33 @@ erweitert nur der Commander, nie das Skript.
|
||||
legt er zusätzlich EINE rohe Idee in die Ideen-Queue („Probe bauen?", idempotent je
|
||||
Release-Tag) — der Blick nach draußen füttert damit denselben Kreislauf.
|
||||
|
||||
## Curator Auto-Archivierung (Cron `0 3 * * *` — täglich um 03:00)
|
||||
|
||||
**Zweck:** Automatische Archivierung inaktiver Skills nach 14 Tagen ohne Nutzung.
|
||||
|
||||
**Skripte:**
|
||||
- `~/.hermes/scripts/curator-archive-watchdog.sh` — Cron-Einstieg, ruft das Python-Skript auf.
|
||||
- `~/.hermes/scripts/curator_archive_logic.py` — Hauptlogik. Liest `.curator_state` und `.usage.json` aus `~/.hermes/skills/`, prüft auf Inaktivität (>14 Tage seit letztem Gebrauch), überspringt gepinnte und exkludierte Skills, verschiebt Skills nach `.archive/<Kategorie>/`.
|
||||
- `~/.hermes/scripts/curator-idle-watchdog.sh` — Zusätzlicher Watchdog (Sonntag 00:00, Cron `0 0 * * 0`): prüft, ob der Curator selbst ≥14 Tage idle ist und triggert ggf. eine Reaktivierung.
|
||||
|
||||
**Konfiguration** (`~/.hermes/skills/.curator_state`):
|
||||
- `archivierung.enabled: false` — Archivierung muss explizit aktiviert werden.
|
||||
- `archivierung.threshold_days: 14` — Tage ohne Nutzung bis zur Archivierung.
|
||||
- `archivierung.excluded_skills: [...]` — Liste der nie zu archivierenden Skills.
|
||||
- `archivierung.last_archive_run_at` — Zeitstempel des letzten Durchlaufs.
|
||||
|
||||
**Ablauf im Durchlauf:**
|
||||
1. Prüft, ob Archivierung aktiviert ist.
|
||||
2. Läuft alle Skills durch `.usage.json`.
|
||||
3. Überspringt: gepinnte Skills, exkludierte Skills, aktive Skills (<14 Tage).
|
||||
4. Archiviert: State auf "archived" setzen, Verzeichnis nach `.archive/<Kategorie>/` verschieben, Usage-Datei aktualisieren.
|
||||
5. Protokolliert in `~/.hermes/logs/curator/archive-<Datum>.log`.
|
||||
|
||||
**Sicherheit:**
|
||||
- Archivierung standardmäßig deaktiviert — passiert nie ohne explizite Freigabe.
|
||||
- Gepinnte und exkludierte Skills werden nie berührt.
|
||||
- Archivierung ist reversibel: Skills können manuell aus `.archive/` zurückverschoben werden.
|
||||
|
||||
## Leitplanken
|
||||
|
||||
- Propose-only bleibt: **keine Code-Zeile geht ohne Klick live.** Das Gate ist dieses
|
||||
|
||||
+22
-2
@@ -51,10 +51,14 @@ einen User-Entscheid — kein „Best Practice sagt aber…" (siehe ARBEITSWEISE
|
||||
⚠️ `llama-quantize` verwirft DFlash-Sondertensoren bei Nicht-Standard-Archs.
|
||||
- **coder-Lane: Qwen3-Coder-Next, FINAL** („bleiben bei dem was wir haben, nachweislich
|
||||
besser"). Qwen3.6-27B dense gebencht + verworfen (tg 12,7 t/s = 4–7× langsamer).
|
||||
- **hipEngine + strix-halo-toolboxes/TheRock: beobachten, nicht adoptieren.** hipEngine
|
||||
|- **hipEngine + strix-halo-toolboxes/TheRock: beobachten, nicht adoptieren.** hipEngine
|
||||
(natives HIP, nur Qwen3.6, >2× Prefill) ist jung; kyuz0-Toolboxes lösen ein Problem, das
|
||||
die Box nicht hat. Radar/Reminder wachen; Adoption nur nach eigenem brain-bench.
|
||||
- **dist/ im Git ist ABSICHT.** Box hat kein Node; Deploy = `git reset --hard` + committetes
|
||||
|- **Vision-Rolle: GLM-4.6V-Flash bleibt Amtsinhaber.** Qwen-AgentWorld-35B geprüft (Prüfstand
|
||||
t_9daf088f, Speed: tg 26,8 t/s / pp 339,1, Vision: 0/1 korrekt). Qualität deutlich unter
|
||||
GLM-4.6V-Flash, kein Aufsteiger — **abgelehnt**. Wiedervorlage nur bei neuer Version mit
|
||||
nachweislich > 50 % Vision-Rate.
|
||||
|- **dist/ im Git ist ABSICHT.** Box hat kein Node; Deploy = `git reset --hard` + committetes
|
||||
`frontend/dist`. NICHT gitignoren.
|
||||
|
||||
## Lucy (Voice-App)
|
||||
@@ -126,3 +130,19 @@ Gemessen mit llama-bench (Vulkan/RADV, Q4_K_M, -fa on) auf der Box (Ryzen AI Max
|
||||
Speed- und kein Backend-Problem und wird weder durch Modelltausch noch ROCm geloest. Es
|
||||
ist Reliabilitaet = Modellklasse + Harness. Naechster Hebel: Post-Edit-Verify-Hook,
|
||||
kleine Etappen/frische Threads, Eskalation der harten 20% an Frontier.
|
||||
- **Devstral Small 2 ist der KRITIKER, nicht der Coder — mit hartem 16k-Deckel (25.07.).**
|
||||
Das Verdikt „als Coder disqualifiziert" (24.07.) bleibt unveraendert richtig. Neu ist nur die
|
||||
ENGE Rolle: Gegenlesen einer Etappe. Gemessen am 25.07. auf der Box: Generierung 15,0 t/s
|
||||
(Coder 51,5), Prefill 265-318 t/s (Coder 754). Der Prefill bricht mit der Kontextlaenge ein
|
||||
(32k -> 63 t/s = ~9 min nur zum Lesen), deshalb ist der Kritiker in llama-swap UND in beiden
|
||||
opencode.json auf **16384** gedeckelt: schlimmster Fall je Review bleibt unter einer Minute.
|
||||
Der Nutzen liegt nicht im Tempo, sondern in der FREMDEN Modellfamilie — ein Mistral-Modell hat
|
||||
andere blinde Flecken als der Qwen-Coder. Alternative GLM-4.7-Flash (MoE, schnellerer Prefill)
|
||||
bleibt eine Zeile Config entfernt, kostet aber ~9 Punkte SWE-bench (59,2 vs. 68,0).
|
||||
- **llama-swap haelt nur EINE Gruppe resident, und `persistent: true` ist gefaehrlich (25.07.).**
|
||||
Zwei ko-residente Gruppen verdraengen sich gegenseitig — alles, was gleichzeitig warm sein muss,
|
||||
gehoert in DIESELBE Gruppe (`brains`). `persistent: true` hindert llama-swap daran, vor einem
|
||||
grossen On-Demand-Modell abzuraeumen: mit 107 GB Warm-Set + Vision (20 GB) folgte ein
|
||||
**Kernel-OOM** (llama-server abgeschossen). Regel: `Warm-Set + groesstes On-Demand-Modell
|
||||
<= ~115 GB` UND `persistent: false`. Danach lud vision in 10 s statt 117 s + Absturz.
|
||||
Kontext-Halbierung spart bei Qwen3-Next uebrigens **0 GB** (Hybrid-Attention, winziger KV).
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-DZ34eLRy.js";/**
|
||||
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-BChEIiQl.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as m,ag as S,r as c,j as e,ah as z,L as g,U as M,ai as C,ad as D,aj as A,ak as B,al as Z,e as E,am as L,u as R,b as q,g as k,q as T}from"./index-DZ34eLRy.js";import{R as j}from"./rotate-ccw-DRxIaGHA.js";/**
|
||||
import{c as m,ai as S,r as c,j as e,aj as z,L as g,U as M,ak as C,ae as D,al as A,am as B,an as Z,e as E,ao as L,u as R,b as q,g as k,q as T}from"./index-BChEIiQl.js";import{R as j}from"./rotate-ccw-CPYnjQ6M.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as v,r as c,I,J as A,j as e,K as h,x as B,B as H,D as E,M as z,e as M,L as T,N as F,C as K}from"./index-DZ34eLRy.js";import{F as O}from"./folder-open-Bb3HvkQd.js";import{C as R}from"./circle-x-Cw3-1mKp.js";import{C as W}from"./copy-aWlMWseD.js";/**
|
||||
import{c as v,r as c,I,J as A,j as e,K as h,x as B,B as H,D as E,M as z,e as M,L as T,N as F,C as K}from"./index-BChEIiQl.js";import{F as O}from"./folder-open-CtWva2VF.js";import{C as R}from"./circle-x-D8NCr-a2.js";import{C as W}from"./copy-CRj5M_pY.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as h,an as b,r as i,j as e,L as g,ao as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-DZ34eLRy.js";import{B as S}from"./book-open-C27V5HRN.js";import{C as z}from"./circle-x-Cw3-1mKp.js";/**
|
||||
import{c as h,ap as b,r as i,j as e,L as g,aq as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-BChEIiQl.js";import{B as S}from"./book-open-2v7qByfb.js";import{C as z}from"./circle-x-D8NCr-a2.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+30
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user