Merge governor-phase0: Umbau Stufe 1-5 (Ampel #3 gruen)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
+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,541 @@
|
||||
#!/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 HARD_CEILING > 0 and est >= HARD_CEILING:
|
||||
return "hard", body, est, streaming, model, chars
|
||||
|
||||
if est >= THRESHOLD and isinstance(messages, list):
|
||||
if not safe_to_append(messages):
|
||||
# Mitten im Werkzeug-Austausch: nicht dazwischenfunken, naechste Runde.
|
||||
return "defer", body, est, streaming, model, chars
|
||||
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
|
||||
|
||||
@@ -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,55 @@
|
||||
# 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` |
|
||||
| `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 |
|
||||
|
||||
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,67 @@
|
||||
{
|
||||
"$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, 15,0 t/s)",
|
||||
"limit": { "context": 65536, "output": 8192 }
|
||||
},
|
||||
"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,67 @@
|
||||
{
|
||||
"$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, 15,0 t/s)",
|
||||
"limit": { "context": 65536, "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, 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user