Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d6be8c4a | |||
| 7fac17ed9a |
@@ -11,6 +11,10 @@
|
||||
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
||||
deploy/agent-hooks/*.py text eol=lf
|
||||
deploy/*.py text eol=lf
|
||||
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
|
||||
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
|
||||
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
|
||||
deploy/**/*.py text eol=lf
|
||||
|
||||
# Windows-Batch-Wrapper bleiben CRLF.
|
||||
*.cmd text eol=crlf
|
||||
|
||||
@@ -20,3 +20,6 @@ frontend/dist/avatar.vrm
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from routers import (
|
||||
eigenleben,
|
||||
events,
|
||||
gateway_proxy,
|
||||
governor,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
@@ -141,6 +142,7 @@ app.include_router(maintenance.router)
|
||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
||||
app.include_router(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
|
||||
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||
|
||||
@@ -55,6 +55,17 @@
|
||||
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
||||
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
|
||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
||||
},
|
||||
|
||||
{
|
||||
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
|
||||
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
|
||||
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||
},
|
||||
{
|
||||
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
|
||||
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
|
||||
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Governor — Fenster auf den Token-Waechter (:8100).
|
||||
|
||||
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
|
||||
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
|
||||
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
|
||||
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
|
||||
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
|
||||
zweiten Port ausweichen muss.
|
||||
|
||||
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
|
||||
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
|
||||
|
||||
|
||||
@router.get("/governor")
|
||||
async def governor_status() -> dict:
|
||||
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as c:
|
||||
r = await c.get(f"{GOVERNOR_URL}/governor/status")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
data["reachable"] = True
|
||||
return data
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"url": GOVERNOR_URL,
|
||||
}
|
||||
+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 ;;
|
||||
|
||||
+213
-92
@@ -1,34 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governor — duenner, zustandsloser Token-Waechter-Proxy (Phase 0).
|
||||
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
|
||||
|
||||
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:
|
||||
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 >= 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.
|
||||
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 zustandslos: jede Anfrage wird fuer sich bewertet; keine Sitzungs-DB.
|
||||
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 Modell-Endpoint (Default http://127.0.0.1:8080)
|
||||
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 25000)
|
||||
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 Heuristik Zeichen->Token (Default 3.5, kalibriert)
|
||||
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 (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)
|
||||
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
|
||||
@@ -37,6 +55,7 @@ import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -44,16 +63,22 @@ from urllib.parse import urlparse
|
||||
|
||||
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.
|
||||
# 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.5"))
|
||||
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 "
|
||||
@@ -76,12 +101,8 @@ DEFAULT_HARDSTOP = (
|
||||
)
|
||||
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
|
||||
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."
|
||||
@@ -100,9 +121,21 @@ HOP_BY_HOP = {
|
||||
_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)
|
||||
_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)."""
|
||||
@@ -117,28 +150,77 @@ def log(line: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def estimate_tokens(messages) -> int:
|
||||
"""Grobe, aber stabile Heuristik: Zeichen aller Nachrichteninhalte / CPT.
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
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. Laeuft im Hintergrund-Thread."""
|
||||
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
|
||||
try:
|
||||
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
||||
.replace("{threshold}", str(THRESHOLD)))
|
||||
@@ -158,8 +240,7 @@ def _post_announce(est) -> None:
|
||||
|
||||
|
||||
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."""
|
||||
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
|
||||
if not _an:
|
||||
return
|
||||
global _last_announce
|
||||
@@ -171,14 +252,35 @@ def maybe_announce(est) -> None:
|
||||
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/0.2"
|
||||
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):
|
||||
@@ -194,6 +296,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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:
|
||||
@@ -206,8 +321,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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")
|
||||
|
||||
@@ -215,19 +328,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
est = None
|
||||
streaming = False
|
||||
model = ""
|
||||
chars = 0
|
||||
if is_chat and body:
|
||||
action, body, est, streaming, model = self._decide(body)
|
||||
action, body, est, streaming, model, chars = self._decide(body)
|
||||
if action in ("soft", "hard"):
|
||||
maybe_announce(est) # Sprach-Signal an Lucy (gedrosselt)
|
||||
maybe_announce(est)
|
||||
|
||||
# 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")
|
||||
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
|
||||
|
||||
# Header fuer Upstream aufbereiten.
|
||||
out_headers = {}
|
||||
for k, v in self.headers.items():
|
||||
kl = k.lower()
|
||||
@@ -242,7 +359,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=600)
|
||||
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:
|
||||
@@ -255,7 +372,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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)
|
||||
@@ -266,8 +382,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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.
|
||||
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
|
||||
chunk = resp.read1(65536)
|
||||
if not chunk:
|
||||
break
|
||||
@@ -283,41 +398,55 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
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 "ok"
|
||||
log(f"chat est={est} exact={exact_s} thr={THRESHOLD} {flag} "
|
||||
f"stream={streaming} status={resp.status}")
|
||||
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: passthrough | soft (Einschub) | hard (Selbstantwort).
|
||||
|
||||
Rueckgabe: (action, body, est, streaming, model).
|
||||
"""
|
||||
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return "passthrough", body, None, False, ""
|
||||
return "passthrough", body, None, False, "", 0
|
||||
if not isinstance(data, dict):
|
||||
return "passthrough", body, None, False, ""
|
||||
return "passthrough", body, None, False, "", 0
|
||||
|
||||
messages = data.get("messages")
|
||||
streaming = bool(data.get("stream"))
|
||||
model = data.get("model", "") or ""
|
||||
est = estimate_tokens(messages if isinstance(messages, list) else [])
|
||||
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
|
||||
return "hard", body, est, streaming, model, chars
|
||||
|
||||
if est >= THRESHOLD and isinstance(messages, list):
|
||||
# Sichere Substitution statt str.format: ein operator-gesetzter
|
||||
# GOV_DIRECTIVE mit { } (JSON/Code-Beispiel) darf nicht crashen.
|
||||
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
|
||||
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
|
||||
|
||||
return "passthrough", body, est, streaming, model
|
||||
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)."""
|
||||
@@ -366,16 +495,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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
|
||||
self._send_json(status, {"error": msg})
|
||||
|
||||
@staticmethod
|
||||
def _scan_prompt_tokens(tail: bytearray):
|
||||
@@ -405,8 +525,9 @@ def main() -> int:
|
||||
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}")
|
||||
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:
|
||||
|
||||
@@ -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,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
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-DZ34eLRy.js";/**
|
||||
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as P,j as e,e as v,aa as Ne,ab as he,u as me,b as pe,r as o,a3 as J,L as S,C as ge,U as be,ac as we,V as Y,X as Z,T as R,D as $,M as T,g as B,ad as ve,ae as fe,af as ye,q as ke}from"./index-DZ34eLRy.js";import{L as _}from"./lightbulb-CUD1a5ov.js";import{C as V}from"./code-xml-CCSjVAVc.js";import{S as xe}from"./send-DrbIxJOY.js";import{L as De}from"./layers-a89ag0RB.js";import{R as Se}from"./rotate-ccw-DRxIaGHA.js";/**
|
||||
import{c as P,j as e,e as v,aa as Ne,ab as he,u as me,b as pe,r as o,a3 as J,L as S,C as ge,U as be,ac as we,V as Y,X as Z,T as R,D as $,M as T,g as B,ad as ve,ae as fe,af as ye,q as ke}from"./index-Cm0NCQeJ.js";import{L as _}from"./lightbulb-CBiiGPIh.js";import{C as V}from"./code-xml-p3YDy76e.js";import{S as xe}from"./send-Bc-GXqQz.js";import{L as De}from"./layers-CKJvqHFy.js";import{R as Se}from"./rotate-ccw-DQJileoK.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as m,ag as S,r as c,j as e,ah as z,L as g,U as M,ai as C,ad as D,aj as A,ak as B,al as Z,e as E,am as L,u as R,b as q,g as k,q as T}from"./index-DZ34eLRy.js";import{R as j}from"./rotate-ccw-DRxIaGHA.js";/**
|
||||
import{c as m,ag as S,r as c,j as e,ah as z,L as g,U as M,ai as C,ad as D,aj as A,ak as B,al as Z,e as E,am as L,u as R,b as q,g as k,q as T}from"./index-Cm0NCQeJ.js";import{R as j}from"./rotate-ccw-DQJileoK.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as v,r as c,I,J as A,j as e,K as h,x as B,B as H,D as E,M as z,e as M,L as T,N as F,C as K}from"./index-DZ34eLRy.js";import{F as O}from"./folder-open-Bb3HvkQd.js";import{C as R}from"./circle-x-Cw3-1mKp.js";import{C as W}from"./copy-aWlMWseD.js";/**
|
||||
import{c as v,r as c,I,J as A,j as e,K as h,x as B,B as H,D as E,M as z,e as M,L as T,N as F,C as K}from"./index-Cm0NCQeJ.js";import{F as O}from"./folder-open-CvG8JCKh.js";import{C as R}from"./circle-x-DT2INJve.js";import{C as W}from"./copy-BzlMSkzh.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as h,an as b,r as i,j as e,L as g,ao as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-DZ34eLRy.js";import{B as S}from"./book-open-C27V5HRN.js";import{C as z}from"./circle-x-Cw3-1mKp.js";/**
|
||||
import{c as h,an as b,r as i,j as e,L as g,ao as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-Cm0NCQeJ.js";import{B as S}from"./book-open-CMR6fip9.js";import{C as z}from"./circle-x-DT2INJve.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as p,j as e,r as M,x as C,a6 as z,a7 as k,a8 as y,a0 as v,W as L,a9 as G,a2 as T,e as f,U as D}from"./index-DZ34eLRy.js";import{B as w}from"./book-open-C27V5HRN.js";import{a as N,L as B,C as P,Z as E}from"./zap-BXgwt99q.js";import{L as R}from"./layers-a89ag0RB.js";import{L as I}from"./lightbulb-CUD1a5ov.js";/**
|
||||
import{c as p,j as e,r as M,x as C,a6 as z,a7 as k,a8 as y,a0 as v,W as L,a9 as G,a2 as T,e as f,U as D}from"./index-Cm0NCQeJ.js";import{B as w}from"./book-open-CMR6fip9.js";import{a as N,L as B,C as P,Z as E}from"./zap-BOjMx7d9.js";import{L as R}from"./layers-CKJvqHFy.js";import{L as I}from"./lightbulb-CBiiGPIh.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{Y as b,u as f,$ as h,r as d,j as e,e as c,Z as g,T as p,a3 as j,a4 as N,g as v,a5 as w,q as x}from"./index-DZ34eLRy.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
|
||||
import{Y as b,u as f,$ as h,r as d,j as e,e as c,Z as g,T as p,a3 as j,a4 as N,g as v,a5 as w,q as x}from"./index-Cm0NCQeJ.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-CfWXNn-Z.js","assets/index-DZ34eLRy.js","assets/index-CYYdYGeg.css"])))=>i.map(i=>d[i]);
|
||||
var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var J=(d,i,c)=>ke(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as ve,b as Ne,O as W,Q as Se,C as X,R as Ce,S as Ee,P as Me,U as O,e as x,V as ze,W as De,X as Z,A as Y,_ as Oe,g as j}from"./index-DZ34eLRy.js";import{C as ee}from"./copy-aWlMWseD.js";import{S as _e}from"./send-DrbIxJOY.js";import{B as Ge}from"./book-open-C27V5HRN.js";/**
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-B853GRcI.js","assets/index-Cm0NCQeJ.js","assets/index-Bkns39Uj.css"])))=>i.map(i=>d[i]);
|
||||
var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var J=(d,i,c)=>ke(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as ve,b as Ne,O as W,Q as Se,C as X,R as Ce,S as Ee,P as Me,U as O,e as x,V as ze,W as De,X as Z,A as Y,_ as Oe,g as j}from"./index-Cm0NCQeJ.js";import{C as ee}from"./copy-BzlMSkzh.js";import{S as _e}from"./send-Bc-GXqQz.js";import{B as Ge}from"./book-open-CMR6fip9.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -34,7 +34,7 @@ var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,config
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Pe=h("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);class te extends s.Component{constructor(){super(...arguments);J(this,"state",{error:null})}static getDerivedStateFromError(c){return{error:c}}render(){return this.state.error?e.jsxs("div",{className:"h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto",children:[e.jsx("div",{className:"font-semibold mb-2",children:"Graph konnte nicht gerendert werden"}),e.jsx("pre",{className:"whitespace-pre-wrap break-words text-[11px] leading-relaxed",children:this.state.error.message})]}):this.props.children}}const se=s.lazy(()=>Oe(()=>import("./GraphView-CfWXNn-Z.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],v=new Set(["auto","agent","hermes"]),N={identity:{label:"Identität",icon:Pe,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:De,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Le,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:ze,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:Ge,text:"text-muted-foreground"},Re={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},_={identity:"#00f5ff",knowledge:"#3b82f6",rules:"#d946ef",events:"#fbbf24"},ae=`Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
|
||||
*/const Pe=h("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);class te extends s.Component{constructor(){super(...arguments);J(this,"state",{error:null})}static getDerivedStateFromError(c){return{error:c}}render(){return this.state.error?e.jsxs("div",{className:"h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto",children:[e.jsx("div",{className:"font-semibold mb-2",children:"Graph konnte nicht gerendert werden"}),e.jsx("pre",{className:"whitespace-pre-wrap break-words text-[11px] leading-relaxed",children:this.state.error.message})]}):this.props.children}}const se=s.lazy(()=>Oe(()=>import("./GraphView-B853GRcI.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],v=new Set(["auto","agent","hermes"]),N={identity:{label:"Identität",icon:Pe,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:De,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Le,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:ze,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:Ge,text:"text-muted-foreground"},Re={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},_={identity:"#00f5ff",knowledge:"#3b82f6",rules:"#d946ef",events:"#fbbf24"},ae=`Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
|
||||
1) wer ich bin und woran ich gerade arbeite,
|
||||
2) wie ich angesprochen werden möchte,
|
||||
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
|
||||
+16
-16
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{ap as y,r as x,g as L,j as e,L as v,S,ae as W,U as C,e as w,aq as E}from"./index-DZ34eLRy.js";import{F as M}from"./folder-open-Bb3HvkQd.js";const D={"":"Allgemein",traeume:"Träume",muster:"Muster","skill-kandidaten":"Skill-Kandidaten","skill-kandidaten/beauftragt":"Skill-Kandidaten · beauftragt","skill-kandidaten/verworfen":"Skill-Kandidaten · verworfen"};function T(){const{data:o,isLoading:h}=y(),[l,f]=x.useState(null),[d,r]=x.useState(null),[u,i]=x.useState(!1),[a,p]=x.useState(""),t=x.useMemo(()=>(o==null?void 0:o.files)??[],[o]),m=x.useMemo(()=>{const s=new Map;for(const n of t)s.set(n.name.toLowerCase(),n.path);return s},[t]),g=x.useMemo(()=>{if(!a.trim())return t;const s=a.toLowerCase();return t.filter(n=>n.title.toLowerCase().includes(s)||n.path.toLowerCase().includes(s))},[t,a]),b=x.useMemo(()=>{const s=[];for(const n of g){const c=s.find(k=>k.dir===n.dir);c?c.files.push(n):s.push({dir:n.dir,files:[n]})}return s.sort((n,c)=>n.dir===""?-1:c.dir===""?1:n.dir.localeCompare(c.dir))},[g]);x.useEffect(()=>{if(!l&&t.length){const s=t.find(n=>n.path.toLowerCase()==="index.md");f((s==null?void 0:s.path)??t[0].path)}},[t,l]),x.useEffect(()=>{l&&(i(!0),L(`/api/wissen/datei?pfad=${encodeURIComponent(l)}`).then(r).catch(()=>r({path:l,content:"*(Notiz nicht ladbar)*",mtime:0})).finally(()=>i(!1)))},[l]);const j=s=>{const n=m.get(s.toLowerCase());n&&f(n)};return e.jsxs("div",{className:"space-y-5",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Wissen"}),e.jsx("p",{className:"text-sm text-muted-foreground",children:"Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki."})]}),o&&!o.available&&e.jsx("div",{className:"rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300",children:"Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar."}),h&&e.jsxs("div",{className:"flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Vault wird geladen …"]}),(o==null?void 0:o.available)&&t.length===0&&e.jsx("div",{className:"rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground",children:"Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste."}),t.length>0&&e.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row",children:[e.jsxs("aside",{className:"w-full shrink-0 space-y-3 lg:w-72",children:[e.jsxs("div",{className:"relative",children:[e.jsx("input",{value:a,onChange:s=>p(s.target.value),type:"search",placeholder:"Notizen durchsuchen…","aria-label":"Notizen durchsuchen",className:"h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50"}),e.jsx(S,{className:"absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground"})]}),e.jsx("div",{className:"max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin",children:b.map(({dir:s,files:n})=>e.jsxs("div",{children:[e.jsxs("p",{className:"mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(M,{className:"h-3 w-3"})," ",D[s]??s]}),e.jsx("div",{className:"space-y-0.5",children:n.map(c=>e.jsxs("button",{onClick:()=>f(c.path),className:w("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",l===c.path?"bg-primary/15 text-primary":"text-muted-foreground hover:bg-accent hover:text-foreground"),children:[e.jsx(W,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",title:c.title,children:c.name}),c.neu&&e.jsxs("span",{className:"ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300",children:[e.jsx(C,{className:"h-2.5 w-2.5"}),"neu"]})]},c.path))})]},s||"__root"))})]}),e.jsx("div",{className:"min-w-0 flex-1 mc-card p-5",children:u?e.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Notiz wird geladen …"]}):d?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between border-b border-border/40 pb-2",children:[e.jsxs("span",{className:"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground",children:[e.jsx(E,{className:"h-3.5 w-3.5"})," ",d.path]}),d.mtime>0&&e.jsxs("span",{className:"text-[10px] text-muted-foreground/60",children:["Stand ",new Date(d.mtime*1e3).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"})]})]}),e.jsx(z,{text:d.content,onWikiLink:j,known:m})]}):null})]})]})}function z({text:o,onWikiLink:h,known:l}){const f=o.split(`
|
||||
import{ap as y,r as x,g as L,j as e,L as v,S,ae as W,U as C,e as w,aq as E}from"./index-Cm0NCQeJ.js";import{F as M}from"./folder-open-CvG8JCKh.js";const D={"":"Allgemein",traeume:"Träume",muster:"Muster","skill-kandidaten":"Skill-Kandidaten","skill-kandidaten/beauftragt":"Skill-Kandidaten · beauftragt","skill-kandidaten/verworfen":"Skill-Kandidaten · verworfen"};function T(){const{data:o,isLoading:h}=y(),[l,f]=x.useState(null),[d,r]=x.useState(null),[u,i]=x.useState(!1),[a,p]=x.useState(""),t=x.useMemo(()=>(o==null?void 0:o.files)??[],[o]),m=x.useMemo(()=>{const s=new Map;for(const n of t)s.set(n.name.toLowerCase(),n.path);return s},[t]),g=x.useMemo(()=>{if(!a.trim())return t;const s=a.toLowerCase();return t.filter(n=>n.title.toLowerCase().includes(s)||n.path.toLowerCase().includes(s))},[t,a]),b=x.useMemo(()=>{const s=[];for(const n of g){const c=s.find(k=>k.dir===n.dir);c?c.files.push(n):s.push({dir:n.dir,files:[n]})}return s.sort((n,c)=>n.dir===""?-1:c.dir===""?1:n.dir.localeCompare(c.dir))},[g]);x.useEffect(()=>{if(!l&&t.length){const s=t.find(n=>n.path.toLowerCase()==="index.md");f((s==null?void 0:s.path)??t[0].path)}},[t,l]),x.useEffect(()=>{l&&(i(!0),L(`/api/wissen/datei?pfad=${encodeURIComponent(l)}`).then(r).catch(()=>r({path:l,content:"*(Notiz nicht ladbar)*",mtime:0})).finally(()=>i(!1)))},[l]);const j=s=>{const n=m.get(s.toLowerCase());n&&f(n)};return e.jsxs("div",{className:"space-y-5",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Wissen"}),e.jsx("p",{className:"text-sm text-muted-foreground",children:"Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki."})]}),o&&!o.available&&e.jsx("div",{className:"rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300",children:"Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar."}),h&&e.jsxs("div",{className:"flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Vault wird geladen …"]}),(o==null?void 0:o.available)&&t.length===0&&e.jsx("div",{className:"rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground",children:"Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste."}),t.length>0&&e.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row",children:[e.jsxs("aside",{className:"w-full shrink-0 space-y-3 lg:w-72",children:[e.jsxs("div",{className:"relative",children:[e.jsx("input",{value:a,onChange:s=>p(s.target.value),type:"search",placeholder:"Notizen durchsuchen…","aria-label":"Notizen durchsuchen",className:"h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50"}),e.jsx(S,{className:"absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground"})]}),e.jsx("div",{className:"max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin",children:b.map(({dir:s,files:n})=>e.jsxs("div",{children:[e.jsxs("p",{className:"mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(M,{className:"h-3 w-3"})," ",D[s]??s]}),e.jsx("div",{className:"space-y-0.5",children:n.map(c=>e.jsxs("button",{onClick:()=>f(c.path),className:w("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",l===c.path?"bg-primary/15 text-primary":"text-muted-foreground hover:bg-accent hover:text-foreground"),children:[e.jsx(W,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",title:c.title,children:c.name}),c.neu&&e.jsxs("span",{className:"ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300",children:[e.jsx(C,{className:"h-2.5 w-2.5"}),"neu"]})]},c.path))})]},s||"__root"))})]}),e.jsx("div",{className:"min-w-0 flex-1 mc-card p-5",children:u?e.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Notiz wird geladen …"]}):d?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between border-b border-border/40 pb-2",children:[e.jsxs("span",{className:"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground",children:[e.jsx(E,{className:"h-3.5 w-3.5"})," ",d.path]}),d.mtime>0&&e.jsxs("span",{className:"text-[10px] text-muted-foreground/60",children:["Stand ",new Date(d.mtime*1e3).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"})]})]}),e.jsx(z,{text:d.content,onWikiLink:j,known:m})]}):null})]})]})}function z({text:o,onWikiLink:h,known:l}){const f=o.split(`
|
||||
`),d=[];let r=[],u=null;const i=a=>{r.length&&(d.push(e.jsx("ul",{className:"mb-3 ml-4 list-disc space-y-1",children:r},a)),r=[])};return f.forEach((a,p)=>{const t=`l${p}`;if(u!==null){a.trimEnd()==="```"?(d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85",children:u.join(`
|
||||
`)},t)),u=null):u.push(a);return}if(a.trimStart().startsWith("```")){i(t),u=[];return}const m=a.trimEnd();if(!m.trim()){i(t);return}const g=m.match(/^(#{1,4})\s+(.*)$/);if(g){i(t);const j=g[1].length,s=j===1?"text-lg font-bold mt-1 mb-3":j===2?"text-base font-bold mt-4 mb-2":"text-sm font-bold mt-3 mb-1.5";d.push(e.jsx("p",{className:w(s,"font-space text-foreground"),children:N(g[2],h,l,t)},t));return}const b=m.match(/^\s*[-*•]\s+(.*)$/);if(b){r.push(e.jsx("li",{className:"text-xs leading-relaxed text-foreground/85",children:N(b[1],h,l,t)},t));return}i(t),d.push(e.jsx("p",{className:"mb-2 text-xs leading-relaxed text-foreground/85",children:N(m,h,l,t)},t))}),i("end"),u!==null&&d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]",children:u.join(`
|
||||
`)},"code-end")),e.jsx("div",{className:"max-w-3xl",children:d})}function N(o,h,l,f){return o.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g).map((r,u)=>{const i=`${f}-${u}`,a=r.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/);if(a){const p=a[1].trim(),t=(a[2]??a[1]).trim();return l.has(p.toLowerCase())?e.jsx("button",{onClick:()=>h(p),className:"rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer",children:t},i):e.jsx("span",{className:"rounded bg-background/40 px-1 text-muted-foreground",title:"Notiz existiert (noch) nicht",children:t},i)}return r.startsWith("**")&&r.endsWith("**")?e.jsx("b",{className:"font-semibold text-foreground",children:r.slice(2,-2)},i):r.startsWith("*")&&r.endsWith("*")&&r.length>2?e.jsx("i",{children:r.slice(1,-1)},i):r.startsWith("`")&&r.endsWith("`")?e.jsx("code",{className:"rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300",children:r.slice(1,-1)},i):e.jsx("span",{children:r},i)})}export{T as WissenView};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-DZ34eLRy.js";/**
|
||||
import{c as a}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c}from"./index-DZ34eLRy.js";/**
|
||||
import{c}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as e}from"./index-DZ34eLRy.js";/**
|
||||
import{c as e}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c}from"./index-DZ34eLRy.js";/**
|
||||
import{c}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-DZ34eLRy.js";/**
|
||||
import{c as a}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+466
File diff suppressed because one or more lines are too long
-461
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-DZ34eLRy.js";/**
|
||||
import{c as a}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as t}from"./index-DZ34eLRy.js";/**
|
||||
import{c as t}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as t}from"./index-DZ34eLRy.js";/**
|
||||
import{c as t}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-DZ34eLRy.js";/**
|
||||
import{c as a}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{c as a}from"./index-DZ34eLRy.js";/**
|
||||
import{c as a}from"./index-Cm0NCQeJ.js";/**
|
||||
* @license lucide-react v0.460.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-DZ34eLRy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CYYdYGeg.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cm0NCQeJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bkns39Uj.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Fuel, ShieldAlert, Check } from "lucide-react"
|
||||
import { useGovernor } from "@/lib/queries"
|
||||
import type { GovernorRun } from "@/lib/api"
|
||||
|
||||
// ── Token-Wächter („Tankuhr mit Handbremse") ────────────────────────────────
|
||||
// Zeigt in Klartext, wie voll die laufende Coding-Sitzung ist. Zwei Marken:
|
||||
// Soft = ab hier bittet der Governor um einen Savepoint (Sitzung sauber beenden)
|
||||
// Hart = ab hier antwortet er selbst und lässt das Modell gar nicht mehr ran
|
||||
// Die Zeile „Schätzung vs. Wahrheit" ist der Ehrlichkeits-Nachweis: bis 25.07. lag der
|
||||
// Governor bei werkzeugdichten Agenten um Faktor 3 daneben, weil er Werkzeug-Schemata
|
||||
// nicht mitzählte. Jetzt lernt er aus den echten prompt_tokens jeder Antwort.
|
||||
|
||||
const fmtNum = (n: number | null | undefined) =>
|
||||
n == null ? "–" : n.toLocaleString("de-DE")
|
||||
|
||||
function ago(ts: number): string {
|
||||
const s = Math.max(0, Date.now() / 1000 - ts)
|
||||
if (s < 60) return `vor ${Math.round(s)} s`
|
||||
if (s < 3600) return `vor ${Math.round(s / 60)} min`
|
||||
return `vor ${Math.round(s / 3600)} h`
|
||||
}
|
||||
|
||||
const ACTION_META: Record<string, { label: string; tone: string }> = {
|
||||
passthrough: { label: "frei", tone: "text-emerald-400" },
|
||||
defer: { label: "wartet", tone: "text-amber-400" },
|
||||
soft: { label: "Savepoint", tone: "text-amber-400" },
|
||||
hard: { label: "STOPP", tone: "text-rose-400" },
|
||||
}
|
||||
|
||||
/** Abweichung Schätzung↔Wahrheit in Prozent — der Ehrlichkeits-Messwert. */
|
||||
function drift(run: GovernorRun | undefined): number | null {
|
||||
if (!run?.est || !run?.exact) return null
|
||||
return ((run.est - run.exact) / run.exact) * 100
|
||||
}
|
||||
|
||||
export function GovernorCard() {
|
||||
const { data } = useGovernor()
|
||||
|
||||
if (!data?.reachable) {
|
||||
return (
|
||||
<div className="mc-card p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fuel className="h-4.5 w-4.5 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Wächter</h2>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Nicht erreichbar. Die Coding-Sitzungen laufen dann ohne Sitzungs-Bremse.
|
||||
<div className="mt-1 font-mono text-[10px] text-muted-foreground/60">
|
||||
{data?.error ?? "keine Antwort von :8100"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const soft = data.soft ?? 0
|
||||
const hard = data.hard ?? null
|
||||
const runs = data.recent ?? []
|
||||
// Nur echte Bau-Läufe füllen die Tankuhr; Lucys Gespräche sind ausgenommen.
|
||||
const lastBuild = runs.find((r) => !(data.exempt_models ?? []).includes(r.model))
|
||||
const füllung = lastBuild?.exact ?? lastBuild?.est ?? 0
|
||||
const skala = Math.max(hard ?? soft * 1.2, soft * 1.2, füllung)
|
||||
const pct = Math.min(100, (füllung / skala) * 100)
|
||||
const abw = drift(runs.find((r) => r.est != null && r.exact != null))
|
||||
const c = data.counters
|
||||
|
||||
const tone =
|
||||
hard && füllung >= hard ? "bg-rose-500"
|
||||
: füllung >= soft ? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
|
||||
return (
|
||||
<div className="mc-card p-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Fuel className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Wächter</h2>
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="font-space text-3xl font-bold tracking-tight tabular-nums text-foreground">
|
||||
{fmtNum(füllung)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tokens in der letzten Bau-Anfrage
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 pt-1 text-right">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
Savepoint ab {fmtNum(soft)}
|
||||
</div>
|
||||
{hard != null && (
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-rose-400">
|
||||
Stopp ab {fmtNum(hard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tankuhr */}
|
||||
<div className="relative h-3 overflow-hidden rounded-sm bg-muted/25">
|
||||
<div className={`h-full ${tone} transition-all`} style={{ width: `${pct}%` }} />
|
||||
<div
|
||||
className="absolute inset-y-0 w-px bg-amber-400/80"
|
||||
style={{ left: `${Math.min(100, (soft / skala) * 100)}%` }}
|
||||
title={`Savepoint-Marke ${fmtNum(soft)}`}
|
||||
/>
|
||||
{hard != null && (
|
||||
<div
|
||||
className="absolute inset-y-0 w-px bg-rose-400/80"
|
||||
style={{ left: `${Math.min(100, (hard / skala) * 100)}%` }}
|
||||
title={`Harter Stopp ${fmtNum(hard)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zählerstand */}
|
||||
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
||||
{[
|
||||
{ k: "Anfragen", v: fmtNum(c?.chat) },
|
||||
{ k: "Savepoints", v: fmtNum(c?.soft) },
|
||||
{ k: "Stopps", v: fmtNum(c?.hard) },
|
||||
{ k: "Tokens ges.", v: fmtNum(c?.tokens_prompt) },
|
||||
].map((x) => (
|
||||
<div key={x.k} className="rounded-sm bg-muted/20 py-1.5">
|
||||
<div className="font-mono text-sm font-semibold tabular-nums text-foreground">{x.v}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/70">{x.k}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ehrlichkeits-Nachweis */}
|
||||
{abw != null && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-sm bg-muted/15 px-2.5 py-1.5">
|
||||
{Math.abs(abw) < 15
|
||||
? <Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
: <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-amber-400" />}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Schätzung lag{" "}
|
||||
<span className={Math.abs(abw) < 15 ? "font-semibold text-emerald-400" : "font-semibold text-amber-400"}>
|
||||
{abw > 0 ? "+" : ""}{abw.toFixed(1)} %
|
||||
</span>{" "}
|
||||
neben der Wahrheit — er kalibriert sich aus jeder Antwort selbst nach.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Letzte Läufe */}
|
||||
{runs.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{runs.slice(0, 5).map((r, i) => {
|
||||
const m = ACTION_META[r.action] ?? { label: r.action, tone: "text-muted-foreground" }
|
||||
return (
|
||||
<div key={`${r.t}-${i}`} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(r.t)}</span>
|
||||
<span className="w-20 shrink-0 truncate font-medium text-foreground">{r.model || "?"}</span>
|
||||
<span className="flex-1 font-mono tabular-nums text-muted-foreground">
|
||||
{fmtNum(r.exact ?? r.est)}
|
||||
{r.exact != null && r.est != null && (
|
||||
<span className="text-muted-foreground/50"> (geschätzt {fmtNum(r.est)})</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-semibold ${m.tone}`}>{m.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
|
||||
Alle Coding-Agenten laufen durch den Wächter (:8100). Lucys Alltagsmodelle sind
|
||||
ausgenommen — ein Gespräch ist keine Bau-Sitzung und wird nie unterbrochen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -582,6 +582,40 @@ export interface ChronikResp {
|
||||
items: ChronikItem[]
|
||||
}
|
||||
|
||||
// ── Governor (Token-Wächter :8100, GET /api/governor) ────────────────────────
|
||||
// Sitzt zwischen Coding-Agenten und Gateway. `est` ist seine Schätzung VOR dem Lauf,
|
||||
// `exact` die echten prompt_tokens AUS der Antwort — die Differenz ist sein Fehler,
|
||||
// und aus ihr lernt er (chars_per_token je Modell). action: passthrough | soft | hard | defer.
|
||||
export interface GovernorRun {
|
||||
t: number
|
||||
model: string
|
||||
est: number | null
|
||||
exact: number | null
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface GovernorStatus {
|
||||
ok: boolean
|
||||
reachable: boolean
|
||||
error?: string
|
||||
upstream?: string
|
||||
soft?: number
|
||||
hard?: number | null
|
||||
uptime_s?: number
|
||||
counters?: {
|
||||
chat: number
|
||||
soft: number
|
||||
hard: number
|
||||
passthrough: number
|
||||
tokens_prompt: number
|
||||
tokens_completion: number
|
||||
}
|
||||
calibration?: Record<string, { chars_per_token: number; samples: number }>
|
||||
calibration_default?: number
|
||||
exempt_models?: string[]
|
||||
recent?: GovernorRun[]
|
||||
}
|
||||
|
||||
// ── Eigenleben („Von allein": Skills + Vorschlags-Bilanz der Box) ────────────
|
||||
export interface EigenlebenSkill {
|
||||
id: string
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type DiscoverResp,
|
||||
type DraftsResp,
|
||||
type EigenlebenResp,
|
||||
type GovernorStatus,
|
||||
type GroupsResp,
|
||||
type HermesBrainResp,
|
||||
type Health,
|
||||
@@ -60,6 +61,7 @@ export const qk = {
|
||||
auftragsbuch: ["auftragsbuch"] as const,
|
||||
ideen: ["ideen"] as const,
|
||||
chronik: ["chronik"] as const,
|
||||
governor: ["governor"] as const,
|
||||
eigenleben: ["eigenleben"] as const,
|
||||
wissen: ["wissen"] as const,
|
||||
zeitmaschine: ["zeitmaschine"] as const,
|
||||
@@ -157,6 +159,16 @@ export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell
|
||||
select: (d) => d.turns ?? [],
|
||||
})
|
||||
|
||||
// Token-Wächter (:8100). Ist er aus, liefert das Backend `reachable:false` statt zu werfen —
|
||||
// deshalb kein Retry-Sturm und kein Fehlerzustand in der Kachel.
|
||||
export const useGovernor = (refetchInterval: number = TAKT.normal) =>
|
||||
useQuery({
|
||||
queryKey: qk.governor,
|
||||
queryFn: () => api<GovernorStatus>("/api/governor"),
|
||||
refetchInterval,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const useMemoryGraph = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.memoryGraph,
|
||||
|
||||
@@ -84,11 +84,12 @@ export const ROLE_META: RoleMeta[] = [
|
||||
},
|
||||
{
|
||||
role: "coder",
|
||||
label: "Programmierer (gründlich)",
|
||||
short: "Code gründlich",
|
||||
label: "Programmierer",
|
||||
short: "Code",
|
||||
icon: Code,
|
||||
desc: "Schreibt und prüft Code besonders sorgfältig — für schwere Coding-Aufgaben.",
|
||||
desc: "Baut und plant. Der Kopf der Coding-Mannschaft — er hält den Faden und verteilt Zuarbeit.",
|
||||
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
role: "scout",
|
||||
@@ -103,7 +104,7 @@ export const ROLE_META: RoleMeta[] = [
|
||||
label: "Kritiker",
|
||||
short: "Kritiker",
|
||||
icon: Scale,
|
||||
desc: "Die unbestechliche Zweitmeinung — prüft Behauptungen und Patches (anderer Hersteller als das Hirn).",
|
||||
desc: "Liest dem Programmierer nach jeder Etappe gegen — bewusst aus einer FREMDEN Modellfamilie (Mistral statt Qwen), damit er andere blinde Flecken hat.",
|
||||
tone: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ErinnerungenCard } from "./ErinnerungenCard"
|
||||
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
|
||||
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
||||
import { LatencyCard } from "@/components/dashboard/LatencyCard"
|
||||
import { GovernorCard } from "@/components/dashboard/GovernorCard"
|
||||
|
||||
// Öffnet die bestehende System-Schublade (Pflege/Logs) via globalem Event.
|
||||
const openDrawer = (tab: "maintenance" | "logs") =>
|
||||
@@ -128,6 +129,10 @@ export function CockpitView({ onNavigate }: { onNavigate: (v: string) => void })
|
||||
<div className="mt-4">
|
||||
<LatencyCard />
|
||||
</div>
|
||||
{/* Token-Wächter: wie voll ist die laufende Coding-Sitzung, und wie ehrlich zählt er */}
|
||||
<div className="mt-4">
|
||||
<GovernorCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bereichs-Kacheln (6 statt 7 - Logs ist in die Werkzeug-Liste gewandert) */}
|
||||
|
||||
Reference in New Issue
Block a user