Merge governor-phase0: Umbau Stufe 1-5 (Ampel #3 gruen)
This commit is contained in:
@@ -11,6 +11,10 @@
|
|||||||
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
||||||
deploy/agent-hooks/*.py text eol=lf
|
deploy/agent-hooks/*.py text eol=lf
|
||||||
deploy/*.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.
|
# Windows-Batch-Wrapper bleiben CRLF.
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
|
||||||
|
#
|
||||||
|
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
|
||||||
|
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
|
||||||
|
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
|
||||||
|
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
|
||||||
|
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
|
||||||
|
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
|
||||||
|
#
|
||||||
|
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||||
|
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||||
|
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
||||||
|
name: Ampel
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ampel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
|
||||||
|
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
|
||||||
|
set -u +e
|
||||||
|
rot=0
|
||||||
|
|
||||||
|
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
|
||||||
|
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
|
||||||
|
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
|
||||||
|
ruff check . || rot=1
|
||||||
|
|
||||||
|
echo "== Python: Import/Syntax (compileall) =="
|
||||||
|
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
|
||||||
|
|
||||||
|
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
|
||||||
|
if [ -f frontend/package.json ]; then
|
||||||
|
if [ ! -f frontend/package-lock.json ]; then
|
||||||
|
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
|
||||||
|
rot=1
|
||||||
|
else
|
||||||
|
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $rot -ne 0 ]; then
|
||||||
|
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||||
|
else
|
||||||
|
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
|
||||||
|
fi
|
||||||
|
exit $rot
|
||||||
@@ -20,3 +20,6 @@ frontend/dist/avatar.vrm
|
|||||||
box_recon*
|
box_recon*
|
||||||
gemma_swap*
|
gemma_swap*
|
||||||
|
|
||||||
|
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -9,17 +9,16 @@ Server (proxyt /api hierher), daher CORS für localhost offen.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any, AsyncGenerator, Awaitable, Callable
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
|
||||||
from routers import (
|
from routers import (
|
||||||
agent,
|
agent,
|
||||||
auftragsbuch,
|
auftragsbuch,
|
||||||
@@ -29,6 +28,7 @@ from routers import (
|
|||||||
eigenleben,
|
eigenleben,
|
||||||
events,
|
events,
|
||||||
gateway_proxy,
|
gateway_proxy,
|
||||||
|
governor,
|
||||||
health,
|
health,
|
||||||
hermes_ui,
|
hermes_ui,
|
||||||
ideen,
|
ideen,
|
||||||
@@ -42,8 +42,9 @@ from routers import (
|
|||||||
zeitmaschine,
|
zeitmaschine,
|
||||||
)
|
)
|
||||||
from routers import reminders as reminders_router
|
from routers import reminders as reminders_router
|
||||||
from services import memory as memory_svc
|
|
||||||
from services import ketten_digest, metrics_history, reminders, sentry, warmer
|
from services import ketten_digest, metrics_history, reminders, sentry, warmer
|
||||||
|
from services import memory as memory_svc
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||||
# für alle Module (logging.getLogger(__name__)).
|
# für alle Module (logging.getLogger(__name__)).
|
||||||
@@ -141,6 +142,7 @@ app.include_router(maintenance.router)
|
|||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
app.include_router(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(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(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(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
||||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL, VERSION
|
from config import LLAMA_SWAP_URL, VERSION
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
from routers import gateway_proxy
|
from routers import gateway_proxy
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from pathlib import Path
|
|||||||
# Add backend directory to sys.path so we can import services
|
# Add backend directory to sys.path so we can import services
|
||||||
sys.path.append(str(Path(__file__).resolve().parent))
|
sys.path.append(str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
|
|
||||||
from config import CONFIG_PATH
|
from config import CONFIG_PATH
|
||||||
|
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
||||||
|
|
||||||
|
|
||||||
def migrate():
|
def migrate():
|
||||||
print(f"Reading config from {CONFIG_PATH}...")
|
print(f"Reading config from {CONFIG_PATH}...")
|
||||||
|
|||||||
@@ -55,6 +55,17 @@
|
|||||||
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
"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,
|
"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
|
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
|
||||||
|
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
|
||||||
|
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
|
||||||
|
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
|
||||||
|
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import auftragsbuch
|
from services import auftragsbuch
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wäc
|
|||||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
||||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import announce
|
from services import announce
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import logging
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
|
from config import BOX_CONSOLE_UPSTREAM
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from config import BOX_CONSOLE_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ def _register(base: str, upstream: str) -> None:
|
|||||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
||||||
|
|
||||||
@router.websocket(f"/{base}/ws")
|
@router.websocket(f"/{base}/ws")
|
||||||
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
|
async def _proxy_ws(ws: WebSocket) -> None:
|
||||||
await ws.accept(subprotocol="tty")
|
await ws.accept(subprotocol="tty")
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import eigenleben
|
from services import eigenleben
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import MODELS_DIR
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from config import MODELS_DIR
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ entfernen → app.py bindet wieder den lokalen Gateway ein.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from config import V1_UPSTREAM
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from config import V1_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1")
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from config import LLAMA_SWAP_URL
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL
|
|
||||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||||
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
||||||
from services.routing_policy import load_policy
|
from services.routing_policy import load_policy
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Governor — Fenster auf den Token-Waechter (:8100).
|
||||||
|
|
||||||
|
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
|
||||||
|
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
|
||||||
|
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
|
||||||
|
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
|
||||||
|
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
|
||||||
|
zweiten Port ausweichen muss.
|
||||||
|
|
||||||
|
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
|
||||||
|
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/governor")
|
||||||
|
async def governor_status() -> dict:
|
||||||
|
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as c:
|
||||||
|
r = await c.get(f"{GOVERNOR_URL}/governor/status")
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
data["reachable"] = True
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reachable": False,
|
||||||
|
"error": f"{type(exc).__name__}: {exc}",
|
||||||
|
"url": GOVERNOR_URL,
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from config import VERSION
|
from config import VERSION
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import gateway, llamaswap
|
from services import gateway, llamaswap
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ import re
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
|
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import RedirectResponse, Response
|
from starlette.responses import RedirectResponse, Response
|
||||||
|
|
||||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuc
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import ideen
|
from services import ideen
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header
|
from fastapi import APIRouter, Header, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import maintenance
|
from services import maintenance
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import time
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import memory
|
from services import memory
|
||||||
from services.voice_metrics import park, record_stage
|
from services.voice_metrics import park, record_stage
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
|
||||||
from services import budget, discover, hf, jobengine, llamaswap
|
from services import budget, discover, hf, jobengine, llamaswap
|
||||||
from services.fit import evaluate_fit, max_ctx_for
|
from services.fit import evaluate_fit, max_ctx_for
|
||||||
|
|
||||||
@@ -140,8 +139,7 @@ def install(req: InstallReq) -> dict:
|
|||||||
|
|
||||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||||
args = [hf.hf_bin(), "download", repo]
|
args = [hf.hf_bin(), "download", repo]
|
||||||
for f in info["files"]:
|
args.extend(info["files"])
|
||||||
args.append(f)
|
|
||||||
if info["mmproj"]:
|
if info["mmproj"]:
|
||||||
args.append(info["mmproj"])
|
args.append(info["mmproj"])
|
||||||
args += ["--local-dir", str(target)]
|
args += ["--local-dir", str(target)]
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
|
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
|
||||||
LAN-only wie alle MC2-Endpoints."""
|
LAN-only wie alle MC2-Endpoints."""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from services import reminders
|
from services import reminders
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import gateway
|
from services import gateway
|
||||||
from services.routing_policy import policy_meta, save_policy
|
from services.routing_policy import policy_meta, save_policy
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
|
||||||
from services import backup as backup_svc
|
from services import backup as backup_svc
|
||||||
from services import maintenance
|
from services import maintenance
|
||||||
from services.agent import agent_status
|
from services.agent import agent_status
|
||||||
@@ -67,7 +65,7 @@ def _user_unit_active(unit: str) -> bool:
|
|||||||
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
||||||
capture_output=True, text=True, timeout=3)
|
capture_output=True, text=True, timeout=3)
|
||||||
return r.stdout.strip() == "active"
|
return r.stdout.strip() == "active"
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +133,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
|||||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||||
|
import sys as _sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
|
||||||
from services import announce
|
from services import announce
|
||||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||||
Timer,
|
Timer,
|
||||||
@@ -29,8 +31,6 @@ from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (inter
|
|||||||
record_stage,
|
record_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
|
||||||
import sys as _sys
|
|
||||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||||
if _GUARD_DIR not in _sys.path:
|
if _GUARD_DIR not in _sys.path:
|
||||||
_sys.path.insert(0, _GUARD_DIR)
|
_sys.path.insert(0, _GUARD_DIR)
|
||||||
@@ -155,7 +155,7 @@ def voice_health() -> dict:
|
|||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
out["sidecar"] = r.status_code == 200
|
out["sidecar"] = r.status_code == 200
|
||||||
out["detail"] = r.json() if r.status_code == 200 else None
|
out["detail"] = r.json() if r.status_code == 200 else None
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
out["error"] = str(exc)
|
out["error"] = str(exc)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ def voice_voices() -> dict:
|
|||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ def voice_get_reference() -> dict:
|
|||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"active": False, "error": str(exc)}
|
return {"active": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import backup as backup_svc
|
from services import backup as backup_svc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ Permission errors are handled gracefully.
|
|||||||
Uses user journal (systemctl --user) to query logs.
|
Uses user journal (systemctl --user) to query logs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
LOG_DIR = Path.home() / "logs"
|
LOG_DIR = Path.home() / "logs"
|
||||||
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
||||||
SERVICE_NAME = "mission-control-2.service"
|
SERVICE_NAME = "mission-control-2.service"
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
from config import (
|
||||||
|
BOX_CONSOLE_PATH,
|
||||||
from config import (BOX_CONSOLE_UPSTREAM,
|
BOX_CONSOLE_UPSTREAM,
|
||||||
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
|
HERMES_API_URL,
|
||||||
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
|
HERMES_BUILTIN_UI_PATH,
|
||||||
|
HERMES_BUILTIN_UI_UPSTREAM,
|
||||||
|
HERMES_HOME,
|
||||||
|
PC_EXECUTOR_URL,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ def set_agent_brain(model_id: str) -> dict:
|
|||||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||||
from services.llamaswap import set_ttl, DEFAULT_TTL
|
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||||
set_ttl(model_id, 0)
|
set_ttl(model_id, 0)
|
||||||
if old:
|
if old:
|
||||||
set_ttl(old, DEFAULT_TTL)
|
set_ttl(old, DEFAULT_TTL)
|
||||||
@@ -253,7 +257,7 @@ def update_brain_model(new_model: str) -> bool:
|
|||||||
|
|
||||||
# Restart the user-space service to apply changes
|
# Restart the user-space service to apply changes
|
||||||
try:
|
try:
|
||||||
import services.maintenance as maintenance
|
from services import maintenance
|
||||||
maintenance.restart_service("hermes-gateway")
|
maintenance.restart_service("hermes-gateway")
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def backup_now() -> dict:
|
|||||||
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
||||||
|
|
||||||
latest = _latest()
|
latest = _latest()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
|
||||||
|
|
||||||
DEFAULT_HOST = "192.168.178.151"
|
DEFAULT_HOST = "192.168.178.151"
|
||||||
@@ -139,7 +138,7 @@ def check_health() -> dict:
|
|||||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||||
else:
|
else:
|
||||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||||
@@ -148,7 +147,7 @@ def check_health() -> dict:
|
|||||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
||||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||||
@@ -157,7 +156,7 @@ def check_health() -> dict:
|
|||||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||||
|
|
||||||
from services import catalog
|
from services import catalog
|
||||||
from services.caps import capabilities
|
from services.caps import capabilities
|
||||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import PORT, V1_UPSTREAM
|
from config import PORT, V1_UPSTREAM
|
||||||
|
|
||||||
from services.llamaswap import engine_reachable
|
from services.llamaswap import engine_reachable
|
||||||
from services.routing_policy import load_policy
|
from services.routing_policy import load_policy
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ def gateway_reachable() -> bool:
|
|||||||
if V1_UPSTREAM:
|
if V1_UPSTREAM:
|
||||||
try:
|
try:
|
||||||
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
|
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
return False
|
return False
|
||||||
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
|
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
|
||||||
return engine_reachable()
|
return engine_reachable()
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ def _kurz(text: str, max_len: int = 80) -> str:
|
|||||||
return (schnitt or t[:max_len]) + "…"
|
return (schnitt or t[:max_len]) + "…"
|
||||||
|
|
||||||
|
|
||||||
_TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-–]?\s*", re.I)
|
_TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-–]?\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _projekt_titel(titles: list[str]) -> str:
|
def _projekt_titel(titles: list[str]) -> str:
|
||||||
@@ -220,7 +220,7 @@ def _ketten_anreichern(items: list[dict]) -> list[dict]:
|
|||||||
for it in items:
|
for it in items:
|
||||||
familien.setdefault(boss(it["id"]), []).append(it)
|
familien.setdefault(boss(it["id"]), []).append(it)
|
||||||
projekte = []
|
projekte = []
|
||||||
for wurzel, mitglieder in familien.items():
|
for mitglieder in familien.values():
|
||||||
if len(mitglieder) < 2:
|
if len(mitglieder) < 2:
|
||||||
continue
|
continue
|
||||||
mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
|
mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
|
||||||
@@ -752,8 +752,8 @@ def _lokales_konzept(text: str, titel: str = "") -> tuple | None:
|
|||||||
if not kandidaten and titel and _KONZEPT_DIR.is_dir():
|
if not kandidaten and titel and _KONZEPT_DIR.is_dir():
|
||||||
worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())}
|
worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())}
|
||||||
treffer = [p.name for p in _KONZEPT_DIR.glob("*.md")
|
treffer = [p.name for p in _KONZEPT_DIR.glob("*.md")
|
||||||
if (lambda g: len(g) >= 2 or any(len(w) >= 6 for w in g))(
|
if len(g := worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower()))) >= 2
|
||||||
worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower())))]
|
or any(len(w) >= 6 for w in g)]
|
||||||
if len(treffer) == 1:
|
if len(treffer) == 1:
|
||||||
kandidaten.append(treffer[0])
|
kandidaten.append(treffer[0])
|
||||||
for name in kandidaten:
|
for name in kandidaten:
|
||||||
@@ -935,7 +935,7 @@ def _konzept_und_name(task_id: str) -> tuple:
|
|||||||
quell_titel = _TITEL_PRAEFIX_RX.sub("", str(t.get("title") or "").strip())
|
quell_titel = _TITEL_PRAEFIX_RX.sub("", str(t.get("title") or "").strip())
|
||||||
if not quell_titel:
|
if not quell_titel:
|
||||||
return {}, "", "Quell-Karte hat keinen Titel."
|
return {}, "", "Quell-Karte hat keinen Titel."
|
||||||
h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.M)
|
h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.MULTILINE)
|
||||||
kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0]
|
kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0]
|
||||||
return konz, _kurz(kurz, 85), ""
|
return konz, _kurz(kurz, 85), ""
|
||||||
|
|
||||||
@@ -1052,9 +1052,9 @@ def konzept_ueberarbeiten(task_id: str, hinweis: str) -> dict:
|
|||||||
"als `KONZEPT.md` hinein (klonen, schreiben, pushen, Push beweisen)."
|
"als `KONZEPT.md` hinein (klonen, schreiben, pushen, Push beweisen)."
|
||||||
if konz.get("repo") else ""))
|
if konz.get("repo") else ""))
|
||||||
|
|
||||||
teile = [f"{_UEBERARBEITEN_KOPF}\n{quelle}\n"
|
teile = [(f"{_UEBERARBEITEN_KOPF}\n{quelle}\n"
|
||||||
f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n „{hinweis}“\n"
|
f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n „{hinweis}“\n"
|
||||||
f"{_UEBERARBEITEN_FUSS}",
|
f"{_UEBERARBEITEN_FUSS}"),
|
||||||
_INFRA_LXC]
|
_INFRA_LXC]
|
||||||
titel = f"Konzept nachschärfen: {kurz}"[:200]
|
titel = f"Konzept nachschärfen: {kurz}"[:200]
|
||||||
args = ["create", titel, "--body", "\n\n".join(teile)[:4000], "--assignee", "projektstart",
|
args = ["create", titel, "--body", "\n\n".join(teile)[:4000], "--assignee", "projektstart",
|
||||||
@@ -1098,7 +1098,7 @@ def log_of(task_id: str) -> dict:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
r = _hermes(["log", task_id])
|
r = _hermes(["log", task_id])
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
log.warning("ideen: kanban log fehlgeschlagen", exc_info=True)
|
log.warning("ideen: kanban log fehlgeschlagen", exc_info=True)
|
||||||
return {"available": True, "lines": []}
|
return {"available": True, "lines": []}
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
|
|||||||
log_str = "\n".join(job["log"])
|
log_str = "\n".join(job["log"])
|
||||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||||
job["sudo_failed"] = True
|
job["sudo_failed"] = True
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Fehler: {exc}")
|
_append_log(job, f"[mc] Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
job["state"] = "failed"
|
||||||
job["returncode"] = -1
|
job["returncode"] = -1
|
||||||
@@ -101,7 +101,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
|
|||||||
if cb and job["state"] == "done":
|
if cb and job["state"] == "done":
|
||||||
try:
|
try:
|
||||||
cb()
|
cb()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
|||||||
j["rate_bps"] = rate
|
j["rate_bps"] = rate
|
||||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||||
prev_t, prev_b = now, cur
|
prev_t, prev_b = now, cur
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
j = JOBS.get(job_id)
|
j = JOBS.get(job_id)
|
||||||
@@ -182,7 +182,7 @@ def cancel_job(job_id: str) -> bool:
|
|||||||
if proc is not None:
|
if proc is not None:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
job["state"] = "canceled"
|
job["state"] = "canceled"
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ def _tick() -> None:
|
|||||||
return
|
return
|
||||||
items = data.get("items") or []
|
items = data.get("items") or []
|
||||||
projekte = data.get("projekte") or []
|
projekte = data.get("projekte") or []
|
||||||
by_id = {i["id"]: i for i in items if i.get("id")}
|
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
||||||
projekt_state: dict = state.setdefault("projekte", {})
|
projekt_state: dict = state.setdefault("projekte", {})
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
|
CMD_TEMPLATE,
|
||||||
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
|
CONFIG_PATH,
|
||||||
|
DEFAULT_TTL,
|
||||||
|
DRAFTS_DIR,
|
||||||
|
LLAMA_SWAP_URL,
|
||||||
|
SPEC_DRAFT_MODEL_PATH,
|
||||||
|
SPEC_DRAFT_N_MAX,
|
||||||
|
SPEC_TYPE,
|
||||||
)
|
)
|
||||||
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -143,13 +148,13 @@ def model_id_from_path(model_path: str) -> str:
|
|||||||
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
||||||
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
||||||
d = os.path.basename(os.path.dirname(model_path))
|
d = os.path.basename(os.path.dirname(model_path))
|
||||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
|
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
|
||||||
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
||||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
|
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
|
||||||
if not name:
|
if not name:
|
||||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
|
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
|
||||||
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
||||||
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
|
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
|
||||||
return name or "modell"
|
return name or "modell"
|
||||||
|
|
||||||
|
|
||||||
@@ -222,7 +227,7 @@ def write_config(cfg: dict) -> None:
|
|||||||
try:
|
try:
|
||||||
from services import warmer
|
from services import warmer
|
||||||
warmer.nudge()
|
warmer.nudge()
|
||||||
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ _engine_cache = {"ts": 0.0, "avail": False}
|
|||||||
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
|
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
|
||||||
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
|
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
|
||||||
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
|
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
|
||||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
|
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _latest_engine_asset_release() -> dict | None:
|
def _latest_engine_asset_release() -> dict | None:
|
||||||
@@ -289,7 +289,7 @@ def model_upgrades() -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
base = rec.split("/")[-1].lower()
|
base = rec.split("/")[-1].lower()
|
||||||
stem = base[:-5] if base.endswith("-gguf") else base
|
stem = base.removesuffix("-gguf")
|
||||||
if base in cmds or (stem and stem in cmds):
|
if base in cmds or (stem and stem in cmds):
|
||||||
continue # schon installiert
|
continue # schon installiert
|
||||||
out.append({"role": role, "title": c["title"], "repo": rec})
|
out.append({"role": role, "title": c["title"], "repo": rec})
|
||||||
@@ -343,7 +343,7 @@ def _os_held_back() -> list[dict]:
|
|||||||
held.append({"name": name, "reason": reason})
|
held.append({"name": name, "reason": reason})
|
||||||
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
||||||
reason = None
|
reason = None
|
||||||
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
|
except Exception:
|
||||||
pass
|
pass
|
||||||
held.sort(key=lambda p: p["name"])
|
held.sort(key=lambda p: p["name"])
|
||||||
return held
|
return held
|
||||||
@@ -368,7 +368,7 @@ def os_update_details() -> dict:
|
|||||||
out_pkgs.sort(key=lambda p: p["name"])
|
out_pkgs.sort(key=lambda p: p["name"])
|
||||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
||||||
"held_back": _os_held_back()}
|
"held_back": _os_held_back()}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
@@ -393,7 +393,7 @@ def engine_update_details() -> dict:
|
|||||||
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
||||||
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
||||||
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -418,7 +418,7 @@ def swap_update_details() -> dict:
|
|||||||
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
||||||
"verteilt und Sprachmodelle heiß nachlädt).")
|
"verteilt und Sprachmodelle heiß nachlädt).")
|
||||||
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -496,7 +496,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
|
|||||||
if text:
|
if text:
|
||||||
cache.update(key=key, data=data)
|
cache.update(key=key, data=data)
|
||||||
return data
|
return data
|
||||||
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
|
except Exception as exc:
|
||||||
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
||||||
"action_needed": None, "action_text": ""}
|
"action_needed": None, "action_text": ""}
|
||||||
|
|
||||||
@@ -534,7 +534,7 @@ def hermes_update_details() -> dict:
|
|||||||
info["behind"] = len(commits)
|
info["behind"] = len(commits)
|
||||||
if commits:
|
if commits:
|
||||||
info.update(_summarize_hermes_commits(commits))
|
info.update(_summarize_hermes_commits(commits))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -575,7 +575,7 @@ def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
|||||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
||||||
|
|
||||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
return {"ok": False, "out": "", "err": str(exc)}
|
return {"ok": False, "out": "", "err": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import re
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import MEM0_SERVICE_URL
|
from config import MEM0_SERVICE_URL
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def _load() -> None:
|
|||||||
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
except Exception: # noqa: BLE001 — kaputte Datei = leer starten, nie crashen
|
except Exception:
|
||||||
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,19 +77,19 @@ def _sample() -> None:
|
|||||||
try:
|
try:
|
||||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||||
disk = du.percent
|
disk = du.percent
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
gpu = None
|
gpu = None
|
||||||
try:
|
try:
|
||||||
g = _gpu_sysfs()
|
g = _gpu_sysfs()
|
||||||
gpu = g.get("busy_percent") if g else None
|
gpu = g.get("busy_percent") if g else None
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
tp = tc = None
|
tp = tc = None
|
||||||
try:
|
try:
|
||||||
ts = get_stats()
|
ts = get_stats()
|
||||||
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
||||||
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
||||||
@@ -101,12 +101,12 @@ async def sampler_loop() -> None:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_sample)
|
await asyncio.to_thread(_sample)
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
||||||
if time.time() - _last_flush >= FLUSH_S:
|
if time.time() - _last_flush >= FLUSH_S:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_flush)
|
await asyncio.to_thread(_flush)
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await asyncio.sleep(SAMPLE_S)
|
await asyncio.sleep(SAMPLE_S)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from pathlib import Path
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
from services import announce
|
from services import announce
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import time
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
|
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||||
|
|
||||||
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
|
||||||
from services import announce, llamaswap
|
from services import announce, llamaswap
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -100,7 +100,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
|||||||
|
|
||||||
|
|
||||||
class _Watch:
|
class _Watch:
|
||||||
__slots__ = ("fails", "alerted", "alert_ts")
|
__slots__ = ("alert_ts", "alerted", "fails")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.fails = 0 # Fehl-Ticks in Folge
|
self.fails = 0 # Fehl-Ticks in Folge
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config import HERMES_HOME
|
from config import HERMES_HOME
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL
|
from config import LLAMA_SWAP_URL
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async def volume_control(req: dict):
|
|||||||
async def open_target(req: dict):
|
async def open_target(req: dict):
|
||||||
target = req.get("target", "")
|
target = req.get("target", "")
|
||||||
try:
|
try:
|
||||||
if target.startswith("http://") or target.startswith("https://"):
|
if target.startswith(("http://", "https://")):
|
||||||
webbrowser.open(target)
|
webbrowser.open(target)
|
||||||
else:
|
else:
|
||||||
os.startfile(target)
|
os.startfile(target)
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ nur Schreib-Operationen.
|
|||||||
|
|
||||||
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
|
||||||
"""
|
"""
|
||||||
import sys, json, os, re
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def out(obj):
|
def out(obj):
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ verschlucken und den Tabu-Guard lautlos deaktivieren.
|
|||||||
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
|
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
|
||||||
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
|
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
|
||||||
"""
|
"""
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
|
|||||||
+23
-12
@@ -79,26 +79,37 @@ case "$CODE" in
|
|||||||
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
|
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
|
||||||
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
|
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
|
||||||
# dedizierte Anlage-Token hat u.U. nur write:user).
|
# 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}"
|
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
|
||||||
if [ -r "$AMPEL" ]; then
|
# saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
|
||||||
SEED_CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/.gitea/workflows/ci.yml" \
|
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" \
|
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
|
||||||
--data "$(python3 - "$AMPEL" <<'PY'
|
--data "$(python3 - "$QUELLE" "$MSG" <<'PY'
|
||||||
import base64, json, sys
|
import base64, json, sys
|
||||||
inhalt = open(sys.argv[1], "rb").read()
|
inhalt = open(sys.argv[1], "rb").read()
|
||||||
print(json.dumps({"content": base64.b64encode(inhalt).decode(),
|
print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
|
||||||
"message": "CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)"}))
|
|
||||||
PY
|
PY
|
||||||
)")"
|
)")"
|
||||||
if [ "$SEED_CODE" = "201" ]; then
|
if [ "$CODE" = "201" ]; then
|
||||||
echo "CI-Ampel eingepflanzt (.gitea/workflows/ci.yml)."
|
echo "$NAME eingepflanzt ($ZIEL)."
|
||||||
else
|
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
|
fi
|
||||||
else
|
}
|
||||||
echo "WARNUNG: Ampel-Vorlage fehlt ($AMPEL) — Repo ohne CI-Ampel angelegt." >&2
|
# JEDES neue Repo wird mit beiden Wächtern geboren:
|
||||||
fi
|
# 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 "Repo '$FULL' angelegt (privat, main initialisiert)."
|
||||||
echo "CLONE ${CLONE}"
|
echo "CLONE ${CLONE}"
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
|
||||||
|
|
||||||
|
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
|
||||||
|
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
|
||||||
|
|
||||||
|
## 1. Zu Beginn: erst SAVEPOINT.md lesen
|
||||||
|
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
|
||||||
|
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
|
||||||
|
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
|
||||||
|
ist, sag es, statt es zu erfinden.
|
||||||
|
|
||||||
|
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
|
||||||
|
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
|
||||||
|
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
|
||||||
|
|
||||||
|
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
|
||||||
|
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
|
||||||
|
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
|
||||||
|
- **Offene Fragen** — Entscheidungen, die noch anstehen.
|
||||||
|
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
|
||||||
|
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
|
||||||
|
|
||||||
|
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
|
||||||
|
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
|
||||||
|
|
||||||
|
## 3. Kleine, überprüfbare Schritte
|
||||||
|
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
|
||||||
|
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
|
||||||
|
|
||||||
|
## 4. Wenn der Governor das Sitzungs-Limit meldet
|
||||||
|
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
|
||||||
|
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
|
||||||
|
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Governor — Phase 0
|
||||||
|
|
||||||
|
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
|
||||||
|
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
|
||||||
|
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
|
||||||
|
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
|
||||||
|
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
|
||||||
|
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
|
||||||
|
|
||||||
|
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
|
||||||
|
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
|
||||||
|
|
||||||
|
## Bausteine (dieses Verzeichnis)
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
|
||||||
|
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
|
||||||
|
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
|
||||||
|
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
|
||||||
|
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
|
||||||
|
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
|
||||||
|
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
|
||||||
|
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
|
||||||
|
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
|
||||||
|
|
||||||
|
## Verhalten des Governors
|
||||||
|
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
|
||||||
|
kalibriert auf CPT **3.5** → `est ≈ echte prompt_tokens` auf ~1–3 % bei echten Sessions):
|
||||||
|
|
||||||
|
- **est < Soft** → unverändert durchreichen.
|
||||||
|
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
|
||||||
|
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
|
||||||
|
ehrlichen Abschluss-Savepoint.
|
||||||
|
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
|
||||||
|
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
|
||||||
|
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
|
||||||
|
|
||||||
|
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
|
||||||
|
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
|
||||||
|
mitgeloggt.
|
||||||
|
|
||||||
|
### Sprach-Signal an Lucy (Phase 2)
|
||||||
|
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
|
||||||
|
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
|
||||||
|
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
|
||||||
|
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
|
||||||
|
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
|
||||||
|
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
|
||||||
|
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
|
||||||
|
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
|
||||||
|
|
||||||
|
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
|
||||||
|
```bash
|
||||||
|
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
|
||||||
|
pipx install uv && uv tool install --python 3.12 aider-chat
|
||||||
|
|
||||||
|
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
|
||||||
|
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
|
||||||
|
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
|
||||||
|
|
||||||
|
# Aider-Sitzung durch den Governor:
|
||||||
|
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
|
||||||
|
```
|
||||||
|
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
|
||||||
|
`openai/Qwen3-Coder-Next` auf den Governor.
|
||||||
|
|
||||||
|
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
|
||||||
|
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
|
||||||
|
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
|
||||||
|
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
|
||||||
|
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
|
||||||
|
|
||||||
|
## Ergebnisse & Befunde (24.07.2026)
|
||||||
|
|
||||||
|
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
|
||||||
|
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
|
||||||
|
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
|
||||||
|
→ `HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
|
||||||
|
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
|
||||||
|
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
|
||||||
|
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
|
||||||
|
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
|
||||||
|
Schritt) und verweigerte neuen Code.
|
||||||
|
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
|
||||||
|
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
|
||||||
|
Tests laufen grün** gegen die echte API. Kein Erfinden.
|
||||||
|
|
||||||
|
### Kalibrierung
|
||||||
|
`CHARS_PER_TOKEN = 3.5` → `est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
|
||||||
|
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
|
||||||
|
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
|
||||||
|
|
||||||
|
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
|
||||||
|
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
|
||||||
|
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
|
||||||
|
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
|
||||||
|
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
|
||||||
|
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
|
||||||
|
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
|
||||||
|
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
|
||||||
|
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
|
||||||
|
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
|
||||||
|
|
||||||
|
### Weitere Befunde / Fallen
|
||||||
|
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
|
||||||
|
Aider selbst und der Governor greift nie. Siehe oben.
|
||||||
|
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
|
||||||
|
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
|
||||||
|
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
|
||||||
|
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
|
||||||
|
Python 3.12 installiert.
|
||||||
|
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
|
||||||
|
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
|
||||||
|
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
|
||||||
|
|
||||||
|
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
|
||||||
|
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
|
||||||
|
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
|
||||||
|
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
|
||||||
|
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
|
||||||
|
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
|
||||||
|
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
|
||||||
|
lokalen `:8080`-Endpoint irrelevant.
|
||||||
|
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
|
||||||
|
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
|
||||||
|
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
|
||||||
|
|
||||||
|
## Nächste Schritte (Phase 1+)
|
||||||
|
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
|
||||||
|
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
|
||||||
|
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# SAVEPOINT
|
||||||
|
|
||||||
|
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
|
||||||
|
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
|
||||||
|
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
_(noch nichts — von der ersten Sitzung zu füllen)_
|
||||||
|
|
||||||
|
## Erledigt
|
||||||
|
- _(noch nichts)_
|
||||||
|
|
||||||
|
## Nächster Schritt
|
||||||
|
- Auftrag des Nutzers entgegennehmen und beginnen.
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
- _(keine)_
|
||||||
|
|
||||||
|
## Stolpersteine
|
||||||
|
- _(keine bekannt)_
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
- `SAVEPOINT.md` — diese Übergabe-Datei.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
|
||||||
|
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
|
||||||
|
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
|
||||||
|
set -u
|
||||||
|
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||||
|
|
||||||
|
python3 - <<'PY' > /tmp/gov_fire.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
|
||||||
|
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
|
||||||
|
|
||||||
|
sleep 1.5 # Announce-Thread durchlassen
|
||||||
|
echo "=== Governor-Log (letzte 4 Zeilen) ==="
|
||||||
|
tail -4 ~/governor-p0/governor.log
|
||||||
|
|
||||||
|
echo "=== Announce-Queue: Governor-Eintraege ==="
|
||||||
|
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
|
||||||
|
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
|
||||||
|
|
||||||
|
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
|
||||||
|
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was
|
||||||
|
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
|
||||||
|
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
|
||||||
|
|
||||||
|
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
|
||||||
|
.../aider-chat/bin/python driver.py <messages-file>
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aider.coders import Coder
|
||||||
|
from aider.io import InputOutput
|
||||||
|
from aider.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit).
|
||||||
|
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
|
||||||
|
os.environ.setdefault("OPENAI_API_KEY", "dummy")
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("usage: driver.py <messages-file>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||||
|
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
|
||||||
|
|
||||||
|
model = Model("openai/Qwen3-Coder-Next")
|
||||||
|
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
|
||||||
|
coder = Coder.create(
|
||||||
|
main_model=model,
|
||||||
|
io=io,
|
||||||
|
fnames=["SAVEPOINT.md"], # editierbar
|
||||||
|
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
|
||||||
|
auto_commits=True, # jede Etappe -> git-Commit
|
||||||
|
stream=False, # deterministische Logs fuer P0
|
||||||
|
map_tokens=512,
|
||||||
|
use_git=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
|
||||||
|
try:
|
||||||
|
coder.summarizer.max_tokens = 10 ** 9
|
||||||
|
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
|
||||||
|
|
||||||
|
for i, msg in enumerate(messages, 1):
|
||||||
|
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
|
||||||
|
print(f">> {msg[:140]}", flush=True)
|
||||||
|
try:
|
||||||
|
coder.run(with_message=msg)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
|
||||||
|
break
|
||||||
|
sent = getattr(coder, "total_tokens_sent", "?")
|
||||||
|
recv = getattr(coder, "total_tokens_received", "?")
|
||||||
|
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
|
||||||
|
|
||||||
|
print("\n===== SESSION ENDE =====", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
|
||||||
|
# Nutzung:
|
||||||
|
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
|
||||||
|
# gov-ctl.sh stop
|
||||||
|
# gov-ctl.sh status
|
||||||
|
set -u
|
||||||
|
DIR="$HOME/governor-p0"
|
||||||
|
PIDF="$DIR/governor.pid"
|
||||||
|
LOG="$DIR/governor.log"
|
||||||
|
OUT="$DIR/governor.stdout"
|
||||||
|
|
||||||
|
start() {
|
||||||
|
stop
|
||||||
|
local thr="${1:-25000}"
|
||||||
|
cd "$DIR"
|
||||||
|
: > "$LOG"
|
||||||
|
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
|
||||||
|
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
|
||||||
|
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
|
||||||
|
local hardenv=()
|
||||||
|
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
|
||||||
|
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
|
||||||
|
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
|
||||||
|
GOV_LOG="$LOG" GOV_PORT=8100 \
|
||||||
|
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
|
||||||
|
echo $! > "$PIDF"
|
||||||
|
sleep 1.2
|
||||||
|
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
|
||||||
|
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
pkill -f "$DIR/governor.py" 2>/dev/null || true
|
||||||
|
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
|
||||||
|
sleep 0.6
|
||||||
|
rm -f "$PIDF"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
if pgrep -f "$DIR/governor.py" >/dev/null; then
|
||||||
|
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
|
||||||
|
ss -tlnp 2>/dev/null | grep ":8100" || true
|
||||||
|
else
|
||||||
|
echo "not running"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
start) shift; start "${1:-25000}" "${2:-}" ;;
|
||||||
|
stop) stop; echo stopped ;;
|
||||||
|
status) status ;;
|
||||||
|
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
|
||||||
|
|
||||||
|
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
|
||||||
|
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
|
||||||
|
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
|
||||||
|
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
|
||||||
|
|
||||||
|
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
|
||||||
|
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
|
||||||
|
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
|
||||||
|
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
|
||||||
|
|
||||||
|
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
|
||||||
|
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
|
||||||
|
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
|
||||||
|
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
|
||||||
|
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
|
||||||
|
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
|
||||||
|
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
|
||||||
|
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
|
||||||
|
einem einmal geratenen Wert festzuhaengen.
|
||||||
|
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
|
||||||
|
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
|
||||||
|
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
|
||||||
|
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
|
||||||
|
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
|
||||||
|
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
|
||||||
|
und Lucys `loop_status`.
|
||||||
|
|
||||||
|
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
|
||||||
|
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
|
||||||
|
|
||||||
|
Konfiguration per Umgebungsvariablen (alle optional):
|
||||||
|
GOV_PORT Listen-Port (Default 8100)
|
||||||
|
GOV_HOST Listen-Adresse (Default 0.0.0.0)
|
||||||
|
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
|
||||||
|
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
|
||||||
|
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
|
||||||
|
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
|
||||||
|
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
|
||||||
|
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
|
||||||
|
GOV_DIRECTIVE Text des Soft-Einschubs
|
||||||
|
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
|
||||||
|
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
|
||||||
|
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
|
||||||
|
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
|
||||||
|
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
|
||||||
|
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# ---- Konfiguration ---------------------------------------------------------
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("GOV_PORT", "8100"))
|
||||||
|
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
|
||||||
|
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
|
||||||
|
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
|
||||||
|
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
|
||||||
|
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
|
||||||
|
_hard_env = os.environ.get("GOV_HARD_CEILING")
|
||||||
|
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
|
||||||
|
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
|
||||||
|
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
|
||||||
|
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
|
||||||
|
|
||||||
|
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
|
||||||
|
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
|
||||||
|
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
|
||||||
|
EXEMPT_MODELS = {m.strip().lower() for m in
|
||||||
|
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
|
||||||
|
|
||||||
|
DEFAULT_DIRECTIVE = (
|
||||||
|
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
|
||||||
|
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
|
||||||
|
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
|
||||||
|
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
|
||||||
|
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
|
||||||
|
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
|
||||||
|
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
|
||||||
|
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
|
||||||
|
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
|
||||||
|
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
|
||||||
|
)
|
||||||
|
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
|
||||||
|
|
||||||
|
DEFAULT_HARDSTOP = (
|
||||||
|
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
|
||||||
|
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
|
||||||
|
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
|
||||||
|
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
|
||||||
|
)
|
||||||
|
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
|
||||||
|
|
||||||
|
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
|
||||||
|
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
|
||||||
|
DEFAULT_ANNOUNCE = (
|
||||||
|
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
|
||||||
|
"Stand im Savepoint; am besten fangen wir gleich frisch an."
|
||||||
|
)
|
||||||
|
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
|
||||||
|
|
||||||
|
up = urlparse(UPSTREAM)
|
||||||
|
UP_HOST = up.hostname or "127.0.0.1"
|
||||||
|
UP_PORT = up.port or 80
|
||||||
|
|
||||||
|
HOP_BY_HOP = {
|
||||||
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||||
|
"te", "trailers", "transfer-encoding", "upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
_log_lock = threading.Lock()
|
||||||
|
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
|
||||||
|
_announce_lock = threading.Lock()
|
||||||
|
_last_announce = 0.0
|
||||||
|
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
|
||||||
|
|
||||||
|
# ---- Zustand (klein, im Speicher) ------------------------------------------
|
||||||
|
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
|
||||||
|
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
|
||||||
|
_state_lock = threading.Lock()
|
||||||
|
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
|
||||||
|
_cpt_n: dict = {} # modell -> Anzahl Messungen
|
||||||
|
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
|
||||||
|
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
|
||||||
|
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
|
||||||
|
|
||||||
|
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
|
||||||
|
|
||||||
|
|
||||||
|
def log(line: str) -> None:
|
||||||
|
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
|
||||||
|
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
msg = f"{stamp} {line}"
|
||||||
|
with _log_lock:
|
||||||
|
print(msg, flush=True)
|
||||||
|
try:
|
||||||
|
with open(LOG_PATH, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(msg + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cpt_for(model: str) -> float:
|
||||||
|
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
|
||||||
|
with _state_lock:
|
||||||
|
return _cpt.get(model, CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate(model: str, chars: int, exact: int) -> None:
|
||||||
|
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
|
||||||
|
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
|
||||||
|
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
|
||||||
|
return
|
||||||
|
ratio = chars / exact
|
||||||
|
if not (CPT_MIN <= ratio <= CPT_MAX):
|
||||||
|
return
|
||||||
|
with _state_lock:
|
||||||
|
n = _cpt_n.get(model, 0)
|
||||||
|
old = _cpt.get(model, CHARS_PER_TOKEN)
|
||||||
|
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
|
||||||
|
w = max(0.08, 1.0 / (n + 2))
|
||||||
|
_cpt[model] = old * (1 - w) + ratio * w
|
||||||
|
_cpt_n[model] = n + 1
|
||||||
|
|
||||||
|
|
||||||
|
def body_chars(data: dict) -> int:
|
||||||
|
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
|
||||||
|
|
||||||
|
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
|
||||||
|
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
|
||||||
|
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
|
||||||
|
Wir serialisieren einfach alles, was ans Modell geht.
|
||||||
|
"""
|
||||||
|
payload = {k: v for k, v in data.items()
|
||||||
|
if k in ("messages", "tools", "tool_choice", "system", "functions")}
|
||||||
|
try:
|
||||||
|
return len(json.dumps(payload, ensure_ascii=False))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
|
||||||
|
chars = 0
|
||||||
|
for m in data.get("messages") or []:
|
||||||
|
c = m.get("content") if isinstance(m, dict) else None
|
||||||
|
if isinstance(c, str):
|
||||||
|
chars += len(c) + 4
|
||||||
|
elif isinstance(c, list):
|
||||||
|
for p in c:
|
||||||
|
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
||||||
|
chars += len(p["text"])
|
||||||
|
return chars
|
||||||
|
|
||||||
|
|
||||||
|
def safe_to_append(messages) -> bool:
|
||||||
|
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
|
||||||
|
|
||||||
|
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
|
||||||
|
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
|
||||||
|
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
|
||||||
|
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
|
||||||
|
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
|
||||||
|
"""
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return False
|
||||||
|
last = messages[-1]
|
||||||
|
if not isinstance(last, dict):
|
||||||
|
return False
|
||||||
|
role = last.get("role")
|
||||||
|
if role == "tool":
|
||||||
|
return False
|
||||||
|
return not (role == "assistant" and last.get("tool_calls"))
|
||||||
|
|
||||||
|
|
||||||
|
def _post_announce(est) -> None:
|
||||||
|
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
|
||||||
|
try:
|
||||||
|
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
||||||
|
.replace("{threshold}", str(THRESHOLD)))
|
||||||
|
body = json.dumps({"text": text, "subject": "[Governor]",
|
||||||
|
"source": "governor", "priority": "normal"}).encode("utf-8")
|
||||||
|
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
|
||||||
|
_an.port or 80, timeout=4)
|
||||||
|
conn.request("POST", _an.path or "/api/voice/announce", body=body,
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Content-Length": str(len(body))})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
resp.read()
|
||||||
|
conn.close()
|
||||||
|
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_announce(est) -> None:
|
||||||
|
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
|
||||||
|
if not _an:
|
||||||
|
return
|
||||||
|
global _last_announce
|
||||||
|
now = time.time()
|
||||||
|
with _announce_lock:
|
||||||
|
if now - _last_announce < ANNOUNCE_THROTTLE:
|
||||||
|
return
|
||||||
|
_last_announce = now
|
||||||
|
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def status_payload() -> dict:
|
||||||
|
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
|
||||||
|
with _state_lock:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"upstream": UPSTREAM,
|
||||||
|
"soft": THRESHOLD,
|
||||||
|
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
|
||||||
|
"uptime_s": int(time.time() - _counters["started"]),
|
||||||
|
"counters": {k: v for k, v in _counters.items() if k != "started"},
|
||||||
|
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
|
||||||
|
for m, v in _cpt.items()},
|
||||||
|
"calibration_default": CHARS_PER_TOKEN,
|
||||||
|
"exempt_models": sorted(EXEMPT_MODELS),
|
||||||
|
"recent": list(_recent),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
server_version = "Governor/2.0"
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
|
||||||
|
self._send_json(200, status_payload())
|
||||||
|
return
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self._proxy()
|
||||||
|
|
||||||
|
# -- Kern ---------------------------------------------------------------
|
||||||
|
def _send_json(self, status: int, obj) -> None:
|
||||||
|
try:
|
||||||
|
data = json.dumps(obj).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _read_body(self) -> bytes:
|
||||||
|
length = self.headers.get("Content-Length")
|
||||||
|
if length is None:
|
||||||
|
return b""
|
||||||
|
try:
|
||||||
|
return self.rfile.read(int(length))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def _proxy(self) -> None:
|
||||||
|
body = self._read_body()
|
||||||
|
path = self.path
|
||||||
|
clean_path = path.split("?", 1)[0]
|
||||||
|
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
|
||||||
|
|
||||||
|
action = "passthrough"
|
||||||
|
est = None
|
||||||
|
streaming = False
|
||||||
|
model = ""
|
||||||
|
chars = 0
|
||||||
|
if is_chat and body:
|
||||||
|
action, body, est, streaming, model, chars = self._decide(body)
|
||||||
|
if action in ("soft", "hard"):
|
||||||
|
maybe_announce(est)
|
||||||
|
|
||||||
|
if action == "hard":
|
||||||
|
self._send_canned_stop(model, streaming, est)
|
||||||
|
with _state_lock:
|
||||||
|
_counters["chat"] += 1
|
||||||
|
_counters["hard"] += 1
|
||||||
|
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||||
|
"exact": None, "action": "hard"})
|
||||||
|
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
|
||||||
|
f"HARDSTOP stream={streaming} status=200")
|
||||||
|
return
|
||||||
|
|
||||||
|
out_headers = {}
|
||||||
|
for k, v in self.headers.items():
|
||||||
|
kl = k.lower()
|
||||||
|
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
|
||||||
|
continue
|
||||||
|
out_headers[k] = v
|
||||||
|
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
|
||||||
|
out_headers["Accept-Encoding"] = "identity"
|
||||||
|
if body:
|
||||||
|
out_headers["Content-Length"] = str(len(body))
|
||||||
|
out_headers["Connection"] = "close"
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
|
||||||
|
conn.request(self.command, path, body=body or None, headers=out_headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
log(f"ERROR upstream {self.command} {path}: {exc!r}")
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
self._safe_error(502, f"governor upstream: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(resp.status)
|
||||||
|
for k, v in resp.getheaders():
|
||||||
|
kl = k.lower()
|
||||||
|
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
|
||||||
|
continue
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
tail = bytearray()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
|
||||||
|
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
|
||||||
|
chunk = resp.read1(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
tail.extend(chunk)
|
||||||
|
if len(tail) > 16384:
|
||||||
|
del tail[:-16384]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
exact = self._scan_prompt_tokens(tail)
|
||||||
|
if is_chat:
|
||||||
|
if exact:
|
||||||
|
calibrate(model, chars, exact)
|
||||||
|
with _state_lock:
|
||||||
|
_counters["chat"] += 1
|
||||||
|
_counters["soft" if action == "soft" else "passthrough"] += 1
|
||||||
|
if exact:
|
||||||
|
_counters["tokens_prompt"] += exact
|
||||||
|
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
||||||
|
"exact": exact, "action": action})
|
||||||
|
exact_s = str(exact) if exact is not None else "-"
|
||||||
|
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
|
||||||
|
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
|
||||||
|
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
|
||||||
|
|
||||||
|
def _decide(self, body: bytes):
|
||||||
|
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return "passthrough", body, None, False, "", 0
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "passthrough", body, None, False, "", 0
|
||||||
|
|
||||||
|
messages = data.get("messages")
|
||||||
|
streaming = bool(data.get("stream"))
|
||||||
|
model = (data.get("model") or "").strip()
|
||||||
|
chars = body_chars(data)
|
||||||
|
est = int(chars / max(cpt_for(model), 0.1))
|
||||||
|
|
||||||
|
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
|
||||||
|
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
|
||||||
|
base = model.split("/")[-1].lower()
|
||||||
|
if base in EXEMPT_MODELS:
|
||||||
|
return "passthrough", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
if HARD_CEILING > 0 and est >= HARD_CEILING:
|
||||||
|
return "hard", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
if est >= THRESHOLD and isinstance(messages, list):
|
||||||
|
if not safe_to_append(messages):
|
||||||
|
# Mitten im Werkzeug-Austausch: nicht dazwischenfunken, naechste Runde.
|
||||||
|
return "defer", body, est, streaming, model, chars
|
||||||
|
directive = (DIRECTIVE.replace("{est}", str(est))
|
||||||
|
.replace("{threshold}", str(THRESHOLD)))
|
||||||
|
messages.append({"role": "user", "content": directive})
|
||||||
|
data["messages"] = messages
|
||||||
|
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
|
||||||
|
|
||||||
|
return "passthrough", body, est, streaming, model, chars
|
||||||
|
|
||||||
|
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
|
||||||
|
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
|
||||||
|
created = int(time.time())
|
||||||
|
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
|
||||||
|
"total_tokens": est or 0}
|
||||||
|
try:
|
||||||
|
if streaming:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def sse(obj):
|
||||||
|
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
|
||||||
|
"created": created, "model": model}
|
||||||
|
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
|
||||||
|
"finish_reason": None}]})
|
||||||
|
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
|
||||||
|
"finish_reason": None}]})
|
||||||
|
sse({**base, "choices": [{"index": 0, "delta": {},
|
||||||
|
"finish_reason": "stop"}], "usage": usage})
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"id": "governor-hardstop", "object": "chat.completion",
|
||||||
|
"created": created, "model": model,
|
||||||
|
"choices": [{"index": 0, "finish_reason": "stop",
|
||||||
|
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
|
||||||
|
"usage": usage,
|
||||||
|
}
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
self.wfile.flush()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _safe_error(self, status: int, msg: str) -> None:
|
||||||
|
self._send_json(status, {"error": msg})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scan_prompt_tokens(tail: bytearray):
|
||||||
|
if not tail:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = tail.decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
matches = _PROMPT_TOKENS_RE.findall(text)
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(matches[-1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
log_dir = os.path.dirname(LOG_PATH)
|
||||||
|
if log_dir and not os.path.isdir(log_dir):
|
||||||
|
try:
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
server.daemon_threads = True
|
||||||
|
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
|
||||||
|
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
|
||||||
|
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
|
||||||
|
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("Governor beendet (SIGINT).")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
|
||||||
|
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
|
||||||
|
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
|
||||||
|
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
|
||||||
|
After=network-online.target mission-control-2.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/governor
|
||||||
|
ExecStart=/usr/bin/python3 %h/governor/governor.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# --- Schwellen -------------------------------------------------------------
|
||||||
|
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
|
||||||
|
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
|
||||||
|
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
|
||||||
|
Environment=GOV_THRESHOLD=45000
|
||||||
|
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
|
||||||
|
Environment=GOV_LOG=%h/governor/governor.log
|
||||||
|
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
|
||||||
|
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
|
||||||
|
# klein -> passthrough (Modell antwortet normal)
|
||||||
|
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
|
||||||
|
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
|
||||||
|
set -u
|
||||||
|
GOV="http://127.0.0.1:8100/v1/chat/completions"
|
||||||
|
|
||||||
|
python3 - <<'PY' > /tmp/gov_small.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
|
||||||
|
PY
|
||||||
|
python3 - <<'PY' > /tmp/gov_mid.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
|
||||||
|
PY
|
||||||
|
python3 - <<'PY' > /tmp/gov_big.json
|
||||||
|
import json
|
||||||
|
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "=== TEST A: klein (passthrough) ==="
|
||||||
|
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
|
||||||
|
|
||||||
|
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
|
||||||
|
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
|
||||||
|
|
||||||
|
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
|
||||||
|
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
|
||||||
|
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
|
||||||
|
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
|
||||||
|
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
|
||||||
|
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
|
||||||
|
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
|
||||||
|
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
|
||||||
|
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
|
||||||
|
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
|
||||||
|
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
|
||||||
|
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
|
||||||
|
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
|
||||||
|
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
|
||||||
|
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
|
||||||
|
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
|
||||||
|
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
|
||||||
|
set -u
|
||||||
|
REPO="${1:?repo dir}"
|
||||||
|
MSG="${2:?message}"
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||||
|
export OPENAI_API_KEY="dummy"
|
||||||
|
cd "$REPO"
|
||||||
|
aider \
|
||||||
|
--model openai/Qwen3-Coder-Next \
|
||||||
|
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
|
||||||
|
--map-tokens 512 \
|
||||||
|
--read CONVENTIONS.md --read todo.py --read cli.py \
|
||||||
|
SAVEPOINT.md \
|
||||||
|
--message "$MSG"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
|
||||||
|
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
|
||||||
|
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
|
||||||
|
set -u
|
||||||
|
MSGS="${1:?Nachrichtendatei angeben}"
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
|
||||||
|
export OPENAI_API_KEY="dummy"
|
||||||
|
cd "$HOME/governor-p0/testrepo"
|
||||||
|
aider \
|
||||||
|
--model openai/Qwen3-Coder-Next \
|
||||||
|
--no-check-update \
|
||||||
|
--no-show-model-warnings \
|
||||||
|
--no-analytics \
|
||||||
|
--yes-always \
|
||||||
|
--map-tokens 512 \
|
||||||
|
--read CONVENTIONS.md \
|
||||||
|
SAVEPOINT.md \
|
||||||
|
< "$MSGS"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
|
||||||
|
set -u
|
||||||
|
MSGS="${1:?Nachrichtendatei angeben}"
|
||||||
|
REPO="${2:-$HOME/governor-p0/testrepo}"
|
||||||
|
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
|
||||||
|
cd "$REPO"
|
||||||
|
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
|
||||||
@@ -234,8 +234,8 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
n = src.count(p["old"])
|
n = src.count(p["old"])
|
||||||
if n != 1:
|
if n != 1:
|
||||||
failed.append((p["name"], f"erwartete 1 Vorkommen von old, fand {n} "
|
failed.append((p["name"], (f"erwartete 1 Vorkommen von old, fand {n} "
|
||||||
"(Update hat den Kontext geaendert?)"))
|
"(Update hat den Kontext geaendert?)")))
|
||||||
continue
|
continue
|
||||||
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
|
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import sys
|
|||||||
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
|
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
|
||||||
|
|
||||||
|
|
||||||
def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
def reap_orphaned_workers(connect_closing) -> list[tuple[int, str]]:
|
||||||
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
|
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
|
||||||
|
|
||||||
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
|
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
|
||||||
@@ -32,7 +32,7 @@ def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
|||||||
"""
|
"""
|
||||||
if sys.platform != "linux":
|
if sys.platform != "linux":
|
||||||
return []
|
return []
|
||||||
candidates: "dict[int, str]" = {}
|
candidates: dict[int, str] = {}
|
||||||
try:
|
try:
|
||||||
entries = os.listdir("/proc")
|
entries = os.listdir("/proc")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -53,7 +53,7 @@ def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
|||||||
|
|
||||||
task_ids = list(set(candidates.values()))
|
task_ids = list(set(candidates.values()))
|
||||||
placeholders = ",".join("?" * len(task_ids))
|
placeholders = ",".join("?" * len(task_ids))
|
||||||
running: "set[str]" = set()
|
running: set[str] = set()
|
||||||
try:
|
try:
|
||||||
with connect_closing() as conn:
|
with connect_closing() as conn:
|
||||||
running = {
|
running = {
|
||||||
@@ -67,7 +67,7 @@ def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
killed: "list[tuple[int, str]]" = []
|
killed: list[tuple[int, str]] = []
|
||||||
for pid, task_id in candidates.items():
|
for pid, task_id in candidates.items():
|
||||||
if task_id in running:
|
if task_id in running:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# opencode-lauf.sh — EIN begrenzter Agentenlauf auf der Box, mit denselben Regeln wie in Zed.
|
||||||
|
#
|
||||||
|
# Das ist die Nacht-Seite von „ein Regelwerk, zwei Ausloeser": tagsueber tippst du in Zed,
|
||||||
|
# nachts ruft ein Hermes-Cron dieses Skript. Beide Wege benutzen
|
||||||
|
# * dieselbe OpenCode-Version,
|
||||||
|
# * dieselbe Mannschaft (~/.config/opencode/opencode.json: coder/hermes/kritiker),
|
||||||
|
# * dasselbe Plugin (~/.config/opencode/plugin/mc2-governor.ts: Zaun + Pruef-Tor),
|
||||||
|
# * denselben Token-Waechter (:8100).
|
||||||
|
#
|
||||||
|
# Unterschied zu deploy/worker.sh: worker.sh ist EIN zustandsloser Completion-Aufruf
|
||||||
|
# (Hermes schreibt die Dateien selbst). Hier laeuft ein ECHTER Agent mit Datei-Haenden,
|
||||||
|
# Subagenten und Pruef-Tor — fuer ganze Karten statt fuer Schnipsel.
|
||||||
|
#
|
||||||
|
# Nutzung:
|
||||||
|
# opencode-lauf.sh <repo-pfad> "<auftrag>"
|
||||||
|
# opencode-lauf.sh ~/projekte/foo "Baue X. Halte dich an AGENTS.md."
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# LAUF_TIMEOUT Sekunden Hoechstdauer (Default 3600)
|
||||||
|
# LAUF_LAUT 1 = Lucy spricht mit (Default 0 = still, Nachtbetrieb)
|
||||||
|
# LAUF_AGENT OpenCode-Agent (Default build)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="${1:-}"
|
||||||
|
AUFTRAG="${2:-}"
|
||||||
|
TIMEOUT="${LAUF_TIMEOUT:-3600}"
|
||||||
|
AGENT="${LAUF_AGENT:-build}"
|
||||||
|
OC="$HOME/.opencode/bin/opencode"
|
||||||
|
ANNOUNCE="${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}"
|
||||||
|
|
||||||
|
melde () { # melde <betreff> <text> [prioritaet]
|
||||||
|
curl -sf -m 5 -X POST "$ANNOUNCE" -H 'Content-Type: application/json' \
|
||||||
|
--data "$(python3 -c 'import json,sys; print(json.dumps({"subject":sys.argv[1],"text":sys.argv[2],"source":"loop","priority":sys.argv[3]}))' \
|
||||||
|
"$1" "$2" "${3:-silent}")" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$REPO" ] || [ -z "$AUFTRAG" ]; then
|
||||||
|
echo "Nutzung: $0 <repo-pfad> \"<auftrag>\"" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -d "$REPO" ]; then
|
||||||
|
echo "FEHLER: '$REPO' ist kein Verzeichnis." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -x "$OC" ]; then
|
||||||
|
echo "FEHLER: OpenCode nicht gefunden ($OC)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Governor MUSS stehen — ohne ihn liefe der Lauf ohne Sitzungs-Bremse.
|
||||||
|
if ! curl -sf -m 5 -o /dev/null "http://127.0.0.1:8100/governor/status"; then
|
||||||
|
echo "FEHLER: Governor (:8100) antwortet nicht — Lauf abgebrochen (keine Sitzungs-Bremse)." >&2
|
||||||
|
melde "[Lauf]" "Ich habe einen Nachtlauf abgebrochen: der Token-Waechter antwortet nicht." "normal"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nachts still: Meldungen landen im Briefkasten, Lucy spricht sie aber nicht aus.
|
||||||
|
# Das Plugin liest diese Variable; die Morgen-Zusammenfassung kommt vom Daily-Briefing.
|
||||||
|
if [ "${LAUF_LAUT:-0}" = "1" ]; then export MC2_LOOP_SILENT=0; else export MC2_LOOP_SILENT=1; fi
|
||||||
|
|
||||||
|
LOGDIR="$HOME/.hermes/logs"; mkdir -p "$LOGDIR"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
LOG="$LOGDIR/opencode-lauf-$STAMP.log"
|
||||||
|
MAXRUNDEN="${LAUF_MAX_RUNDEN:-3}"
|
||||||
|
|
||||||
|
cd "$REPO" || exit 2
|
||||||
|
NAME="$(basename "$REPO")"
|
||||||
|
melde "[Lauf]" "Ich fange an zu bauen: $NAME." "silent"
|
||||||
|
echo "=== Lauf $STAMP · Repo $REPO · Agent $AGENT · Timeout ${TIMEOUT}s ===" | tee "$LOG"
|
||||||
|
|
||||||
|
# Verify-Befehl des Projekts lesen (gleiche Datei und gleiche Regeln wie im Plugin).
|
||||||
|
verify_cmd () {
|
||||||
|
[ -r "$REPO/VERIFY" ] || return 1
|
||||||
|
grep -vE '^\s*(#|$)' "$REPO/VERIFY" | paste -sd' && ' -
|
||||||
|
}
|
||||||
|
|
||||||
|
START=$(date +%s)
|
||||||
|
CODE=0
|
||||||
|
RUNDE=0
|
||||||
|
AUFGABE="$AUFTRAG"
|
||||||
|
|
||||||
|
# ── Bau-Schleife ────────────────────────────────────────────────────────────
|
||||||
|
# Warum hier UND im Plugin? Das Plugin haengt am Ereignis `session.idle` — in Zed
|
||||||
|
# laeuft der Prozess weiter und alles ist gut. Bei `opencode run` beendet sich der
|
||||||
|
# Prozess aber, bevor das Pruef-Tor fertig ist (gemessen 25.07.). Fuer unbeaufsichtigte
|
||||||
|
# Laeufe muss die Schleife deshalb HIER liegen, wo sie den Prozess ueberlebt.
|
||||||
|
while :; do
|
||||||
|
RUNDE=$((RUNDE + 1))
|
||||||
|
echo "--- Runde $RUNDE/$MAXRUNDEN ---" | tee -a "$LOG"
|
||||||
|
timeout "$TIMEOUT" "$OC" run --agent "$AGENT" "$AUFGABE" >>"$LOG" 2>&1
|
||||||
|
CODE=$?
|
||||||
|
[ "$CODE" -eq 124 ] && { echo "ZEITUEBERSCHREITUNG" | tee -a "$LOG"; break; }
|
||||||
|
|
||||||
|
VCMD="$(verify_cmd)" || { echo "Kein VERIFY — Pruef-Tor aus, Lauf endet." | tee -a "$LOG"; break; }
|
||||||
|
|
||||||
|
echo "--- Pruef-Tor: $VCMD ---" | tee -a "$LOG"
|
||||||
|
VOUT="$(cd "$REPO" && eval "$VCMD" 2>&1)"; VCODE=$?
|
||||||
|
printf '%s\n' "$VOUT" | tail -20 >> "$LOG"
|
||||||
|
|
||||||
|
if [ "$VCODE" -eq 0 ]; then
|
||||||
|
echo "PRUEF-TOR GRUEN" | tee -a "$LOG"
|
||||||
|
melde "[Pruefung]" "$NAME: Tests gruen nach $RUNDE Runde(n)." "normal"
|
||||||
|
CODE=0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RUNDE" -ge "$MAXRUNDEN" ]; then
|
||||||
|
echo "PRUEF-TOR ROT — Reparaturrunden aufgebraucht." | tee -a "$LOG"
|
||||||
|
melde "[Pruefung]" "$NAME: Tests bleiben rot nach $RUNDE Runden. Hier komme ich allein nicht weiter." "normal"
|
||||||
|
CODE=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PRUEF-TOR ROT — Runde $((RUNDE + 1)) folgt." | tee -a "$LOG"
|
||||||
|
melde "[Pruefung]" "$NAME: Tests rot, ich repariere selbst weiter (Runde $((RUNDE + 1))/$MAXRUNDEN)." "silent"
|
||||||
|
AUFGABE="[MC2-PRUEFTOR] Der Verify-Befehl des Projekts ist fehlgeschlagen.
|
||||||
|
|
||||||
|
Befehl: $VCMD
|
||||||
|
|
||||||
|
Ausgabe (Ende):
|
||||||
|
$(printf '%s' "$VOUT" | tail -c 3000)
|
||||||
|
|
||||||
|
Behebe die URSACHE — nicht das Symptom. Schalte keinen Test ab und aendere keine Tests.
|
||||||
|
Urspruenglicher Auftrag war: $AUFTRAG"
|
||||||
|
done
|
||||||
|
|
||||||
|
DAUER=$(( $(date +%s) - START ))
|
||||||
|
# Nachweis statt Behauptung: was hat der Lauf im Arbeitsbaum tatsaechlich veraendert?
|
||||||
|
GEAENDERT="$(git -C "$REPO" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
|
case "$CODE" in
|
||||||
|
0) ERG="fertig, Pruefung bestanden" ;;
|
||||||
|
124) ERG="ZEITUEBERSCHREITUNG nach ${TIMEOUT}s" ;;
|
||||||
|
*) ERG="Pruefung NICHT bestanden (Exitcode $CODE)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "=== Ergebnis: $ERG · ${DAUER}s · $RUNDE Runde(n) · $GEAENDERT geaenderte Dateien · Log $LOG ===" | tee -a "$LOG"
|
||||||
|
melde "[Lauf]" "$NAME: $ERG nach $((DAUER/60)) Minuten, $RUNDE Runde(n), $GEAENDERT Dateien angefasst." "silent"
|
||||||
|
exit "$CODE"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# OpenCode-Seite: ein Regelwerk, zwei Auslöser
|
||||||
|
|
||||||
|
Tagsüber tippst du in **Zed** (PC), nachts ruft ein **Hermes-Cron** dasselbe (Box).
|
||||||
|
Beide Wege benutzen dieselbe OpenCode-Version, dieselbe Mannschaft, dasselbe Plugin
|
||||||
|
und denselben Token-Wächter. Der einzige Unterschied ist die Adresse des Governors.
|
||||||
|
|
||||||
|
## Was wohin gehört
|
||||||
|
|
||||||
|
| Datei hier | Ziel auf dem PC | Ziel auf der Box |
|
||||||
|
|---|---|---|
|
||||||
|
| `opencode.pc.json` | `~/.config/opencode/opencode.json` | — |
|
||||||
|
| `opencode.box.json` | — | `~/.config/opencode/opencode.json` |
|
||||||
|
| `plugin/mc2-governor.ts` | `~/.config/opencode/plugin/` | `~/.config/opencode/plugin/` |
|
||||||
|
| `VERIFY.template` | — | wird von `gitea-repo-create.sh` in **jedes neue Repo** als `VERIFY` gesät |
|
||||||
|
|
||||||
|
Der Governor selbst liegt in `../governor/` (Proxy + systemd-Unit), der unbeaufsichtigte
|
||||||
|
Läufer in `../opencode-lauf.sh`.
|
||||||
|
|
||||||
|
## Die Mannschaft
|
||||||
|
|
||||||
|
| Rolle | Modell | Gemessen (25.07.2026) | Warum |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `plan` + `build` | `coder` — Qwen3-Coder-Next | **51,5 t/s** | Hält den Faden, verteilt Zuarbeit. MoE mit 3B aktiv → schnell auf Strix Halo. |
|
||||||
|
| `explore` | `hermes` — Qwen3.6-35B | **69,6 t/s** | Ohnehin dauerwarm → kostet **null** zusätzlichen Speicher. Sucht, liest, meldet kurz zurück. |
|
||||||
|
| `review` | `kritiker` — Devstral-Small-2 | **15,0 t/s** | Bewusst eine **fremde Modellfamilie** (Mistral statt Qwen) → andere blinde Flecken. Dicht = langsam beim Schreiben, aber ein Kritiker liest viel und schreibt wenig. |
|
||||||
|
|
||||||
|
`heavy` (gpt-oss-120b, 63 GB) ist **nicht** mehr in der Tagesrolle: es würde beim Laden
|
||||||
|
das ganze warme Set verdrängen. Es bleibt der Nacht-Gutachter (4:30-Cron).
|
||||||
|
|
||||||
|
## Nach einer Änderung
|
||||||
|
|
||||||
|
Die Dateien hier sind **Vorlagen**, keine Live-Konfiguration. Nach einer Änderung
|
||||||
|
verteilen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Box
|
||||||
|
scp deploy/opencode/opencode.box.json hitonabi@192.168.178.151:~/.config/opencode/opencode.json
|
||||||
|
scp deploy/opencode/plugin/*.ts hitonabi@192.168.178.151:~/.config/opencode/plugin/
|
||||||
|
# PC (aus dem Repo heraus)
|
||||||
|
cp deploy/opencode/opencode.pc.json ~/.config/opencode/opencode.json
|
||||||
|
cp deploy/opencode/plugin/*.ts ~/.config/opencode/plugin/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zed muss danach neu gestartet werden** — OpenCode liest seine Konfiguration nur beim Start.
|
||||||
|
|
||||||
|
## Fallen, die Zeit gekostet haben
|
||||||
|
|
||||||
|
- **Kein `_comment`-Schlüssel in `opencode.json`.** OpenCode validiert streng und
|
||||||
|
verweigert den Start mit „Unrecognized key". Kommentare gehören in dieses README.
|
||||||
|
- **Das Plugin läuft auf Windows UND Linux.** Deshalb `node:fs` statt `cat` und Buns
|
||||||
|
`${{ raw: cmd }}` statt `bash -lc` — beides fehlt auf Windows bzw. verschluckt den Befehl.
|
||||||
|
- **Bei `opencode run` beendet sich der Prozess, bevor `session.idle` fertig ist**
|
||||||
|
(gemessen 25.07.). Für unbeaufsichtigte Läufe liegt die Prüf-Schleife deshalb
|
||||||
|
zusätzlich in `opencode-lauf.sh`, wo sie den Prozess überlebt. In Zed greift das Plugin.
|
||||||
|
- **Plugin-Verzeichnis:** `plugin/` und `plugins/` werden beide erkannt; wir nutzen `plugin/`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# VERIFY — wie man dieses Projekt prueft.
|
||||||
|
#
|
||||||
|
# Diese Datei ist das Pruef-Tor. Das MC2-Governor-Plugin fuehrt sie aus, sobald der
|
||||||
|
# Agent "fertig" sagt. Gruen -> Etappe gilt als fertig. Rot -> der Fehler geht
|
||||||
|
# automatisch als naechster Auftrag an den Agenten zurueck, bis zu 3 Runden.
|
||||||
|
#
|
||||||
|
# Regeln:
|
||||||
|
# * Eine Zeile = ein Befehl. Alle Zeilen werden mit && verkettet.
|
||||||
|
# * Zeilen mit # sind Kommentare.
|
||||||
|
# * KEINE Datei VERIFY im Projekt = Pruef-Tor aus (nichts passiert).
|
||||||
|
# * Der Befehl muss ohne Rueckfragen durchlaufen und mit 0 enden, wenn alles gut ist.
|
||||||
|
#
|
||||||
|
# Beispiele (unzutreffende Zeilen loeschen):
|
||||||
|
#
|
||||||
|
# Python: ruff check . && pytest -q
|
||||||
|
# Node/TS: npm run lint && npm test
|
||||||
|
# Frontend: npm run build
|
||||||
|
# Nur Syntax: python -m compileall -q .
|
||||||
|
# Nichts da: git diff --stat (laeuft immer gruen — Platzhalter)
|
||||||
|
|
||||||
|
git diff --stat
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"aibox": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "AI-Box ueber Governor (lokal)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://127.0.0.1:8100/v1",
|
||||||
|
"apiKey": "local"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"coder": {
|
||||||
|
"name": "coder — Bauen + Planen (Qwen3-Coder-Next, 51,5 t/s)",
|
||||||
|
"limit": { "context": 131072, "output": 16384 }
|
||||||
|
},
|
||||||
|
"hermes": {
|
||||||
|
"name": "hermes — Suchen (Qwen3.6-35B, immer warm, 69,6 t/s)",
|
||||||
|
"limit": { "context": 65536, "output": 8192 }
|
||||||
|
},
|
||||||
|
"kritiker": {
|
||||||
|
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 15,0 t/s)",
|
||||||
|
"limit": { "context": 65536, "output": 8192 }
|
||||||
|
},
|
||||||
|
"heavy": {
|
||||||
|
"name": "heavy — Nacht-Gutachter (gpt-oss-120b, verdraengt das warme Set!)",
|
||||||
|
"limit": { "context": 32768, "output": 8192 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"small_model": "aibox/hermes",
|
||||||
|
"agent": {
|
||||||
|
"plan": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": { "edit": "deny", "bash": "deny" }
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"webfetch": "allow",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"ssh *": "deny",
|
||||||
|
"scp *": "deny",
|
||||||
|
"sftp *": "deny",
|
||||||
|
"ssh arcane@192.168.178.162 *": "allow",
|
||||||
|
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||||
|
"scp *arcane@192.168.178.162*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, laeuft auf dem immer warmen Hirn",
|
||||||
|
"model": "aibox/hermes",
|
||||||
|
"permission": { "edit": "deny", "bash": "deny" }
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, aendert nichts. Laeuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
|
||||||
|
"model": "aibox/kritiker",
|
||||||
|
"permission": { "edit": "deny", "bash": "deny" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"aibox": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "AI-Box ueber Governor (192.168.178.151:8100)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://192.168.178.151:8100/v1",
|
||||||
|
"apiKey": "local"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"heavy": {
|
||||||
|
"name": "heavy — Planer (gpt-oss-120b, 32k)",
|
||||||
|
"limit": { "context": 32768, "output": 8192 }
|
||||||
|
},
|
||||||
|
"coder": {
|
||||||
|
"name": "coder — Bauen (Qwen3-Coder-Next, 131k)",
|
||||||
|
"limit": { "context": 131072, "output": 16384 }
|
||||||
|
},
|
||||||
|
"hermes": {
|
||||||
|
"name": "hermes — Erkunden (Qwen3.6, immer warm, 69,6 t/s)",
|
||||||
|
"limit": { "context": 65536, "output": 8192 }
|
||||||
|
},
|
||||||
|
"kritiker": {
|
||||||
|
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 15,0 t/s)",
|
||||||
|
"limit": { "context": 65536, "output": 8192 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"small_model": "aibox/hermes",
|
||||||
|
"agent": {
|
||||||
|
"plan": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": { "edit": "deny", "bash": "deny" }
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"model": "aibox/coder",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"webfetch": "allow",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"ssh *": "deny",
|
||||||
|
"scp *": "deny",
|
||||||
|
"sftp *": "deny",
|
||||||
|
"ssh arcane@192.168.178.162 *": "allow",
|
||||||
|
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
|
||||||
|
"scp *arcane@192.168.178.162*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, läuft auf dem immer warmen Hirn",
|
||||||
|
"model": "aibox/hermes",
|
||||||
|
"permission": { "edit": "deny", "bash": "deny" }
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, ändert nichts. Läuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
|
||||||
|
"model": "aibox/kritiker",
|
||||||
|
"permission": { "edit": "deny", "bash": "deny" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten.
|
||||||
|
*
|
||||||
|
* Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die
|
||||||
|
* Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden
|
||||||
|
* Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge:
|
||||||
|
*
|
||||||
|
* 1. WERKZEUG-ZAUN (tool.execute.before)
|
||||||
|
* Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push,
|
||||||
|
* Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser
|
||||||
|
* Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 —
|
||||||
|
* nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen.
|
||||||
|
*
|
||||||
|
* 2. PRUEF-TOR + SCHLEIFE (session.idle)
|
||||||
|
* Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei
|
||||||
|
* `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler
|
||||||
|
* geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu
|
||||||
|
* MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse.
|
||||||
|
*
|
||||||
|
* 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted)
|
||||||
|
* Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper
|
||||||
|
* "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md
|
||||||
|
* zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat.
|
||||||
|
*
|
||||||
|
* 4. STIMME (MC2 /api/voice/announce)
|
||||||
|
* Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten.
|
||||||
|
* Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code.
|
||||||
|
*
|
||||||
|
* Schalter (Umgebungsvariablen):
|
||||||
|
* MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001)
|
||||||
|
* MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an)
|
||||||
|
* MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3)
|
||||||
|
* MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an)
|
||||||
|
* MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen
|
||||||
|
* MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle)
|
||||||
|
*
|
||||||
|
* Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt —
|
||||||
|
* am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001"
|
||||||
|
const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0"
|
||||||
|
const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10)
|
||||||
|
const SILENT = process.env.MC2_LOOP_SILENT === "1"
|
||||||
|
const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0"
|
||||||
|
const FENCE_OFF = process.env.MC2_FENCE_OFF === "1"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile —
|
||||||
|
* ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt.
|
||||||
|
* Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen
|
||||||
|
* Entgleisen, keine Abwehr gegen einen boesartigen Akteur.
|
||||||
|
*/
|
||||||
|
const FENCE: Array<{ rx: RegExp; why: string }> = [
|
||||||
|
{ rx: /\bgit\s+push\b/, why: "git push — Veroeffentlichen ist Sache des Menschen (oder der CI-Ampel)." },
|
||||||
|
{ rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." },
|
||||||
|
{ rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." },
|
||||||
|
{ rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." },
|
||||||
|
{ rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." },
|
||||||
|
{ rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." },
|
||||||
|
{ rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." },
|
||||||
|
{ rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." },
|
||||||
|
{ rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." },
|
||||||
|
{ rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." },
|
||||||
|
{ rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */
|
||||||
|
const rounds = new Map<string, number>()
|
||||||
|
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
|
||||||
|
const busy = new Set<string>()
|
||||||
|
|
||||||
|
async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") {
|
||||||
|
if (!ANNOUNCE_ON) return
|
||||||
|
try {
|
||||||
|
await fetch(`${BOX_URL}/api/voice/announce`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
source: "loop",
|
||||||
|
priority: SILENT ? "silent" : priority,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(4000),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* best effort — eine stumme Lucy darf den Bau nie aufhalten */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MC2Governor = async ({ client, $, directory, worktree }: any) => {
|
||||||
|
const root: string = worktree || directory || process.cwd()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS.
|
||||||
|
* Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und
|
||||||
|
* nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig.
|
||||||
|
*/
|
||||||
|
async function readVerify(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { readFile } = await import("node:fs/promises")
|
||||||
|
const { join } = await import("node:path")
|
||||||
|
const raw = await readFile(join(root, "VERIFY"), "utf8")
|
||||||
|
const cmd = raw
|
||||||
|
.split("\n")
|
||||||
|
.map((l: string) => l.trim())
|
||||||
|
.filter((l: string) => l && !l.startsWith("#"))
|
||||||
|
.join(" && ")
|
||||||
|
return cmd || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt.
|
||||||
|
* `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales
|
||||||
|
* `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen.
|
||||||
|
* Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt.
|
||||||
|
*/
|
||||||
|
async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> {
|
||||||
|
try {
|
||||||
|
const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet()
|
||||||
|
const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}`
|
||||||
|
return { ok: res.exitCode === 0, out: out.slice(-4000) }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, out: String(e?.message ?? e).slice(-4000) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */
|
||||||
|
async function sendPrompt(sessionID: string, text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await client.session.prompt({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: { parts: [{ type: "text", text }] },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ── 1. Werkzeug-Zaun ───────────────────────────────────────────────────
|
||||||
|
"tool.execute.before": async (input: any, output: any) => {
|
||||||
|
if (FENCE_OFF) return
|
||||||
|
if (input?.tool !== "bash") return
|
||||||
|
const cmd: string = output?.args?.command ?? ""
|
||||||
|
if (!cmd) return
|
||||||
|
for (const rule of FENCE) {
|
||||||
|
if (rule.rx.test(cmd)) {
|
||||||
|
await announce(
|
||||||
|
"[Zaun]",
|
||||||
|
`Ich habe einen Befehl geblockt: ${rule.why}`,
|
||||||
|
"silent",
|
||||||
|
)
|
||||||
|
// Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem
|
||||||
|
// Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders.
|
||||||
|
throw new Error(
|
||||||
|
`[MC2-ZAUN] Blockiert: ${rule.why}\n` +
|
||||||
|
`Befehl war: ${cmd}\n` +
|
||||||
|
`Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` +
|
||||||
|
`er macht es selbst.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 2.-4. Ereignisse ───────────────────────────────────────────────────
|
||||||
|
event: async ({ event }: any) => {
|
||||||
|
const type: string = event?.type ?? ""
|
||||||
|
const props: any = event?.properties ?? event ?? {}
|
||||||
|
const sessionID: string = props.sessionID || props.sessionId || props.id || ""
|
||||||
|
|
||||||
|
// ── Savepoint statt Zusammenfassen ──────────────────────────────────
|
||||||
|
if (type === "session.compacted" || type === "experimental.session.compacting") {
|
||||||
|
await announce(
|
||||||
|
"[Sitzung]",
|
||||||
|
"Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.",
|
||||||
|
"silent",
|
||||||
|
)
|
||||||
|
if (sessionID) {
|
||||||
|
await sendPrompt(
|
||||||
|
sessionID,
|
||||||
|
"[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " +
|
||||||
|
"Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " +
|
||||||
|
"erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " +
|
||||||
|
"Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pruef-Tor + Selbstreparatur ─────────────────────────────────────
|
||||||
|
if (type !== "session.idle" || !sessionID) return
|
||||||
|
if (busy.has(sessionID)) return
|
||||||
|
|
||||||
|
const cmd = await readVerify()
|
||||||
|
if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus.
|
||||||
|
|
||||||
|
busy.add(sessionID)
|
||||||
|
try {
|
||||||
|
const { ok, out } = await runVerify(cmd)
|
||||||
|
const round = rounds.get(sessionID) ?? 0
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
rounds.delete(sessionID)
|
||||||
|
await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AUTOFIX || round >= MAX_ROUNDS) {
|
||||||
|
rounds.delete(sessionID)
|
||||||
|
await announce(
|
||||||
|
"[Pruefung]",
|
||||||
|
`Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` +
|
||||||
|
`Hier komme ich allein nicht weiter, Commander.`,
|
||||||
|
"normal",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rounds.set(sessionID, round + 1)
|
||||||
|
await announce(
|
||||||
|
"[Pruefung]",
|
||||||
|
`Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`,
|
||||||
|
"silent",
|
||||||
|
)
|
||||||
|
await sendPrompt(
|
||||||
|
sessionID,
|
||||||
|
`[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` +
|
||||||
|
`Projekts ist fehlgeschlagen.\n\n` +
|
||||||
|
`Befehl: ${cmd}\n\n` +
|
||||||
|
`Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` +
|
||||||
|
`Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` +
|
||||||
|
`Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` +
|
||||||
|
`(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
busy.delete(sessionID)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MC2Governor
|
||||||
@@ -84,7 +84,7 @@ def norm(s):
|
|||||||
|
|
||||||
def code_block(text, sprache="python"):
|
def code_block(text, sprache="python"):
|
||||||
"""Letzten passenden Code-Block extrahieren; ohne Zaun: ganzen Text nehmen."""
|
"""Letzten passenden Code-Block extrahieren; ohne Zaun: ganzen Text nehmen."""
|
||||||
bloecke = re.findall(r"```(?:%s)?\s*\n(.*?)```" % re.escape(sprache),
|
bloecke = re.findall(rf"```(?:{re.escape(sprache)})?\s*\n(.*?)```",
|
||||||
text or "", re.DOTALL | re.IGNORECASE)
|
text or "", re.DOTALL | re.IGNORECASE)
|
||||||
if bloecke:
|
if bloecke:
|
||||||
return bloecke[-1]
|
return bloecke[-1]
|
||||||
@@ -469,7 +469,7 @@ def suite_speed(cfg, ergebnisse):
|
|||||||
# Kurz-Probe: misst tg (Alltags-Turn) — 2 Läufe, erster wärmt den Cache an.
|
# Kurz-Probe: misst tg (Alltags-Turn) — 2 Läufe, erster wärmt den Cache an.
|
||||||
for lauf in ("warm", "kurz"):
|
for lauf in ("warm", "kurz"):
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
|
_msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
|
||||||
{"role": "user", "content":
|
{"role": "user", "content":
|
||||||
"Erkläre in drei kurzen Sätzen, was ein Mixture-of-Experts-Modell "
|
"Erkläre in drei kurzen Sätzen, was ein Mixture-of-Experts-Modell "
|
||||||
"ist."}], max_tokens=200, temperature=0)
|
"ist."}], max_tokens=200, temperature=0)
|
||||||
@@ -487,7 +487,7 @@ def suite_speed(cfg, ergebnisse):
|
|||||||
return
|
return
|
||||||
lang = LANG_BAUSTEIN * 550
|
lang = LANG_BAUSTEIN * 550
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
|
_msg, tim, err = api_chat(cfg.endpoint, cfg.model, [
|
||||||
{"role": "user", "content":
|
{"role": "user", "content":
|
||||||
lang + "\n\nWie oft steht sinngemäß derselbe Satz oben? Antworte in "
|
lang + "\n\nWie oft steht sinngemäß derselbe Satz oben? Antworte in "
|
||||||
"einem Satz."}], max_tokens=80, temperature=0, timeout=900)
|
"einem Satz."}], max_tokens=80, temperature=0, timeout=900)
|
||||||
|
|||||||
+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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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]);
|
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-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";/**
|
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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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.
|
* This source code is licensed under the ISC license.
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
* 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,
|
1) wer ich bin und woran ich gerade arbeite,
|
||||||
2) wie ich angesprochen werden möchte,
|
2) wie ich angesprochen werden möchte,
|
||||||
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
|
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(`
|
`),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(`
|
`)},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};
|
`)},"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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* 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
|
* @license lucide-react v0.460.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DZ34eLRy.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cm0NCQeJ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CYYdYGeg.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bkns39Uj.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user