2 Commits

Author SHA1 Message Date
Hitonabi 798de0be8f Governor Phase 0 fertig + Phase 2: Hart-Deckel default + Sprach-Signal an Lucy
Phase 0 abgeschlossen:
- Hart-Deckel jetzt Default AN (Soft+5000), da der weiche Schnitt allein bei
  Weiterarbeit ueber die Grenze eine Fassade erzeugt (P0-Befund). GOV_HARD_CEILING=0
  schaltet ihn aus. gov-ctl reicht Arg 2 nur bei Bedarf durch.
- README: Empfehlung/Doku auf Default-an aktualisiert, Commit-Msg-Restpunkt notiert
  (--no-auto-commits als saubere Option).

Phase 2 (Voice-Hook): beim Feuern (soft/hard) POSTet der Governor best-effort +
gedrosselt (Default 300 s) eine Meldung an Lucys vorhandene Announce-Pipeline
(POST :9001/api/voice/announce, source=governor, priority=normal). Lucy pollt,
dedupliziert und spricht sie via lokales TTS -- KEIN Lucy-Code noetig. E2E bewiesen:
Feuern -> ANNOUNCE status=200 -> Eintrag id 394 source=governor in der Queue.
GOV_ANNOUNCE_URL="" schaltet es ab. announce-test.sh beigelegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:06:10 +02:00
Hitonabi c13cfd2bd0 Governor Phase 0: Token-Waechter-Proxy + Aider Session-Hygiene (bewiesen)
Duenner, zustandsloser Proxy (nur stdlib) zwischen Aider und llama-swap :8080.
Schiebt an einer Token-Schwelle "SAVEPOINT.md finalisieren + stoppen" ein (weich)
bzw. antwortet oberhalb eines optionalen Hart-Deckels selbst. Beweist Session-
Hygiene per hartem Schnitt statt Auto-Compaction -- ohne eine Zeile Lucy-Code.

Alle 4 Akzeptanzkriterien gruen: feuert im Log; SAVEPOINT.md gepflegt; ehrlicher
Grenz-Savepoint am ersten Feuern; frische Sitzung liest Savepoint -> baut Tests,
9 unittest gruen, keine Fassade. CPT 3.5 kalibriert (est ~= echte prompt_tokens).
Hart-Deckel nachgeruestet (weicher Schnitt allein erzeugt Fassade bei Weiterarbeit
ueber die Grenze). Streaming-read1-Fix + 4 kleinere aus adversarialer Review.

Laeuft deployt auf der Box unter ~/governor-p0/ (gov-ctl.sh start <soft> <hart>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:17:36 +02:00
12 changed files with 842 additions and 0 deletions
+33
View File
@@ -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.
+134
View File
@@ -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 ~13 % bei echten Sessions):
- **est < Soft** → unverändert durchreichen.
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
ehrlichen Abschluss-Savepoint.
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
mitgeloggt.
### Sprach-Signal an Lucy (Phase 2)
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
### Umgebungsvariablen
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
```bash
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
pipx install uv && uv tool install --python 3.12 aider-chat
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
# Aider-Sitzung durch den Governor:
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
```
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
`openai/Qwen3-Coder-Next` auf den Governor.
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
## Ergebnisse & Befunde (24.07.2026)
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
`HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
Schritt) und verweigerte neuen Code.
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
Tests laufen grün** gegen die echte API. Kein Erfinden.
### Kalibrierung
`CHARS_PER_TOKEN = 3.5``est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
### Weitere Befunde / Fallen
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
Aider selbst und der Governor greift nie. Siehe oben.
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
Python 3.12 installiert.
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
lokalen `:8080`-Endpoint irrelevant.
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
## Nächste Schritte (Phase 1+)
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
+23
View File
@@ -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.
+22
View File
@@ -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")'
+67
View File
@@ -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
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
os.environ.setdefault("OPENAI_API_KEY", "dummy")
from aider.coders import Coder # noqa: E402
from aider.models import Model # noqa: E402
from aider.io import InputOutput # noqa: E402
def main() -> int:
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: # noqa: BLE001
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: # noqa: BLE001
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
break
sent = getattr(coder, "total_tokens_sent", "?")
recv = getattr(coder, "total_tokens_received", "?")
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
print("\n===== SESSION ENDE =====", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -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
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""Governor — duenner, zustandsloser Token-Waechter-Proxy (Phase 0).
Sitzt zwischen einem Coding-Agenten (Aider) und dem Modell-Endpoint (llama-swap
:8080). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
/v1/chat/completions: er schaetzt die Token-Groesse der Anfrage (= Sessiongroesse,
weil die ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
est >= SCHWELLE (soft): haengt eine Stopp-Anweisung als letzte User-Nachricht an
("SAVEPOINT.md finalisieren + stoppen") und leitet weiter. Das Modell schreibt
EINEN ehrlichen Abschluss-Savepoint. Loggt FIRED.
est >= HART-DECKEL (optional): antwortet SELBST mit einer kurzen Stopp-Nachricht,
OHNE das Modell zu fragen. Verhindert, dass ueber die Grenze hinaus
weitergearbeitet wird — genau das erzeugte in Tests eine Fassade. Loggt HARDSTOP.
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
Bewusst zustandslos: jede Anfrage wird fuer sich bewertet; keine Sitzungs-DB.
Konfiguration per Umgebungsvariablen (alle optional):
GOV_PORT Listen-Port (Default 8100)
GOV_HOST Listen-Adresse (Default 0.0.0.0)
GOV_UPSTREAM Modell-Endpoint (Default http://127.0.0.1:8080)
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 25000)
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
GOV_CHARS_PER_TOKEN Heuristik Zeichen->Token (Default 3.5, kalibriert)
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
GOV_DIRECTIVE Text des Soft-Einschubs (sonst Default unten)
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort (sonst Default unten)
GOV_ANNOUNCE_URL Lucy-Sprach-Signal-Endpunkt; "" = aus (Default :9001/api/voice/announce)
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
GOV_ANNOUNCE_TEXT Text des Sprach-Signals (sonst Default)
"""
import http.client
import json
import os
import re
import threading
import time
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")
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:8080")
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "25000"))
# Hart-Deckel: Default AN (Soft + 5000 = ein Finalisier-Zug Luft), weil der weiche
# Schnitt allein bei Weiterarbeit ueber die Grenze eine Fassade erzeugt (Befund P0).
# Explizit setzbar; GOV_HARD_CEILING=0 schaltet ihn aus.
_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.5"))
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
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)
# Sprach-Signal an Lucy (Phase 2): beim Feuern POSTet der Governor eine Meldung an die
# vorhandene MC2-Announce-Pipeline (:9001). Lucy pollt sie ohnehin, dedupliziert und
# spricht sie (gated durch ihren "Box-Meldungen laut"-Schalter). Best-effort, gedrosselt
# gegen die Pro-Runde-Feuerung. GOV_ANNOUNCE_URL="" schaltet das Signal ab.
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")) # Sekunden
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 # Zeitstempel der letzten Meldung (Drossel)
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
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 estimate_tokens(messages) -> int:
"""Grobe, aber stabile Heuristik: Zeichen aller Nachrichteninhalte / CPT.
Gegen die echten prompt_tokens aus der Antwort kalibriert (CPT=3.5 traf am
24.07. auf ~1-3 % genau). Zaehlt Text in String- und Multimodal-Listen-Inhalten;
kleiner Aufschlag je Nachricht fuer Rollen-/Template-Overhead.
"""
chars = 0
for m in messages or []:
chars += 4
content = m.get("content") if isinstance(m, dict) else None
if isinstance(content, str):
chars += len(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
chars += len(part["text"])
return int(chars / CHARS_PER_TOKEN)
def _post_announce(est) -> None:
"""POSTet die Meldung an die MC2-Announce-Pipeline. Laeuft im Hintergrund-Thread."""
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: # noqa: BLE001
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
def maybe_announce(est) -> None:
"""Sprach-Signal an Lucy ausloesen — gedrosselt, damit die Pro-Runde-Feuerung
nicht spammt (eine Aeusserung je Ueberschreitungs-Episode). Best-effort."""
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()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "Governor/0.2"
def log_message(self, *args):
pass
def do_GET(self):
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 _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
# Query-String vor der Endpunkt-Erkennung abschneiden (sonst umgeht
# z. B. ?api-version=... den Governor).
clean_path = path.split("?", 1)[0]
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
action = "passthrough"
est = None
streaming = False
model = ""
if is_chat and body:
action, body, est, streaming, model = self._decide(body)
if action in ("soft", "hard"):
maybe_announce(est) # Sprach-Signal an Lucy (gedrosselt)
# HARTER STOPP: selbst antworten, Upstream nie fragen.
if action == "hard":
self._send_canned_stop(model, streaming, est)
log(f"chat est={est} thr={THRESHOLD} hard={HARD_CEILING} HARDSTOP "
f"stream={streaming} status=200")
return
# Header fuer Upstream aufbereiten.
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=600)
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()
# hop-by-hop + Laenge raus; Date/Server setzt send_response schon selbst.
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 bis 64 KB oder Stream-Ende puffern
# und streamendes Aider die ganze Generierung 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:
exact_s = str(exact) if exact is not None else "-"
flag = "FIRED" if action == "soft" else "ok"
log(f"chat est={est} exact={exact_s} thr={THRESHOLD} {flag} "
f"stream={streaming} status={resp.status}")
def _decide(self, body: bytes):
"""Aktion bestimmen: passthrough | soft (Einschub) | hard (Selbstantwort).
Rueckgabe: (action, body, est, streaming, model).
"""
try:
data = json.loads(body)
except (ValueError, UnicodeDecodeError):
return "passthrough", body, None, False, ""
if not isinstance(data, dict):
return "passthrough", body, None, False, ""
messages = data.get("messages")
streaming = bool(data.get("stream"))
model = data.get("model", "") or ""
est = estimate_tokens(messages if isinstance(messages, list) else [])
if HARD_CEILING > 0 and est >= HARD_CEILING:
return "hard", body, est, streaming, model
if est >= THRESHOLD and isinstance(messages, list):
# Sichere Substitution statt str.format: ein operator-gesetzter
# GOV_DIRECTIVE mit { } (JSON/Code-Beispiel) darf nicht crashen.
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
return "passthrough", body, est, streaming, model
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:
try:
data = json.dumps({"error": msg}).encode()
self.send_response(status)
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)
except OSError:
pass
@staticmethod
def _scan_prompt_tokens(tail: bytearray):
if not tail:
return None
try:
text = tail.decode("utf-8", errors="ignore")
except Exception: # noqa: BLE001
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 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
f"Hart={hard} | CPT={CHARS_PER_TOKEN} | 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())
+29
View File
@@ -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}'
+13
View File
@@ -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.
+19
View File
@@ -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"
+20
View File
@@ -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"
+8
View File
@@ -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"