28 Commits

Author SHA1 Message Date
Hitonabi b08e867c1a feat: integrate Guide-Tab (Anleitung) for local Vibe Coding and compile frontend 2026-06-25 22:36:58 +02:00
Hitonabi bd6aacf3ed feat: configure MC_ENGINE_UPDATE_CMD in systemd unit file 2026-06-25 22:32:21 +02:00
Hitonabi cb02ede5fb fix: serve manifest.webmanifest and other root files in SPA catch-all 2026-06-25 22:30:26 +02:00
Hitonabi d15744a812 feat: consolidate routing view into models tab, and add updates widget directly to Zentrale dashboard 2026-06-25 22:24:00 +02:00
Hitonabi 6a8e55cc43 feat: complete UI/UX Rework into Sleek Glassmorphic AI OS (June 2026) 2026-06-25 22:18:51 +02:00
Hitonabi e1da5c797d feat(2.0): implement cockpit improvements, dynamic mcp path, v2 venv, and ko-residency 2026-06-25 21:50:16 +02:00
Hitonabi 807c2c6194 docs: sync status, hermes setup, and cutover docs with box reality 2026-06-25 21:41:03 +02:00
Hitonabi 2536d91430 docs(2.0): W8 — Wartungs-Bedienung + sudoers-Erweiterung dokumentiert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:44:57 +02:00
Hitonabi 1f391644ca feat(2.0): W8 — Wartung (OS/Engine/Modell-Updates, Restart, Reboot, Logs)
services/maintenance.py + routers/maintenance.py: updates-Badge (apt/Engine-
Release/dyn. Modell-Upgrades via discover), os-update + engine-update als
jobengine-Jobs, system-/user-aware Restart (llama-swap via sudo -n NOPASSWD),
reboot, logs (journalctl). Frontend: Wartungs-Block in SystemView mit Badge,
Buttons + Modell-Upgrade-Vorschlaegen. Passwortfrei (sudo -n).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:43:53 +02:00
Hitonabi 2ea3d01b58 docs(2.0): W4 — Bedien-Anleitung (BEDIENUNG.md) + Hilfe-Link im Cockpit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:35:31 +02:00
Hitonabi c863f01a78 feat(2.0): W2+W3 — HF-Link/Suche + Modell-Verwaltungs-UX
W2: install akzeptiert HF-URL ODER org/repo (normalize_repo); GET /api/hf/
search + /api/hf/quants; Frontend AddModel-Panel (URL+Quant-Dropdown+freie
Suche) im Discover-Tab. W3: POST /api/models/{id}/role + /ctx; Installiert-
Tab mit Rollen-Select (fast/heavy/coder/...), ctx-Edit, Loeschen → LLM
tauschen per Klick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:33:23 +02:00
Hitonabi 501ba36b89 feat(2.0): W1 — fast-Spur ohne Thinking (flotte Antworten) + tool-call-Cap
Gateway injiziert chat_template_kwargs.enable_thinking=false fuer die fast-
Spur (Qwen3.6 ist Reasoning-Modell → sonst lahm/leer). heavy behaelt
Thinking. Env MC_FAST_NO_THINK. Hermes-Thrash war poisoned Persistent-
Session (fresh=clean, 34k statt 249k verifiziert); code_execution.max_tool_
calls 50->20 auf der Box (Historie unangetastet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:24:57 +02:00
Hitonabi 77b6dee02f docs(2.0): Phase 6d/e — Cutover-Readiness + Hermes-Tuning-Befunde
docs/CUTOVER.md: Box-Stack live (Modelle/Gateway/Hermes/Memory verifiziert),
bekannte Tuning-Punkte (Hermes-Kontext/Tool-Thrash NICHT brain-abhaengig →
Hands-on-Debug; SSH-Windows; nesquena optional), reversibler Cutover-Ablauf.
STATUS aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:08:04 +02:00
Hitonabi ba435fb1d7 chore(2.0): geteiltes Gedaechtnis = bestehende v1-DB (Cutover-Kontinuitaet)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:59:18 +02:00
Hitonabi ceca2ae8e3 feat(2.0): Phase 6c — eingebauter OpenAI-Gateway (model:auto) statt LiteLLM
LiteLLM baut auf Python 3.14 nicht (orjson-Pin ohne cp314-Wheel). Stattdessen
eingebauter Gateway in MC2: routers/gateway_proxy.py (/v1/chat/completions,
/completions, /models) + services/router_logic.py (Komplexitaets-Routing
fast<->heavy, Streaming-Passthrough). gateway.py/routing.py/connect.py auf
builtin umgestellt (Endpunkt = MC :PORT/v1). Gleicher OpenAI-Vertrag,
spaeter gegen LiteLLM austauschbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:54:19 +02:00
Hitonabi db1f62227b feat(2.0): DELETE /api/models/{id} (Eintrag + Gruppen-Mitgliedschaft entfernen)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:41:58 +02:00
Hitonabi fc0153d0de fix(2.0): model_id bei Split-GGUFs im Quant-Unterordner (nicht Q4_K_M)
model_id_from_path steigt bei Quant-Ordner (Q4_K_M/, UD-Q4_K_M/) eine
Ebene hoch zum Repo-Ordner. Sonst hiess das Heavy-Modell Q4_K_M.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:40:44 +02:00
Hitonabi af46a7b041 feat(2.0): Phase 6a — Modell-Install (Download-Jobs + Split + UI)
jobengine portiert (Live-Log + %-Fortschritt aus .incomplete), services/hf.py
(resolve_gguf inkl. Split -of-, Groessen), POST /api/models/install (hf
download als Job + sofortige Registrierung, Split-fest), GET /api/jobs +
cancel. huggingface_hub in requirements. Frontend: Install-Buttons auf
Discover-Karten + Live-Download-Fortschrittsbalken (JobsBar).

Verifiziert: resolve_gguf gegen echte Repos (Qwen3.6-35B-A3B 23GB single,
Qwen3.5-122B-A10B 77GB 3-part split) + Frontend-Build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:59:46 +02:00
Hitonabi f1cbfa8e67 docs(2.0): Box-Deploy live (:9001) + Stand/Naechste-Schritte aktualisiert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:35:39 +02:00
Hitonabi 2e6655c398 fix(2.0): GPU-Metrik findet Strix-Halo-Card dynamisch (card1) + GTT
Real-Box-Befund: GPU ist card1 (nicht hardcoded card0), Connector-Dirs
uebersprungen. Zusaetzlich GTT (echter Unified-Memory-Pool) statt nur
VRAM-Carve-out. Frontend zeigt GTT bevorzugt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:33:29 +02:00
Hitonabi 1b332f86e6 chore(2.0): Deploy als sudo-freier systemd-USER-Dienst (Home, :9001)
mission-control-2.service auf %h/mission-control-v2 umgestellt (kein /opt/
sudo); deploy.sh = Erstinstall+Update als User-Dienst (clone/pull, venv,
systemctl --user, linger). Nordstern: kein Passwort/SSH-Gefummel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:30:40 +02:00
Hitonabi 1f4c987652 feat(2.0): Phase 5 — Betrieb, Observability + Politur
Backup: services/backup.py (Memory-SQLite inkl. WAL/SHM + Configs, retain 7)
+ deploy/backup.sh + POST /api/system/backup + GET /api/system/backups.
Services-Health: GET /api/system/services (aggregierte Reachability +
Observability-Links). Frontend: SystemView mit Dienste-Health + Backup-
Button + Links; Theme-Toggle (Hell/Dunkel, persistent).

Lokal verifiziert: Backup-Snapshot + services-Aggregat (TestClient),
Frontend-Build + Browser (Light-Mode bestaetigt via computed styles).
Damit Phasen 0-5 lokal fertig. Docs aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:29:27 +02:00
Hitonabi c773dd7eae feat(2.0): Phase 4 — Hermes-Schicht (MC-Seite + Runbook)
MC-Seite: services/agent.py + routers/agent.py (GET /api/agent/status:
Gateway/WebUI-Reachability + Verdrahtungs-Hinweise), AgentView (Status-
Tiles, Hermes-oeffnen-Button, Offline-Hinweis). deploy/hermes-webui.service
(systemd-USER, :8787). Box-Runbook docs/HERMES_SETUP.md (hermes-webui
installieren, Brain=model:auto via Gateway, Tools/MCP verdrahten inkl.
mcp_mc+mcp_memory, SSH-Windows, LiteLLM-Caveat #26489).

Lokal verifiziert: agent/status + Frontend-Build + Browser (Hermes-View).
Box-Ausfuehrung der Verdrahtung steht aus (Runbook). Docs aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:22:28 +02:00
Hitonabi 81468df9c0 feat(2.0): Phase 3 — Memory + MCP
Geteiltes Gedaechtnis: services/memory.py (SQLite/WAL, 5 Kategorien,
Dedupe-Kurator deterministisch), routers/memory.py (CRUD/export/dedupe).
MCP: mcp/mcp_memory.py (Guard-Beschreibungen gegen 14B-Loop) + mcp/mcp_mc.py
NEU (Stack-Management fuer Hermes: list/discover/register/route/restart/
status). Frontend MemoryView (Add/Filter/Suche/Delete/Aufraeumen).

Lokal verifiziert: CRUD + Dedupe (TestClient), MCP-Server syntax-OK,
Frontend-Build + Browser (MemoryView). Docs aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:16:12 +02:00
Hitonabi 6c8b6d81fe feat(2.0): Phase 2 — System/OS + Connect
System: services/system.py (psutil CPU/RAM/Disk, sysfs GPU/Temp guarded),
routers/system.py GET status + restart (Whitelist) + self-update (systemd-
USER, sudo-frei). Connect: services/connect.py (Cline/OpenCode/Zed/Continue/
Claude Code/Memory-MCP → Gateway model:auto, LAN-IP-Override), routers/
connect.py. Frontend: SystemView (Metrik-Bars) + ConnectView (Tool-Tabs,
Copy, IP-Override).

Lokal verifiziert: Backend-Smoke + Frontend-Build + Browser (System-Bars,
Connect-Snippets). Docs aktualisiert (README/STATUS/Plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:08:43 +02:00
Hitonabi cff3f0b1a8 feat(2.0): Phase 1 — Engine + Routing (Herzstueck)
Backend-Services: fit/caps/sources (portiert), discover (live HF + Fit +
Caps + ranked recommendation), llama-swap write/register + groups (Ko-
Residenz swap:false), LiteLLM-Gateway-Config + gateway-Service (model:auto +
Fallbacks). Router: discover/fit/register/groups/routing; health zeigt
gateway_reachable. Frontend: Modelle&Routing mit Caps-Chips, Fit-Badges,
Discover-Tab (live), Routing-View.

Lokal verifiziert: Backend-Smoke (alle Endpunkte) + Frontend-Build +
Browser (Shell, Discover, Caps/Fit). Box-Verifikation offen.

Docs: README + docs/STATUS.md (Phasen-Tracker + Resume-Guide).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:01:12 +02:00
Hitonabi 1b421e30f9 build(2.0): frontend/dist committen (kein Node-Build auf der Box)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:18:15 +02:00
Hitonabi b805c294eb feat(2.0): Phase 0 — Greenfield-Skeleton (FastAPI + React/shadcn)
Backend: FastAPI mit /api/health + /api/models (read-only aus llama-swap
config, Logik aus v1 portiert). Frontend: Vite + React + Tailwind v4 +
shadcn-Style App-Shell mit Cmd+K, Dark-default, PWA-Manifest; Modelle-View
live aus /api/models. Deploy-Geruest (systemd-Unit :9001, build/deploy-
Skripte, NOPASSWD-sudoers, kein Klartext-Passwort).

Verifiziert: Backend-Endpunkte + Frontend-Build + gerenderte Shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:17:45 +02:00
206 changed files with 9781 additions and 797 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [
"-m",
"uvicorn",
"app:app",
"--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend",
"--port",
"9000"
],
"port": 9000
}
]
}
+12
View File
@@ -0,0 +1,12 @@
# Python
backend/.venv/
__pycache__/
*.pyc
# Node / Vite
frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Env / local
*.env
.DS_Store
+63
View File
@@ -0,0 +1,63 @@
# Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5. Greenfield-Neuaufbau —
siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
**Schichten:** Engine (llama-swap) · Routing-Gateway (LiteLLM, `model: auto`) ·
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phasen 05 ✅ · MC2 **live auf der Box** (:9001) · Modelle/Hermes-Wiring + Cutover offen
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
- **Phase 0** — FastAPI-Skeleton + React/shadcn-Shell (Cmd+K, Dark, PWA).
- **Phase 1** — Compute-Module (fit/caps/sources, portiert), **Discover** (live HF + Fit + Caps),
**Engine-Write** (register + `groups`/Ko-Residenz), **LiteLLM-Gateway** (Config + Service,
`model: auto` + Fallbacks), Frontend **Modelle & Routing** (Caps-Chips, Fit, Discover, Routing-View).
- **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei),
**Connect** (saubere IDE-Snippets → Gateway `model:auto`, LAN-IP-Override).
- **Phase 3** — Geteiltes **Gedächtnis** (SQLite/WAL, 5 Kategorien, Dedupe-Kurator) + **MCP-Server**
(`mcp/mcp_memory.py` shared, `mcp/mcp_mc.py` Stack-Management für Hermes), MemoryView.
- **Phase 4** — Hermes-**Agent-Status** (`/api/agent/status`, AgentView mit Tiles + „Hermes öffnen"),
`deploy/hermes-webui.service`, **Box-Runbook** [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md)
(hermes-webui, Brain=`auto`, Tools/MCP-Verdrahtung). Box-Ausführung steht noch aus.
- **Phase 5** — **Backup** (Memory + Configs), **Services-Health** + Observability-Links,
**Theme-Toggle** (Hell/Dunkel). Box-Deploy/-Wiring + Cutover (Phase 6) brauchen die Box.
API: `health · models · discover · fit · models/register · groups · routing · system/* · connect ·
memory/* · agent/status` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`).
Box-Runbooks: `docs/HERMES_SETUP.md` + `deploy/` (Units, deploy.sh, backup.sh).
## Entwickeln
**Backend:**
```bash
cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m uvicorn app:app --port 9000
```
**Frontend (Dev, proxyt /api → :9000):**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
## Env-Vars (Auswahl)
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_GATEWAY_URL` | `http://127.0.0.1:4000` | LiteLLM-Gateway |
| `MC_PORT` | `9000` | MC-Backend-Port |
+63
View File
@@ -0,0 +1,63 @@
"""
Mission Control 2.0 — dünner FastAPI-Einstieg.
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
Server (proxyt /api hierher), daher CORS für localhost offen.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system
app = FastAPI(title="Mission Control 2.0", version=VERSION)
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def no_cache(request: Request, call_next):
resp = await call_next(request)
if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache"
return resp
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
app.include_router(system.router)
app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router)
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router)
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
@app.get("/{full_path:path}")
def spa(full_path: str):
# Falls die Datei direkt in FRONTEND_DIST liegt (z.B. manifest.webmanifest, favicon.ico), liefere sie aus
target = FRONTEND_DIST / full_path
if target.is_file():
return FileResponse(target)
index = FRONTEND_DIST / "index.html"
if index.exists():
return FileResponse(index)
return {"detail": "frontend not built"}
+59
View File
@@ -0,0 +1,59 @@
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa 1 --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (LiteLLM, model: auto) ----------------------------------
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
GATEWAY_CONFIG_PATH = Path(os.environ.get(
"MC_GATEWAY_CONFIG", str(Path(__file__).resolve().parent.parent / "gateway" / "config.yaml")))
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + standalone Web-UI (nesquena/hermes-webui).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
HERMES_WEBUI_URL = os.environ.get("HERMES_WEBUI_URL", "http://127.0.0.1:8787").rstrip("/")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115
uvicorn[standard]>=0.30
httpx>=0.27
ruamel.yaml>=0.18
psutil>=5.9
huggingface_hub>=0.27
mcp>=1.2.0
View File
+12
View File
@@ -0,0 +1,12 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter
from services.agent import agent_status
router = APIRouter(prefix="/api")
@router.get("/agent/status")
def status() -> dict:
return agent_status()
+15
View File
@@ -0,0 +1,15 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets
router = APIRouter(prefix="/api")
@router.get("/connect")
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
kwargs = {}
if mcp_path:
kwargs["mcp_script_path"] = mcp_path
return build_snippets(host=host, **kwargs)
+62
View File
@@ -0,0 +1,62 @@
"""
Eingebauter Routing-Gateway (OpenAI-kompatibel) — EIN Endpunkt für Hermes + IDEs.
`model: auto` → Komplexitäts-Routing fast↔heavy; jeder andere Name geht als
llama-swap-Alias durch (das lädt das Modell bei Bedarf). Streaming wird
durchgereicht. Ersetzt LiteLLM (das auf Python 3.14 nicht baut) — gleicher
Vertrag, später austauschbar.
"""
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.router_logic import FAST, FAST_NO_THINK, choose_model
router = APIRouter(prefix="/v1")
@router.get("/models")
async def models():
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/v1/models")
return JSONResponse(r.json(), status_code=r.status_code)
async def _proxy(path: str, request: Request):
body = await request.json()
requested = str(body.get("model") or "auto")
if requested == "auto":
alias, reason = choose_model(body)
body["model"] = alias
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason}
else:
alias = requested
routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
if FAST_NO_THINK and alias == FAST and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
url = f"{LLAMA_SWAP_URL}{path}"
if body.get("stream"):
async def gen():
async with httpx.AsyncClient(timeout=None) as c:
async with c.stream("POST", url, json=body) as r:
async for chunk in r.aiter_raw():
yield chunk
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
async with httpx.AsyncClient(timeout=600) as c:
r = await c.post(url, json=body)
return JSONResponse(r.json(), status_code=r.status_code, headers=routed)
@router.post("/chat/completions")
async def chat_completions(request: Request):
return await _proxy("/v1/chat/completions", request)
@router.post("/completions")
async def completions(request: Request):
return await _proxy("/v1/completions", request)
+18
View File
@@ -0,0 +1,18 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
from fastapi import APIRouter
from config import VERSION
from services import gateway, llamaswap
router = APIRouter(prefix="/api")
@router.get("/health")
def health() -> dict:
return {
"status": "ok",
"version": VERSION,
"engine_reachable": llamaswap.engine_reachable(),
"gateway_reachable": gateway.gateway_reachable(),
}
+45
View File
@@ -0,0 +1,45 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import maintenance
router = APIRouter(prefix="/api")
@router.get("/maintenance/updates")
def updates() -> dict:
return maintenance.updates()
@router.post("/maintenance/os-update")
def os_update() -> dict:
return {"job_id": maintenance.os_update_job()}
@router.post("/maintenance/engine-update")
def engine_update() -> dict:
job = maintenance.engine_update_job()
if not job:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return {"job_id": job}
@router.post("/maintenance/reboot")
def reboot() -> dict:
return maintenance.reboot()
class RestartReq(BaseModel):
service: str
@router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service)
@router.get("/maintenance/logs")
def logs(service: str, lines: int = 200) -> dict:
return maintenance.logs(service, lines)
+61
View File
@@ -0,0 +1,61 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "stable"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class DedupeIn(BaseModel):
apply: bool = False
threshold: float = 0.85
@router.get("/memory/export")
def export() -> dict:
return memory.export_text()
@router.post("/memory/dedupe")
def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold)
@router.get("/memory")
def list_mem(q: str = "", category: str = "") -> list[dict]:
return memory.list_memories(q=q, category=category)
@router.post("/memory", status_code=201)
def add(body: MemIn) -> dict:
if body.category not in memory.CATEGORIES:
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
return memory.add_memory(body.content, body.category, body.source)
@router.put("/memory/{mid}")
def update(mid: str, body: MemUp) -> dict:
res = memory.update_memory(mid, content=body.content, category=body.category)
if not res:
raise HTTPException(404, "Eintrag nicht gefunden")
return res
@router.delete("/memory/{mid}")
def delete(mid: str) -> dict:
if not memory.delete_memory(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}
+186
View File
@@ -0,0 +1,186 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
import psutil
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
router = APIRouter(prefix="/api")
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
@router.get("/models")
def models() -> dict:
items = llamaswap.list_models()
return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
@router.get("/discover")
def discover_models(force: bool = False) -> dict:
ram = _ram_gb()
data = discover.refresh_discover(ram) if force else discover.safe_discover(ram)
if not data:
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut.")
return {**data, "sys_ram_gb": round(ram, 1)}
@router.get("/fit")
def fit(params_b: float, quant: str = "Q4_K_M", ctx: int = 8192, name: str = "") -> dict:
ram = _ram_gb()
return {
"fit": evaluate_fit(params_b, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(params_b, quant, ram),
"sys_ram_gb": round(ram, 1),
}
class RegisterReq(BaseModel):
model_path: str
role: str | None = None
ctx: int = 8192
ttl: int | None = None
mmproj_path: str | None = None
jinja: bool = False
@router.post("/models/register")
def register(req: RegisterReq) -> dict:
try:
model_id = llamaswap.register_model(
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
mmproj_path=req.mmproj_path, jinja=req.jinja,
)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True, "model_id": model_id}
class InstallReq(BaseModel):
repo: str
role: str | None = None
quant: str = "Q4_K_M"
ctx: int | None = None
jinja: bool = False
hf_token: str | None = None
@router.get("/hf/search")
def hf_search(q: str) -> dict:
return {"results": hf.search(q)}
@router.get("/hf/quants")
def hf_quants(repo: str) -> dict:
repo = hf.normalize_repo(repo)
return {"repo": repo, "quants": hf.list_quants(repo)}
@router.post("/models/install")
def install(req: InstallReq) -> dict:
"""Lädt ein Modell von HuggingFace (Hintergrund-Job) UND trägt es sofort in
llama-swap ein (cmd + Rolle-Alias). llama-swap (-watch-config) lädt es, sobald
die Datei da ist. Split-GGUFs werden komplett geladen, registriert wird der
erste Teil (-00001-of-…). Akzeptiert volle HF-URL ODER org/repo."""
repo = hf.normalize_repo(req.repo)
info = hf.resolve_gguf(repo, req.quant)
if not info["first"]:
raise HTTPException(404, f"Keine GGUF-Datei für Quant '{req.quant}' in {repo} gefunden.")
subdir = repo.split("/")[-1]
target = MODELS_DIR / subdir
target.mkdir(parents=True, exist_ok=True)
model_path = str(target / info["first"])
mmproj_path = str(target / info["mmproj"]) if info["mmproj"] else None
ctx = req.ctx
if ctx is None:
ram = _ram_gb()
ctx = max_ctx_for(extract_params_b(repo), req.quant, ram)
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
try:
model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja)
except PermissionError as exc:
raise HTTPException(500, str(exc))
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo]
for f in info["files"]:
args.append(f)
if info["mmproj"]:
args.append(info["mmproj"])
args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])}
@router.get("/jobs")
def jobs() -> dict:
return {"jobs": jobengine.public_jobs()}
@router.post("/jobs/{job_id}/cancel")
def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)}
class RoleReq(BaseModel):
role: str | None = None
@router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict:
if not llamaswap.set_role(model_id, body.role):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
class CtxReq(BaseModel):
ctx: int
@router.post("/models/{model_id}/ctx")
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
if not llamaswap.set_ctx(model_id, body.ctx):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.delete("/models/{model_id}")
def delete(model_id: str) -> dict:
if not llamaswap.delete_model(model_id):
raise HTTPException(404, "Modell nicht gefunden")
return {"ok": True}
@router.get("/groups")
def groups() -> dict:
return {"groups": llamaswap.list_groups()}
class GroupReq(BaseModel):
group: str
members: list[str]
swap: bool = False
persist: bool = False
@router.put("/groups")
def set_group(req: GroupReq) -> dict:
try:
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True}
+12
View File
@@ -0,0 +1,12 @@
"""Routing-Endpoint: zeigt den eingebauten Gateway (model:auto fast↔heavy)."""
from fastapi import APIRouter
from services import gateway
router = APIRouter(prefix="/api")
@router.get("/routing")
def routing() -> dict:
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
+90
View File
@@ -0,0 +1,90 @@
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
zurückgegeben statt zu crashen.
"""
import os
import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import GATEWAY_URL, HERMES_API_URL, HERMES_WEBUI_URL, LLAMA_SWAP_URL
from services import backup as backup_svc
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable
from services.system import system_status
router = APIRouter(prefix="/api")
# Nur diese User-Dienste dürfen neugestartet werden.
ALLOWED_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-webui"}
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@router.get("/system/status")
def status() -> dict:
return system_status()
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
return {
"services": [
{"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-WebUI", "url": HERMES_WEBUI_URL, "ok": a["webui_reachable"]},
],
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
"hermes_webui": HERMES_WEBUI_URL,
},
}
@router.post("/system/backup")
def backup() -> dict:
return backup_svc.backup_now()
@router.get("/system/backups")
def backups() -> dict:
return {"backups": backup_svc.list_backups()}
def _run(cmd: list[str], cwd: str | None = None) -> dict:
try:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
class RestartReq(BaseModel):
service: str
@router.post("/system/restart")
def restart(req: RestartReq) -> dict:
if req.service not in ALLOWED_SERVICES:
raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.")
return _run(["systemctl", "--user", "restart", req.service])
@router.post("/system/self-update")
def self_update() -> dict:
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
return {"pull": pull, "reset": reset, "restart": restart_res}
View File
+48
View File
@@ -0,0 +1,48 @@
"""
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
"""
import httpx
from config import HERMES_API_URL, HERMES_HOME, HERMES_WEBUI_URL, yaml
def _reach(url: str, path: str = "") -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{url}{path}").status_code < 500
except Exception:
return False
def agent_status() -> dict:
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
home = HERMES_HOME
brain_model = "auto"
config_path = home / "config.yaml"
if config_path.exists():
try:
from ruamel.yaml import YAML
r_yaml = YAML()
with config_path.open("r", encoding="utf-8") as f:
cfg = r_yaml.load(f) or {}
if isinstance(cfg, dict):
brain_model = cfg.get("model", {}).get("model", "auto")
except Exception:
pass
return {
"gateway_url": HERMES_API_URL,
"webui_url": HERMES_WEBUI_URL,
"gateway_reachable": _reach(HERMES_API_URL, "/v1/models"),
"webui_reachable": _reach(HERMES_WEBUI_URL),
"home_exists": home.exists(),
"brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
"has_skills": (home / "skills").exists(),
"has_memories": (home / "memories").exists(),
}
+51
View File
@@ -0,0 +1,51 @@
"""
Backup der „Verfassung" (Shared-Memory-SQLite) + aller Configs.
Snapshot in einen Zeitstempel-Ordner; die letzten N bleiben erhalten.
"""
import shutil
import time
from pathlib import Path
from config import CONFIG_PATH, GATEWAY_CONFIG_PATH, MEMORY_DB, MODELS_DIR
BACKUP_DIR = Path(MODELS_DIR) / "mc2-backups"
RETAIN = 7
def _safe_copy(src: Path, dst_dir: Path) -> str | None:
try:
if src and src.exists():
shutil.copy2(src, dst_dir / src.name)
return src.name
except Exception:
pass
return None
def backup_now() -> dict:
"""Erstellt einen Snapshot (Memory-DB inkl. WAL/SHM + Configs). Alte Snapshots
über RETAIN hinaus werden entfernt."""
ts = time.strftime("%Y%m%d-%H%M%S")
dst = BACKUP_DIR / ts
dst.mkdir(parents=True, exist_ok=True)
saved = []
for src in (MEMORY_DB, Path(str(MEMORY_DB) + "-wal"), Path(str(MEMORY_DB) + "-shm"),
CONFIG_PATH, GATEWAY_CONFIG_PATH):
if (name := _safe_copy(src, dst)):
saved.append(name)
# Aufräumen: nur die letzten RETAIN Snapshots behalten.
snaps = sorted([p for p in BACKUP_DIR.iterdir() if p.is_dir()], reverse=True)
for old in snaps[RETAIN:]:
shutil.rmtree(old, ignore_errors=True)
return {"ok": bool(saved), "snapshot": ts, "files": saved, "dir": str(dst)}
def list_backups() -> list[dict]:
if not BACKUP_DIR.exists():
return []
out = []
for p in sorted([d for d in BACKUP_DIR.iterdir() if d.is_dir()], reverse=True):
files = [f.name for f in p.iterdir() if f.is_file()]
out.append({"snapshot": p.name, "files": files})
return out
+131
View File
@@ -0,0 +1,131 @@
"""
Modell-Capabilities — EINE Quelle der Wahrheit für Modell-Eigenschaften
(MoE / Tools / Vision / Coder / Reasoning / Embedding / Kontext).
Portiert aus Mission Control v1 (model_caps.py).
Quellen, geschichtet: GGUF-Header (offline, authoritativ) → cmd-Flags
(--jinja/--mmproj) → HF-Block (tags + chat_template) → Familien-Fallback.
Tool-Fähigkeit dreistufig: yes (bestätigt) | likely (Familie) | no.
"""
import re
import struct
from services.fit import extract_active_params_b, extract_params_b
_GGUF_FIXED = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8}
def _read_gguf_meta(path: str) -> dict:
"""Liest nur den GGUF-Metadaten-Header (architecture/context_length/expert_count/
parameter_count). Bricht vor dem Tokenizer-Array ab → schnell, lädt NICHT das Modell."""
out: dict = {}
try:
with open(path, "rb") as f:
if f.read(4) != b"GGUF":
return {}
struct.unpack("<I", f.read(4))[0]
f.read(8)
kv = struct.unpack("<Q", f.read(8))[0]
def ru32() -> int: return struct.unpack("<I", f.read(4))[0]
def ru64() -> int: return struct.unpack("<Q", f.read(8))[0]
def rstr() -> str: return f.read(ru64()).decode("utf-8", "replace")
def rval(t: int):
if t == 8: return rstr()
if t == 0: return struct.unpack("<B", f.read(1))[0]
if t == 1: return struct.unpack("<b", f.read(1))[0]
if t == 2: return struct.unpack("<H", f.read(2))[0]
if t == 3: return struct.unpack("<h", f.read(2))[0]
if t == 4: return struct.unpack("<I", f.read(4))[0]
if t == 5: return struct.unpack("<i", f.read(4))[0]
if t == 6: return struct.unpack("<f", f.read(4))[0]
if t == 7: return f.read(1) != b"\x00"
if t == 10: return struct.unpack("<Q", f.read(8))[0]
if t == 11: return struct.unpack("<q", f.read(8))[0]
if t == 12: return struct.unpack("<d", f.read(8))[0]
if t == 9:
et = ru32(); cnt = ru64()
if et == 8:
for _ in range(cnt):
f.seek(ru64(), 1)
elif et == 9:
for _ in range(cnt):
rval(9)
else:
f.seek(cnt * _GGUF_FIXED.get(et, 0), 1)
return None
raise ValueError(f"unbekannter GGUF-Typ {t}")
want = {"architecture", "context_length", "expert_count", "parameter_count"}
for _ in range(kv):
key = rstr()
t = ru32()
if key == "tokenizer.ggml.tokens":
break
v = rval(t)
short = key.split(".")[-1]
if short in want and short not in out:
out[short] = v
except Exception:
return out
return out
_TOOL_FAMILIES = (
"qwen2.5", "qwen3", "qwen2", "hermes", "mistral", "mixtral", "devstral",
"command-r", "command_r", "llama-3.1", "llama3.1", "llama-3.3", "llama-4", "llama4",
"functionary", "watt", "firefunction", "granite", "glm-4", "glm-5", "ministral",
)
_REASON_KW = (
"-r1", "deepseek-r1", "qwq", "magistral", "-think", "thinking", "-o1",
"gpt-oss", "reasoning", "exaone-deep", "phi-4-reasoning", "phi-4-mini-reasoning",
)
_CODE_KW = ("coder", "-code", "code-", "codestral", "starcoder", "deepseek-coder")
_VISION_KW = ("-vl", "vision", "llava", "pixtral", "multimodal", "-mm-", "qwen3vl", "qwen2-vl")
_EMBED_KW = ("bge", "e5-", "gte-", "nomic-embed", "embed")
_MOE_ARCH = ("moe", "mixtral", "deepseek2", "deepseek3", "llama4", "qwen3moe", "grok")
def capabilities(name: str = "", cmd: str = "", gguf_path: str = "", hf: dict | None = None) -> dict:
"""Capability-Tag-Set für ein Modell. Alle Quellen optional — nutzt, was da ist."""
low = (name or "").lower()
cmdl = (cmd or "").lower()
hf = hf or {}
meta = _read_gguf_meta(gguf_path) if gguf_path else {}
arch = str(meta.get("architecture") or hf.get("architecture") or "").lower()
tags = [str(t).lower() for t in (hf.get("tags") or [])]
chat_tpl = str(hf.get("chat_template") or "")
expert_count = int(meta.get("expert_count") or 0)
moe = (
expert_count > 1
or any(a in arch for a in _MOE_ARCH)
or bool(re.search(r"\d+x\d+\.?\d*b", low))
or bool(re.search(r"a\d+\.?\d*b", low))
)
active_b = extract_active_params_b(name)
pcount = int(meta.get("parameter_count") or 0)
params_b = round(pcount / 1e9, 1) if pcount else extract_params_b(name)
ctx = meta.get("context_length")
if not ctx:
m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl)
ctx = int(m.group(1)) if m else None
tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl
tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags
tools = "yes" if tool_confirmed else ("likely" if tool_family else "no")
vision = "--mmproj" in cmdl or "vl" in arch or "clip" in arch or any(k in low for k in _VISION_KW)
coder = any(k in low for k in _CODE_KW)
reasoning = any(k in low for k in _REASON_KW) or "reasoning" in tags
embedding = "bert" in arch or any(k in low for k in _EMBED_KW)
return {
"moe": moe, "active_b": active_b, "tools": tools, "vision": vision,
"coder": coder, "reasoning": reasoning, "embedding": embedding,
"ctx": ctx, "params_b": params_b or None, "arch": arch or None,
}
+105
View File
@@ -0,0 +1,105 @@
"""
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(`model: auto`, LiteLLM :4000) + den **Shared-Memory-MCP** (MC :9000).
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
"""
import json
from config import PORT
DEFAULT_HOST = "192.168.178.151"
# Modelle, die der Gateway anbietet (model:auto = Standard).
GATEWAY_MODELS = ["auto", "fast", "heavy", "coder", "vision"]
def _gw(host: str) -> str:
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
return f"http://{host}:{PORT}/v1"
def build_snippets(host: str = DEFAULT_HOST,
mcp_script_path: str = r"C:\\Users\\TobisPC\\mission-control-v2\\mcp\\mcp_memory.py",
mcp_python: str = "python") -> dict:
gw = _gw(host)
mc_url = f"http://{host}:{PORT}"
cline = json.dumps({
"apiProvider": "openai",
"openAiBaseUrl": gw,
"openAiApiKey": "local",
"openAiModelId": "auto",
}, indent=2)
opencode = json.dumps({
"providers": {
"litellm": {
"npm": "@ai-sdk/openai-compatible",
"name": "Bosgame Gateway",
"options": {"baseURL": gw, "apiKey": "local"},
"models": {m: {"name": m} for m in GATEWAY_MODELS},
}
}
}, indent=2)
zed = json.dumps({
"language_models": {
"openai_compatible": {
"bosgame": {
"api_url": gw,
"available_models": [
{"name": m, "display_name": m, "max_tokens": 131072,
"capabilities": {"tools": True}} for m in GATEWAY_MODELS
],
}
}
}
}, indent=2)
cont = json.dumps({
"models": [
{"title": f"Bosgame / {m}", "provider": "openai", "model": m,
"apiBase": gw, "apiKey": "local"} for m in ("auto", "coder", "heavy")
]
}, indent=2)
# Claude Code spricht Anthropic-Format; der eingebaute Gateway ist OpenAI-kompatibel.
claude_code = (
f"# Der eingebaute Gateway ist OpenAI-kompatibel ({gw}, model: auto).\n"
f"# Claude Code nutzt das Anthropic-Format — dafür braucht es einen Anthropic-Shim\n"
f"# (z.B. LiteLLM /v1/messages) als Aufsatz. Für lokale Modelle direkt: Cline / OpenCode /\n"
f"# Continue / Zed nutzen (oben), die sprechen OpenAI-kompatibel mit diesem Gateway."
)
memory_mcp = json.dumps({
"mcpServers": {
"mission-control-memory": {
"command": mcp_python,
"args": [mcp_script_path],
"env": {"MC_URL": mc_url},
}
}
}, indent=2)
return {
"host": host,
"gateway_url": gw,
"tools": {
"cline": {"label": "Cline (VS Code)", "lang": "json", "snippet": cline,
"note": "OpenAI-Provider → Gateway. Modell 'auto' (schnell, eskaliert bei Bedarf)."},
"opencode": {"label": "OpenCode", "lang": "jsonc", "snippet": opencode,
"note": "Datei opencode.jsonc, Key 'providers'."},
"zed": {"label": "Zed", "lang": "json", "snippet": zed,
"note": "settings.json → language_models.openai_compatible."},
"continue": {"label": "Continue", "lang": "json", "snippet": cont,
"note": "~/.continue/config.json."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Anthropic-Format über LiteLLM /v1/messages."},
"memory_mcp": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
"note": "Für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
},
}
+133
View File
@@ -0,0 +1,133 @@
"""
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import os
import time
import httpx
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout"
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception:
return []
def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle:
bestes Fit-Level zuerst (perfect < marginal), bei Gleichstand meistgeladen.
Zu große Modelle (too_tight) fliegen raus."""
return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0)),
)
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0:
raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
tags = [str(t) for t in (m.get("tags") or [])]
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"caps": capabilities(name=rid, hf={"tags": tags}),
})
cats = []
for c in CATEGORIES:
items = by_cat[c["role"]]
ranked = rank_runnable(items)
# Top 4 je Kategorie (für die Anzeige) — gerankt, dann nach Downloads aufgefüllt.
items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True)
top = items[:4]
if top:
cats.append({
"role": c["role"], "title": c["title"], "icon": c["icon"],
"models": top,
"recommended": ranked[0]["repo"] if ranked else None,
})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception:
pass # Cache ist nur Beschleunigung
return data
def load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return None
def safe_discover(ram_gb: float) -> dict | None:
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
cached = load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached
try:
return refresh_discover(ram_gb)
except Exception:
return cached
+92
View File
@@ -0,0 +1,92 @@
"""
Hardware-Fit-Mathe (VRAM/RAM, tps-Schätzung) für APUs mit Unified Memory
(Bosgame M5 / Strix Halo). Portiert aus Mission Control v1 (hw_math.py).
"""
import re
# Bytes pro Parameter je GGUF-Quant (Annahme).
QUANT_BYTES_PER_PARAM = {
"Q2_K": 0.35, "Q3_K_S": 0.38, "Q3_K_M": 0.42, "Q3_K_L": 0.45,
"Q4_0": 0.50, "Q4_1": 0.55, "Q4_K_S": 0.50, "Q4_K_M": 0.55,
"Q5_0": 0.62, "Q5_1": 0.68, "Q5_K_S": 0.62, "Q5_K_M": 0.65,
"Q6_K": 0.75, "Q8_0": 1.00, "F16": 2.00, "BF16": 2.00,
}
def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
"""Geschätzter Speicherbedarf in GB (Gewichte + Kontext-KV)."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
context_vram = (ctx / 8192) * (max(params_b, 7) / 7) * 0.8
return weights + context_vram
def extract_active_params_b(name: str) -> float | None:
"""Aktive Parameter bei MoE ('30B-A3B' → 3.0). None bei Dense."""
m = re.search(r"(?<![a-zA-Z])a(\d+(?:\.\d+)?)b\b", name.lower())
return float(m.group(1)) if m else None
def estimate_speed(req_gb: float, sys_ram_gb: float, moe_active_ratio: float = 1.0) -> float:
"""Geschätzte t/s anhand der ~273 GB/s Bandbreite der APU.
moe_active_ratio = aktive/gesamt Params; < 1 bei MoE."""
bw = 273 if sys_ram_gb > 8 else 70
if req_gb <= 0:
return 0.0
raw_tps = (bw / req_gb) * 0.55
if moe_active_ratio < 0.8:
raw_tps *= (1.0 / moe_active_ratio) ** 0.5
return raw_tps
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float, name: str = "") -> dict:
"""Fit für ein Shared-Memory-System (APU). name → MoE-Erkennung (optional)."""
req_gb = estimate_memory_gb(params_b, quant, ctx)
active_b = extract_active_params_b(name) if name else None
moe_ratio = (active_b / params_b) if (active_b and params_b > 0) else 1.0
tps = estimate_speed(req_gb, sys_ram_gb, moe_ratio)
usable_ram = max(sys_ram_gb - 4.0, 0)
if req_gb > usable_ram:
fit_level, text = "too_tight", "Zu groß (OOM)"
elif req_gb > usable_ram * 0.8:
fit_level, text = "marginal", "Könnte knapp werden"
else:
fit_level, text = "perfect", "Passt perfekt"
return {"level": fit_level, "text": text, "req_gb": round(req_gb, 1), "tps": round(tps, 0)}
def extract_params_b(name: str) -> float:
"""Parametergröße (Mrd.) aus Repo-/Dateiname. 8x7B (MoE) → 56."""
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", name)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", name)
return float(m.group(1)) if m else 7.0
_NICE_CTX = [2048, 4096, 8192, 16384, 32768, 49152, 65536, 98304, 131072]
def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
"""Größter 'schöner' Kontext, der komfortabel passt (80 % des nutzbaren RAM)."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
usable = max(sys_ram_gb - 4.0, 0) * 0.8
ctx_budget = usable - weights
if ctx_budget <= 0:
return 2048
per_8k = (max(params_b, 7) / 7) * 0.8
raw_ctx = (ctx_budget / per_8k) * 8192
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
+30
View File
@@ -0,0 +1,30 @@
"""
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
"""
from config import PORT
from services.llamaswap import engine_reachable
from services.router_logic import FAST, HEAVY, HEAVY_CHARS
def routing_summary() -> dict:
return {
"mode": "builtin",
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
"routes": [
{"name": "auto", "target": f"{FAST}{HEAVY} (nach Komplexität)"},
{"name": FAST, "target": "llama-swap-Alias 'fast'"},
{"name": HEAVY, "target": "llama-swap-Alias 'heavy'"},
{"name": "<alias>", "target": "llama-swap-Passthrough (lädt bei Bedarf)"},
],
"heavy_threshold_chars": HEAVY_CHARS,
"fallbacks": [],
"context_window_fallbacks": [],
}
def gateway_reachable() -> bool:
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
return engine_reachable()
+101
View File
@@ -0,0 +1,101 @@
"""HuggingFace-Helfer: GGUF-Dateien eines Repos auflösen (inkl. Split-Teile) + Größen,
freie Suche, Repo-URL→ID, verfügbare Quants."""
import os
import re
import sys
import httpx
def normalize_repo(s: str) -> str:
"""Akzeptiert volle HF-URL oder `org/repo` → liefert immer `org/repo`."""
s = (s or "").strip()
m = re.search(r"huggingface\.co/([^/\s]+/[^/\s?#]+)", s)
if m:
return m.group(1)
return s.strip("/")
def list_quants(repo: str) -> list[str]:
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
quants: set[str] = set()
for e in _tree(repo):
p = str(e.get("path", ""))
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
if m:
quants.add(m.group(1).upper())
# gängige Reihenfolge zuerst
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
return sorted(quants, key=lambda q: (order.get(q, 99), q))
def search(q: str, limit: int = 20) -> list[dict]:
"""Freie HF-Suche nach GGUF-Repos."""
url = (f"https://huggingface.co/api/models?search={q}"
f"&filter=gguf&sort=downloads&direction=-1&limit={limit}")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
except Exception:
return []
out = []
for m in (data if isinstance(data, list) else []):
rid = m.get("id")
if rid:
out.append({"repo": rid, "downloads": int(m.get("downloads") or 0),
"likes": int(m.get("likes") or 0)})
return out
def hf_bin() -> str:
"""Pfad zur `hf`-CLI (bevorzugt neben dem laufenden Python im venv)."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
return cand if os.path.exists(cand) else "hf"
def _tree(repo: str) -> list[dict]:
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=true"
with httpx.Client(timeout=20.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
def _size(entry: dict) -> int:
return int(entry.get("size") or (entry.get("lfs") or {}).get("size") or 0)
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
"""Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
"""
tree = _tree(repo)
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
q = quant.lower()
# mmproj separat (Vision-Projektor)
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
# bevorzugt den gewünschten Quant
pref = [e for e in model if q in e["path"].lower()]
chosen = pref or model
if not chosen:
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
split = any("-of-" in e["path"].lower() for e in chosen)
if split:
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
files = [e["path"] for e in parts]
first = files[0]
total = sum(_size(e) for e in parts)
else:
# ein einzelnes File: nimm das kleinste passende (typisch genau eins)
chosen.sort(key=lambda e: _size(e))
first = chosen[0]["path"]
files = [first]
total = _size(chosen[0])
if mmproj:
total += next((_size(e) for e in ggufs if e["path"] == mmproj), 0)
return {"files": files, "first": first, "total_bytes": total, "mmproj": mmproj, "split": split}
+163
View File
@@ -0,0 +1,163 @@
"""
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
"""
import glob
import os
import shlex
import subprocess
import threading
import time
import uuid
JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {}
_LOG_CAP = 400
def _append_log(job: dict, line: str) -> None:
job["log"].append(line)
if len(job["log"]) > _LOG_CAP:
del job["log"][0]
def _pump_output(job: dict, stream) -> None:
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
buf = b""
overwrite = False
pending_cr = False
def commit():
line = buf.decode("utf-8", "replace")
if overwrite and job["log"]:
job["log"][-1] = line
else:
_append_log(job, line)
while True:
ch = stream.read(1)
if not ch:
break
if pending_cr:
pending_cr = False
if ch == b"\n":
commit(); overwrite = False; buf = b""
continue
commit(); overwrite = True; buf = b""
if ch == b"\r":
pending_cr = True
elif ch == b"\n":
commit(); overwrite = False; buf = b""
else:
buf += ch
if pending_cr:
commit(); overwrite = True; buf = b""
if buf:
commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None):
job = JOBS[job_id]
job["state"] = "running"
try:
proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0,
env={**os.environ, **(env or {})},
)
_PROCS[job_id] = proc
_pump_output(job, proc.stdout)
proc.wait()
job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed"
job["returncode"] = -1
finally:
_PROCS.pop(job_id, None)
job["finished_at"] = time.time()
cb = job.pop("_on_done", None)
if cb and job["state"] == "done":
try:
cb()
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id)
if job is not None:
job["progress"] = 0
job["total_bytes"] = total_bytes
def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
prev_t = prev_b = None
rate = 0.0
while True:
j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"):
break
try:
inc = glob.glob(pat, recursive=True)
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
if cur:
j["progress"] = min(99, int(cur * 100 / total_bytes))
j["done_bytes"] = cur
now = time.time()
if prev_t is not None and now > prev_t and cur >= prev_b:
inst = (cur - prev_b) / (now - prev_t)
rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate
if rate > 0:
j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur
except Exception: # noqa: BLE001
pass
time.sleep(1.0)
j = JOBS.get(job_id)
if j and j["state"] == "done":
j["progress"] = 100
j.pop("eta_s", None)
threading.Thread(target=_watch, daemon=True).start()
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None) -> str:
job_id = uuid.uuid4().hex[:12]
JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued",
"log": ["$ " + " ".join(shlex.quote(a) for a in args)],
"returncode": None, "started_at": time.time(), "finished_at": None,
}
if on_done:
JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
return job_id
def cancel_job(job_id: str) -> bool:
job = JOBS.get(job_id)
if not job or job["state"] in ("done", "failed", "canceled"):
return False
job["canceled"] = True
_append_log(job, "[mc] Abbruch angefordert…")
proc = _PROCS.get(job_id)
if proc is not None:
try:
proc.terminate()
except Exception: # noqa: BLE001
pass
else:
job["state"] = "canceled"
job["finished_at"] = time.time()
return True
def public_jobs() -> list[dict]:
"""Jobs ohne interne Felder (_on_done) für die API."""
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
+248
View File
@@ -0,0 +1,248 @@
"""
Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die
llama-swap-API. Portiert & erweitert aus Mission Control v1.
NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
"""
import os
import re
import httpx
from ruamel.yaml.scalarstring import LiteralScalarString
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL
# Kanonische Rollen (vereinheitlicht ggü. v1: kein manager/reviewer mehr).
ROLE_IDS = {"vision", "coder", "reasoning", "agent", "scout"}
_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE)
# --- Lesen -------------------------------------------------------------------
def read_config() -> dict:
if not CONFIG_PATH.exists():
return {"models": {}}
from ruamel.yaml import YAML
r_yaml = YAML()
r_yaml.preserve_quotes = True
with CONFIG_PATH.open("r", encoding="utf-8") as f:
data = r_yaml.load(f) or {}
if not data.get("models"):
data["models"] = {}
return data
def _parse_model(name: str, spec: dict) -> dict:
spec = spec or {}
cmd = str(spec.get("cmd", "")).strip()
ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None
path = filename = quant = ""
size_bytes = None
if (m := _PATH_RE.search(cmd)):
path = m.group(1).replace("'", "").replace('"', "")
filename = os.path.basename(path)
if os.path.exists(path):
size_bytes = os.path.getsize(path)
if (q := _QUANT_RE.search(path)):
quant = q.group(1).upper()
aliases = spec.get("aliases") or []
if isinstance(aliases, str):
aliases = [aliases]
aliases = [str(a) for a in aliases]
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
from services.caps import capabilities
return {
"name": name,
"role": role,
"aliases": aliases,
"api_ids": [name] + aliases,
"ctx": ctx,
"ttl": spec.get("ttl"),
"cmd": cmd,
"gguf_path": path,
"filename": filename,
"quant": quant,
"size_bytes": size_bytes,
"incomplete": not path,
"capabilities": capabilities(
name=filename or name, cmd=cmd,
gguf_path=(path if (path and os.path.exists(path)) else ""),
),
}
def list_models() -> list[dict]:
cfg = read_config()
return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()]
def engine_reachable() -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200
except Exception:
return False
# --- Schreiben ---------------------------------------------------------------
def model_id_from_path(model_path: str) -> str:
"""Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne
'-GGUF'. Fallback: Dateiname ohne Quant-Suffix.
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
d = os.path.basename(os.path.dirname(model_path))
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
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)
return name or "modell"
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
models = cfg.get("models") or {}
role = (role or "").strip().lower()
if role:
for mid, spec in models.items():
if mid == model_id or not isinstance(spec, dict):
continue
al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role]
if al:
spec["aliases"] = al
else:
spec.pop("aliases", None)
spec = models.get(model_id)
if isinstance(spec, dict):
if role and role != model_id.lower():
spec["aliases"] = [role]
else:
spec.pop("aliases", None)
def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
"""Vision-Modelle brauchen --mmproj <projektor> und --jinja."""
if mmproj_path:
if "--mmproj" not in cmd:
cmd += f" --mmproj {mmproj_path}"
if "--jinja" not in cmd:
cmd += " --jinja"
return cmd
def write_config(cfg: dict) -> None:
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
from ruamel.yaml import YAML
r_yaml = YAML()
r_yaml.preserve_quotes = True
with tmp.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f)
os.replace(tmp, CONFIG_PATH)
except PermissionError as exc:
raise PermissionError(
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
) from exc
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
cfg = read_config()
model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
cmd = _augment_vision(cmd, model_path, mmproj_path)
if jinja and "--jinja" not in cmd:
cmd += " --jinja"
cfg.setdefault("models", {})[model_id] = {
"cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL,
}
set_role_alias(cfg, model_id, role)
write_config(cfg)
return model_id
# --- Groups (Ko-Residenz) ----------------------------------------------------
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
Mitglieder werden nie automatisch entladen."""
cfg = read_config()
groups = cfg.setdefault("groups", {})
groups[group] = {"swap": swap, "persist": persist, "members": list(members)}
write_config(cfg)
def list_groups() -> dict:
return read_config().get("groups") or {}
def set_role(model_id: str, role: str | None) -> bool:
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
cfg = read_config()
if model_id not in (cfg.get("models") or {}):
return False
set_role_alias(cfg, model_id, role)
write_config(cfg)
return True
def set_ctx(model_id: str, ctx: int) -> bool:
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
cfg = read_config()
spec = (cfg.get("models") or {}).get(model_id)
if not spec:
return False
cmd = str(spec.get("cmd", ""))
if _CTX_RE.search(cmd):
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
else:
cmd = cmd.rstrip() + f" -c {ctx}"
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
write_config(cfg)
return True
def delete_model(model_id: str) -> bool:
"""Entfernt einen Modell-Eintrag aus der config.yaml (und aus allen Gruppen)."""
cfg = read_config()
models = cfg.get("models") or {}
if model_id not in models:
return False
del models[model_id]
for g in (cfg.get("groups") or {}).values():
if isinstance(g, dict) and model_id in (g.get("members") or []):
g["members"] = [m for m in g["members"] if m != model_id]
write_config(cfg)
return True
def get_running_models() -> list[str]:
"""Fragt den /running Endpunkt von llama-swap ab. Gibt geladene Modelle zurück."""
try:
with httpx.Client(timeout=2.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code == 200:
return r.json().get("running") or []
except Exception:
pass
return []
+127
View File
@@ -0,0 +1,127 @@
"""
Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware),
Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py).
Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig
erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job.
"""
import os
import subprocess
import time
from datetime import datetime
import httpx
import psutil
from services import discover, jobengine, llamaswap
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-dashboard", "hermes-webui"}
ENGINE_UPDATE_CMD = os.environ.get("MC_ENGINE_UPDATE_CMD", "")
ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
_engine_cache = {"ts": 0.0, "avail": False}
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _os_upgradable() -> int:
try:
out = subprocess.run(
["bash", "-c", "apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
capture_output=True, text=True, timeout=10)
return int((out.stdout or "0").strip() or 0)
except Exception:
return 0
def _engine_update_available() -> bool:
now = time.time()
if now - _engine_cache["ts"] < 3600:
return _engine_cache["avail"]
avail = False
try:
rel = httpx.get("https://api.github.com/repos/lemonade-sdk/llamacpp-rocm/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
inst = os.path.getmtime(ENGINE_PATH)
avail = pub > inst + 86400
except Exception:
avail = False
_engine_cache.update(ts=now, avail=avail)
return avail
def model_upgrades() -> list[dict]:
"""Dynamisch: je discover-Kategorie das empfohlene Modell, das NOCH NICHT installiert ist
→ Upgrade-Vorschlag für diese Rolle. Self-updating (kein Hardcode wie v1)."""
disc = discover.safe_discover(_ram_gb())
if not disc:
return []
cmds = " ".join(str(s.get("cmd", "")).lower()
for s in (llamaswap.read_config().get("models") or {}).values())
out = []
for c in disc.get("categories", []):
rec = c.get("recommended")
if not rec:
continue
base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base
if base in cmds or (stem and stem in cmds):
continue
out.append({"role": c["role"], "title": c["title"], "repo": rec})
return out
def updates() -> dict:
ups = model_upgrades()
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
"models": len(ups), "model_list": ups}
def _run(cmd: list[str]) -> dict:
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
return {"ok": False, "out": "", "err": str(exc)}
def restart_service(name: str) -> dict:
if name in SYSTEM_SERVICES:
return _run(["sudo", "-n", "systemctl", "restart", name])
if name in USER_SERVICES:
return _run(["systemctl", "--user", "restart", name])
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
def logs(service: str, lines: int = 200) -> dict:
lines = max(1, min(lines, 1000))
if service in SYSTEM_SERVICES:
cmd = ["sudo", "-n", "journalctl", "-u", service, "-n", str(lines), "--no-pager"]
elif service in USER_SERVICES:
cmd = ["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]
else:
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
r = _run(cmd)
return {"ok": r["ok"], "text": r["out"] or r["err"]}
def os_update_job() -> str:
cmd = "sudo -n apt-get update && sudo -n DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
return jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)")
def engine_update_job() -> str | None:
if not ENGINE_UPDATE_CMD:
return None
cmd = f"{ENGINE_UPDATE_CMD} && sudo -n systemctl restart llama-swap"
return jobengine.start_job(["bash", "-c", cmd], "Engine-Update (llama.cpp)")
def reboot() -> dict:
return _run(["sudo", "-n", "reboot"])
+149
View File
@@ -0,0 +1,149 @@
"""
Geteiltes Gedächtnis (die „Verfassung") — SQLite aus stdlib, WAL-Mode.
Portiert aus Mission Control v1 (routers/memory.py), DB-Logik als Service isoliert.
5 Kategorien: user · instruction · stable · versioned · ephemeral (7-Tage-TTL).
Dedupe = deterministischer Kurator (exakt/enthalten/ähnlich), KEIN LLM.
"""
import re
import sqlite3
import uuid
from datetime import datetime, timezone
from difflib import SequenceMatcher
from config import MEMORY_DB
CATEGORIES = ("user", "instruction", "stable", "versioned", "ephemeral")
_conn: sqlite3.Connection | None = None
def db() -> sqlite3.Connection:
global _conn
if _conn is None:
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True)
_conn = sqlite3.connect(str(MEMORY_DB), check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'stable',
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
_conn.execute(
"DELETE FROM memories WHERE category='ephemeral'"
" AND datetime(created_at) < datetime('now','-7 days')"
)
_conn.commit()
return _conn
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def list_memories(q: str = "", category: str = "") -> list[dict]:
sql, params, conds = "SELECT * FROM memories", [], []
if q:
conds.append("content LIKE ?"); params.append(f"%{q}%")
if category:
conds.append("category = ?"); params.append(category)
if conds:
sql += " WHERE " + " AND ".join(conds)
sql += " ORDER BY created_at DESC"
return [dict(r) for r in db().execute(sql, params).fetchall()]
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
now, mid = _now(), str(uuid.uuid4())
db().execute(
"INSERT INTO memories (id,content,category,source,created_at,updated_at) VALUES (?,?,?,?,?,?)",
(mid, content.strip(), category, source, now, now),
)
db().commit()
return {"id": mid, "content": content.strip(), "category": category,
"source": source, "created_at": now, "updated_at": now}
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
row = db().execute("SELECT * FROM memories WHERE id=?", (mid,)).fetchone()
if not row:
return None
now = _now()
new_content = content.strip() if content is not None else row["content"]
new_cat = category if category is not None else row["category"]
db().execute("UPDATE memories SET content=?,category=?,updated_at=? WHERE id=?",
(new_content, new_cat, now, mid))
db().commit()
return {"id": mid, "content": new_content, "category": new_cat,
"source": row["source"], "created_at": row["created_at"], "updated_at": now}
def delete_memory(mid: str) -> bool:
if not db().execute("SELECT id FROM memories WHERE id=?", (mid,)).fetchone():
return False
db().execute("DELETE FROM memories WHERE id=?", (mid,))
db().commit()
return True
def export_text() -> dict:
rows = db().execute("SELECT * FROM memories ORDER BY category, updated_at DESC").fetchall()
lines = ["# Mission Control — Gedächtnis\n"]
current = ""
for r in rows:
if r["category"] != current:
lines.append(f"\n## {r['category']}\n")
current = r["category"]
lines.append(f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_")
return {"text": "\n".join(lines), "count": len(rows)}
def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Deterministischer Kurator: findet Dubletten (exakt/enthalten/ähnlich) je Kategorie,
behält den vollständigsten (längsten) Eintrag. Konservativ, kein LLM."""
rows = [dict(r) for r in db().execute(
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC").fetchall()]
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a["content"])
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b["category"] != a["category"]:
continue
nb = _norm(b["content"])
if not nb:
continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
dups.append(b); used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a["content"], "category": a["category"]},
"remove": [{"id": d["id"], "content": d["content"]} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if apply:
for g in groups:
for d in g["remove"]:
db().execute("DELETE FROM memories WHERE id=?", (d["id"],)); removed += 1
if removed:
db().commit()
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
+33
View File
@@ -0,0 +1,33 @@
"""
Komplexitäts-Routing für `model: auto` (eingebauter Gateway).
Schnell im Alltag (fast), schwer bei Bedarf (heavy) — regelbasiert, sub-ms, ohne Cloud.
"""
import os
import re
FAST = os.environ.get("MC_ROUTE_FAST", "fast")
HEAVY = os.environ.get("MC_ROUTE_HEAVY", "heavy")
HEAVY_CHARS = int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000"))
# Thinking auf der fast-Spur ausschalten → flotte Alltags-Antworten (Qwen3.6 ist ein
# Reasoning-Modell). heavy behält Thinking für harte Aufgaben. Abschaltbar via Env.
FAST_NO_THINK = os.environ.get("MC_FAST_NO_THINK", "1") not in ("0", "false", "")
_HEAVY_KW = re.compile(
r"\b(beweis|prove|theorem|refactor|architect|komplex|complex|schwierig|"
r"think\s*hard|reason\s*carefully|tief\s*nachdenk|optimi[sz]e|algorithm|"
r"root\s*cause|debug|analy[sz]e\s+deeply|step[-\s]?by[-\s]?step)\b",
re.IGNORECASE,
)
def choose_model(body: dict) -> tuple[str, str]:
"""Wählt fast|heavy für eine Chat-Anfrage. Gibt (alias, begründung) zurück."""
msgs = body.get("messages") or []
text = "\n".join(str(m.get("content") or "") for m in msgs)
n = len(text)
if n > HEAVY_CHARS:
return HEAVY, f"langer Kontext ({n} > {HEAVY_CHARS} Zeichen)"
if _HEAVY_KW.search(text):
return HEAVY, "Komplexitäts-Schlüsselwort erkannt"
return FAST, "Standard"
+27
View File
@@ -0,0 +1,27 @@
"""
Vertrauenswürdige Quellen + Kategorien für die automatische Modell-Entdeckung.
Portiert aus Mission Control v1 (sources.py). Rollen sind die EINE Quelle der
Wahrheit (vereinheitlicht): vision · coder · reasoning · agent · scout.
"""
# HF-Orgs, die zuverlässig aktuelle, hochwertige GGUF-Quants veröffentlichen.
TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Priorität). Ein Modell wird der
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout".
# Die `role` ist zugleich der Alias-Vorschlag und gehört zu ROLE_IDS.
CATEGORIES = [
{"role": "vision", "title": "Bilder verstehen", "icon": "eye",
"kw": ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"]},
{"role": "coder", "title": "Coden & Programmieren", "icon": "code",
"kw": ["coder", "-code-", "code-", "codestral", "starcoder"]},
{"role": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
"kw": ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"]},
{"role": "agent", "title": "Agenten & Tool-Use", "icon": "layers",
"kw": ["hermes", "-tool", "command-r", "watt", "-fc-", "function"]},
{"role": "scout", "title": "Allrounder & Chat", "icon": "compass",
"kw": []}, # Fallback: instruct/chat-Modelle
]
# Repo-Namensteile, die bei der Entdeckung übersprungen werden (Roh-/Spezialformate).
SKIP_TOKENS = ["-base", "-bnb-", "-gptq", "-awq", "-fp8", "draft", "tokenizer"]
+81
View File
@@ -0,0 +1,81 @@
"""
System/OS-Metriken für die Box (Bosgame / Strix Halo).
CPU/RAM/Disk via psutil (plattformübergreifend). GPU-Auslastung/VRAM/Temperatur
via sysfs (amdgpu) — nur Linux; auf anderen Plattformen None (amd-smi fehlt auf
der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Bytes).
"""
import glob
import os
import psutil
from config import MODELS_DIR
def _read_int(path: str) -> int | None:
try:
with open(path) as f:
return int(f.read().strip())
except Exception:
return None
def _gpu_sysfs() -> dict | None:
"""AMD-GPU-Auslastung + Speicher via sysfs (Linux). Findet die Basis-Card
dynamisch (Strix Halo ist oft card1, nicht card0) und überspringt die
Connector-Verzeichnisse (card1-DP-1 …). Strix Halo nutzt Unified Memory →
GTT ist der eigentliche große Pool; VRAM ist nur der kleine Carve-out."""
for dev in sorted(glob.glob("/sys/class/drm/card*/device")):
card = dev.split("/")[-2] # z.B. "card1" oder "card1-DP-1"
if "-" in card: # Connector-Dir → kein GPU-Device
continue
busy = _read_int(f"{dev}/gpu_busy_percent")
if busy is None:
continue
return {
"busy_percent": busy,
"vram_used": _read_int(f"{dev}/mem_info_vram_used"),
"vram_total": _read_int(f"{dev}/mem_info_vram_total"),
"gtt_used": _read_int(f"{dev}/mem_info_gtt_used"),
"gtt_total": _read_int(f"{dev}/mem_info_gtt_total"),
}
return None
def _temps() -> dict | None:
"""CPU/GPU-Temperatur via hwmon (Linux). None bei Fehlen."""
out: dict = {}
for hw in glob.glob("/sys/class/hwmon/hwmon*"):
name = ""
try:
with open(f"{hw}/name") as f:
name = f.read().strip()
except Exception:
continue
t = _read_int(f"{hw}/temp1_input")
if t is None:
continue
c = round(t / 1000.0, 1)
if name in ("k10temp", "zenpower", "coretemp"):
out["cpu"] = c
elif name in ("amdgpu", "edge"):
out["gpu"] = c
return out or None
def system_status() -> dict:
vm = psutil.virtual_memory()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = {"total": du.total, "used": du.used, "percent": du.percent}
except Exception:
disk = None
return {
"cpu": {"percent": psutil.cpu_percent(interval=0.1), "cores": psutil.cpu_count()},
"ram": {"total": vm.total, "used": vm.used, "percent": vm.percent},
"gpu": _gpu_sysfs(),
"temp": _temps(),
"disk": disk,
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Backup der Shared-Memory-SQLite + Configs (cron-bar auf der Box).
# Behält die letzten 7 Snapshots. Identisch zur /api/system/backup-Logik.
set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
MEM_DB="${MC_MEMORY_DB:-$MODELS_DIR/mc2-memory.db}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
GW="${MC_GATEWAY_CONFIG:-/opt/mission-control-2/gateway/config.yaml}"
DEST="$MODELS_DIR/mc2-backups/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DEST"
for f in "$MEM_DB" "$MEM_DB-wal" "$MEM_DB-shm" "$LSWAP" "$GW"; do
[ -f "$f" ] && cp -p "$f" "$DEST/" || true
done
# nur die letzten 7 behalten
ls -1dt "$MODELS_DIR"/mc2-backups/*/ 2>/dev/null | tail -n +8 | xargs -r rm -rf
echo "OK — Backup unter $DEST"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Frontend bauen (vor jedem Deploy auf dem Entwickler-PC). Output → frontend/dist,
# das committet wird (kein Node-Build auf der Box).
set -euo pipefail
cd "$(dirname "$0")/../frontend"
npm ci
npm run build
echo "OK — frontend/dist gebaut."
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort.
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
#
# Erstinstallation (einmalig):
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
# bash ~/mission-control-v2/deploy/deploy.sh
set -euo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
cd "$SRC"
git fetch -q origin && git reset -q --hard origin/main
# venv + Abhängigkeiten
if [ ! -d "$SRC/backend/.venv" ]; then
python3 -m venv "$SRC/backend/.venv"
fi
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# systemd-USER-Unit installieren/aktualisieren
mkdir -p "$HOME/.config/systemd/user"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
systemctl --user restart mission-control-2
sleep 2
echo "--- Health ---"
curl -sf http://127.0.0.1:9001/api/health && echo
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)."
+27
View File
@@ -0,0 +1,27 @@
# systemd-USER-Unit für das standalone Hermes-WebUI (nesquena/hermes-webui).
# Eigene Oberfläche des Agenten (Chat/Sessions/Workspace/Cron/Skills/Memory).
# An 0.0.0.0:8787 gebunden, damit der Windows-PC im LAN direkt zugreifen kann
# → Passwort-Schutz aktivieren (HERMES_WEBUI_PASSWORD)!
# Ablage: ~/.config/systemd/user/hermes-webui.service ; dann:
# systemctl --user enable --now hermes-webui
# loginctl enable-linger hitonabi # läuft auch ohne aktive Session
[Unit]
Description=Hermes WebUI (standalone)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/hermes-webui
# Start über das mitgelieferte Control-Skript (siehe docs/HERMES_SETUP.md).
ExecStart=%h/hermes-webui/ctl.sh start --foreground
Environment=HERMES_WEBUI_HOST=0.0.0.0
Environment=HERMES_WEBUI_PORT=8787
Environment=HERMES_HOME=%h/.hermes
# Environment=HERMES_WEBUI_PASSWORD=... # in ~/.config/environment.d/ setzen, NICHT hier committen
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
+29
View File
@@ -0,0 +1,29 @@
# systemd-USER-Unit für Mission Control 2.0 (PARALLEL zu v1, Port 9001).
# Läuft sudo-frei aus dem Home-Verzeichnis (Nordstern: kein Passwort/sudo).
# Ablage: ~/.config/systemd/user/mission-control-2.service ; dann:
# systemctl --user daemon-reload
# systemctl --user enable --now mission-control-2
# loginctl enable-linger hitonabi # läuft auch ohne aktive Session
[Unit]
Description=Mission Control 2.0 (Cockpit)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001
Environment=MC_PORT=9001
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
# Geteiltes Gedächtnis = die bestehende v1-DB (Kontinuität bis/über Cutover).
Environment=MC_MEMORY_DB=/srv/models/mission-control-memory.db
Environment=MC_ENGINE_UPDATE_CMD=/usr/local/bin/update-llamacpp
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
+54
View File
@@ -0,0 +1,54 @@
# Mission Control 2.0 — Bedienung (kurz & klartext)
**Öffnen:** `http://192.168.178.151:9001` (vom Windows-PC im LAN). Dark/Light-Umschalter oben rechts,
**Cmd/Strg+K** springt zu jedem Bereich.
> Im Alltag fasst du MC kaum an: `model: auto` + Auto-Swap laden Modelle selbst. Du öffnest es, um ein
> Modell zu installieren/tauschen, die Auslastung zu prüfen, Gedächtnis zu pflegen oder ein Tool zu verbinden.
## Die 6 Bereiche
- **Modelle & Routing** — installierte Modelle (mit Fähigkeiten) + neue finden/laden.
- **Routing** — zeigt `model: auto` (schnell ↔ schwer) + Schwelle.
- **System** — Live CPU/RAM/GPU/GTT/Temp, Dienste-Status, **Backup**, Updates/Restart (Wartung).
- **Gedächtnis** — geteilte Fakten/Regeln, die ALLE Tools (Hermes, IDEs) via MCP lesen/schreiben.
- **Verbinden** — fertige Konfig-Snippets für deine IDEs (zeigen auf den Gateway).
- **Hermes** — Agent-Status + „Hermes öffnen".
## Modell installieren
**Tab „Modelle & Routing" → „Modelle finden":**
- **Kuratiert:** auf einer Empfehlungs-Karte „Installieren" klicken (⭐ = beste Wahl je Kategorie).
- **Eigenes (HuggingFace):** oben **HF-URL oder `org/repo`** einfügen → „Quants laden" → Quant wählen →
„Installieren". Oder die **Suchleiste** nutzen → Treffer anklicken → Quant → Installieren.
- Der Download läuft als Job mit **Fortschrittsbalken** oben; llama-swap pflegt das Modell automatisch ein.
## LLM tauschen (z.B. anderes „fast"-Hirn)
„Modelle & Routing" → **„Installiert"**: in der Zeile des Modells im **Rollen-Dropdown**
`fast` (bzw. `heavy`/`coder`/`vision`/`scout`) wählen → der Alias wandert auf dieses Modell.
- `model: auto` nutzt ab sofort dieses Modell als schnelles/schweres Hirn — für Hermes **und** Vibe Coding.
- **Kontext** ändern: auf die Kontext-Zahl (✎) klicken. **Entfernen:** 🗑 (GGUF-Datei bleibt erhalten).
- „Auto-Swap" = llama-swap lädt automatisch, was gerade angefragt wird; du musst nichts laden/entladen.
## IDE verbinden (Vibe Coding am eigenen PC)
„Verbinden" → Tool wählen (Cline/OpenCode/Zed/Continue) → Snippet kopieren. Zeigt auf
`http://192.168.178.151:9001/v1`, Modell **`auto`**. Memory-MCP-Snippet separat einfügen → geteiltes Gedächtnis.
## Gedächtnis pflegen
„Gedächtnis": Fakt/Regel hinzufügen (Kategorie wählen), suchen/filtern, **🧹 Aufräumen** entfernt Dubletten.
Das ist die geteilte „Verfassung" für alle Tools.
## Wartung & Backup
„System" → **Wartung & Updates**: Badge (offene OS-Pakete / Engine / Modell-Upgrades), Buttons
**OS aktualisieren · Engine aktualisieren · Engine neu starten · Reboot**, Modell-Upgrade-Vorschläge
(1-Klick), **Backup jetzt** (Gedächtnis-DB + Configs), Dienste-Health.
`Engine neu starten`, Logs & Modell-Upgrades laufen sofort (NOPASSWD vorhanden). **OS-Update + Reboot**
brauchen einmalig erweiterte sudoers — `sudo visudo`, ergänze:
```
hitonabi ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/sbin/reboot
```
(Engine-Update: `MC_ENGINE_UPDATE_CMD` in der mc2-Unit setzen — Befehl, der /opt/llamacpp aktualisiert.)
Danach ist die komplette Wartung klicki-bunti, ohne Passwort.
## Wenn etwas hakt
- Modell antwortet nicht → „System" → Dienste-Health (Engine online?) + Engine-Logs-Link (llama-swap `/ui`).
- Hermes langsam/komisch → im Hermes-WebUI **neuen Chat** starten (frische Session); Details: `docs/CUTOVER.md`.
+45
View File
@@ -0,0 +1,45 @@
# Cutover — Stand & Anleitung
## Was auf der Box LÄUFT (verifiziert)
- **MC2** auf `:9001` (sudo-freier User-Dienst, `~/mission-control-v2`). Update: `deploy/deploy.sh`.
- **Modelle/Rollen:** `fast` = Qwen3.6-35B-A3B, `heavy` = Qwen3.5-122B-A10B, `coder` = Qwen3-Coder-30B,
`vision` = Qwen3-VL-8B, `scout` = Qwen3-8B, `hermes` = Hermes-4-14B (immer warm, ttl 99999). Alle
tool-fähig (`--jinja` wo nötig). Legacy `manager`/`reviewer` entfernt.
- **Gateway (eingebaut, `:9001/v1`, OpenAI-kompatibel):** `model: auto` → kurz/Standard = `fast`,
lang/komplex = `heavy`. End-to-End verifiziert.
- **Hermes:** **eigenes festes Hirn = Hermes-4-14B** (`model.model: hermes`) + **Delegation an `heavy`**.
`model:auto` ist NUR für Vibe Coding/IDEs, nicht Hermes. MCP verdrahtet: `mission-control-memory` +
`mission-control-stack`. Verifiziert: „bist du da?" → 3s, sauber, kein Thrash.
- **Gedächtnis vereinheitlicht:** MC2 nutzt die bestehende DB (`mission-control-memory.db`) — geteilte
„Verfassung" für Cockpit, Hermes, IDEs.
- **Cockpit-Features:** HF-Link/Suche-Install (W2), Rollen/ctx/löschen-UX (W3), Wartung (W8: OS/Engine-
Update, Restart, Reboot, Logs, dyn. Modell-Upgrades), Bedien-Anleitung (BEDIENUNG.md + Hilfe-Link).
## Thrash-Fix (war der „Hermes ist dumm"-Grund)
Ursache war NICHT das Modell/die Session, sondern **kaputte/Cloud-Tools im Toolset** (browser ohne Chrome
→ Loop, vision auf Text, natives memory falsch aufgerufen). Global abgeschaltet über
`agent.disabled_toolsets` in `~/.hermes/config.yaml` (Achtung: `hermes tools disable` greift nur cli,
NICHT den api_server). Natives memory zusätzlich aus (`memory.memory_enabled:false`); geteiltes
Gedächtnis bleibt via MCP. Behaltene Tools: web/terminal/file/code_execution/skills/todo/session_search/
clarify/delegation/cronjob + 2 MCP.
## Cutover-Schritte
1. v2 läuft bereits auf `:9001` parallel — alles dort testen: `http://192.168.178.151:9001`.
2. Vibe-Coding-Tools auf den Gateway zeigen (Verbinden-Tab → `:9001/v1`, `model: auto`).
3. **v1 stilllegen** — bereits erledigt: `hermes-dashboard` (:9119) disabled (killte 4 v1-MCP-Zombies).
**Noch offen (braucht dein sudo, NOPASSWD deckt nur `restart`):**
```
sudo systemctl disable --now mission-control # v1-Cockpit :9000 aus
```
`llama-swap` (System) + `hermes-gateway` (User) bleiben — die nutzt v2 weiter.
4. Optional v2 auf den „Haupt"-Port legen — `MC_PORT` in der mc2-Unit.
5. Backup vorher: Cockpit → System → „Backup jetzt".
## Offene Tuning-/Setup-Punkte (kein Blocker)
1. **nesquena hermes-webui** (:8787) installieren (Plan Block C) → „Hermes öffnen" zeigt darauf statt :9119.
2. **Ko-Residenz** `hermes`+`fast` (swap:false, Plan Block E) — GTT beobachten, bei OOM-Nähe zurück.
3. **SSH→Windows** (voller PC-Zugriff): OpenSSH-Server am Windows-PC + Key `id_ed25519_hermes_agent`.
4. **sudoers erweitern** (`apt-get`, `reboot`) → OS-Update/Reboot klicki-bunti (Zeilen in BEDIENUNG.md).
5. **Delegation an heavy** ist konfiguriert; triggert modell-diskretionär bei echt harten Teilaufgaben.
> v1 bleibt bis zum `disable` lauffähig — Cutover ist reversibel (`sudo systemctl enable --now mission-control`).
+82
View File
@@ -0,0 +1,82 @@
# Hermes-Schicht — Box-Runbook
> Diese Schritte laufen **auf der Bosgame** (`192.168.178.151`, User `hitonabi`).
> MC betreibt Hermes nicht — es zeigt nur Status + verlinkt das WebUI. Hier wird die
> eigentliche **volle Verdrahtung** gemacht (das war in v1 der „Hermes ist dumm"-Grund).
## Reihenfolge der Dienste
`llama-swap (:8080)`**builtin Gateway (`:9001/v1`, Teil von MC2)**`hermes-gateway (:8642)`
`hermes-webui (:8787, nesquena)`
## 1. Gateway = builtin (kein LiteLLM)
LiteLLM scheitert auf Python 3.14 (uvloop/orjson). MC2 bringt einen **eingebauten** OpenAI-kompatiblen
Gateway auf `:9001/v1` mit: `model: auto` (kurz→`fast`, komplex→`heavy`) + explizite Aliase
(`fast`/`heavy`/`coder`/`vision`/`hermes`). Verifizieren:
```bash
curl -s http://127.0.0.1:9001/v1/models
```
## 2. Engine: Rollen (llama-swap)
Aliase sauber: `fast` (Qwen3.6-35B-A3B), `heavy` (Qwen3.5-122B-A10B), `coder`, `vision`, `scout`,
`hermes` (Hermes-4-14B, `ttl 99999` = immer warm). Verwaltung im Cockpit (Modelle & Routing).
**Ko-Residenz** (optional): Gruppe `swap:false` für `hermes`+`fast` → beide warm; `heavy`/`vision`
on-demand. GTT beobachten (~124 GB Limit).
## 3. hermes-webui installieren (nesquena, standalone)
```bash
cd ~ && git clone https://github.com/nesquena/hermes-webui && cd hermes-webui
python3 bootstrap.py # erkennt hermes-agent, baut venv, installiert Deps
mkdir -p ~/.config/environment.d
echo 'HERMES_WEBUI_PASSWORD=<dein-passwort>' > ~/.config/environment.d/hermes-webui.conf
# Unit deploy/hermes-webui.service → ~/.config/systemd/user/ (HOST=0.0.0.0, PORT=8787)
systemctl --user enable --now hermes-webui
loginctl enable-linger hitonabi
```
Zugriff vom Windows-PC: `http://192.168.178.151:8787` (mit Passwort). MC2-Unit
`HERMES_WEBUI_URL=http://192.168.178.151:8787` setzen → „Hermes öffnen" zeigt darauf.
**Danach das alte offizielle Dashboard stilllegen** (eine WebUI):
`systemctl --user disable --now hermes-dashboard` (:9119).
## 4. Hermes-Hirn = dediziertes Hermes-4-14B + Delegation (NICHT model:auto)
In `~/.hermes/config.yaml`:
```yaml
model:
default: Hermes-4-14B
provider: custom
base_url: http://127.0.0.1:9001/v1
api_key: local
model: hermes # Hermes' eigenes Hirn (Alias→Hermes-4-14B), NICHT 'auto'
delegation:
model: heavy # harte Teilaufgaben → Qwen3.5-122B
provider: custom
base_url: http://127.0.0.1:9001/v1
api_key: local
orchestrator_enabled: true
subagent_auto_approve: true
```
`model:auto` bleibt ausschließlich Gateway-Funktion für Vibe Coding/IDEs.
## 5. Tools/MCP verdrahten — UND kaputte Tools abschalten (Thrash-Fix!)
- **Kaputte/Cloud-Tools global abschalten** (sonst Endlos-Loops, siehe Memory `hermes-thrash-rootcause-fix`):
```yaml
agent:
disabled_toolsets: [browser, vision, computer_use, image_gen, tts, video, video_gen, memory]
memory:
memory_enabled: false
user_profile_enabled: false
```
⚠️ `hermes tools disable <x>` wirkt nur für die cli-Plattform, **nicht den api_server** — nutze
`agent.disabled_toolsets` (gilt für ALLE Plattformen).
- **Behalten:** terminal/shell, file, code_execution, skills, todo, session_search, clarify,
delegation, cronjob, web. `approvals: auto` (rein lokal).
- **MCP-Server** (`mcp_servers` in config) — laufen über die v2-venv (hat das `mcp`-Modul nach pip install):
- geteiltes Gedächtnis: `~/mission-control-v2/backend/.venv/bin/python ~/mission-control-v2/mcp/mcp_memory.py` (Env `MC_URL=http://127.0.0.1:9001`)
- Stack-Management: `~/mission-control-v2/backend/.venv/bin/python ~/mission-control-v2/mcp/mcp_mc.py` (Env `MC_URL=http://127.0.0.1:9001`)
- **SSH→Windows-PC** (voller Zugriff): OpenSSH-Server auf Windows aktiv + Key
`~/.ssh/id_ed25519_hermes_agent` autorisiert; Hermes nutzt sein terminal-Tool für `ssh TobisPC@<win-ip>`.
## 6. Verifikation
- `curl :8642/v1/chat/completions` „bist du da?" → kurze Antwort in wenigen Sekunden, **kein** Tool-Loop
im `journalctl --user -u hermes-gateway`; llama-swap `/running` zeigt `Hermes-4-14B`.
- WebUI öffnet vom Windows-PC, Chat antwortet.
- Hermes kann via `mcp_mc` Modelle listen/Routing ändern; erreicht (nach §5-SSH) den Windows-PC.
+57
View File
@@ -0,0 +1,57 @@
# Mission Control 2.0 — Status & Resume-Guide
> So machst du jederzeit nahtlos weiter. Der vollständige Architektur-Plan liegt in
> `C:\Users\TobisPC\.claude\plans\piped-cuddling-sloth.md` (genehmigt).
## Wo das Projekt lebt
- **Code:** `F:\Coding Stuff\mission-control-2` (Windows-Dev-PC) + Gitea-Remote
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2` (Branch `main`).
- **v1** (`F:\Coding Stuff\mission-control`) wird abgelöst (Cutover läuft, s.u.).
- **Box** (Bosgame, `192.168.178.151`): **MC2 LIVE auf :9001** als sudo-freier systemd-USER-Dienst
(`~/mission-control-v2`, Update via `deploy/deploy.sh`).
## Gateway = builtin (NICHT LiteLLM)
LiteLLM verlangt Python <3.14; die Box hat nur 3.14 (uvloop+orjson scheitern). Der **eingebaute
Gateway** (`:9001/v1`, OpenAI-kompatibel) liefert `model:auto` (kurz→`fast`, komplex→`heavy`) +
Streaming und ist E2E verifiziert. Vertrag (OpenAI-API) bleibt austauschbar.
## Phasen-Fortschritt
- [x] **Phase 0 — Gerüst:** FastAPI + React/shadcn-Shell (Cmd+K, Dark, PWA). Live auf Box.
- [x] **Phase 1 — Engine + Routing:** Compute (fit/caps/sources), Discover (live HF), Engine-Write
(register + groups), **builtin Gateway** `model:auto`, Modelle&Routing-UI. Box-verifiziert.
- [x] **Phase 2 — System/OS + Connect:** Metriken, Dienste, Self-Update, Connect-Snippets → Gateway.
- [x] **Phase 3 — Memory + MCP:** Memory-UI, `mcp_memory.py` + `mcp_mc.py` (Stack-Management).
- [x] **Phase 4 — Hermes-Schicht:** Agent-Status + AgentView; **Box: Hermes verdrahtet** (eigenes Hirn,
MCP memory+stack). hermes-webui (nesquena) = Block C offen.
- [x] **Phase 5 — Betrieb/Politur:** Backup, Services-Health, Observability-Links, Theme-Toggle.
- [x] **W1W8** (Audit-Arbeitspaket): Thrash-Fix, HF-Install, Rollen-UX, BEDIENUNG.md, Wartung. ✅
- [~] **Phase 6 — Cutover:** läuft (s.u.). v1-Dashboard+Zombies weg; v1 :9000 Stop offen (User-sudo).
## Hermes-Hirn (Entscheidung 2026-06-25)
Hermes hat ein **eigenes festes Hirn = Hermes-4-14B** (`model.model: hermes`, ttl 99999 = immer warm)
+ **interne Delegation an `heavy`** (Qwen3.5-122B) für harte Teilaufgaben. **`model:auto` ist NUR
Gateway/Vibe-Coding**, nicht Hermes. Verifiziert: „bist du da?" → **3s, sauber, kein Thrash**.
## Box-Stand (Session 2026-06-25)
- **8 Modelle**, Rollen sauber: `fast` (Qwen3.6-35B-A3B), `heavy` (Qwen3.5-122B-A10B), `coder`
(Qwen3-Coder-30B), `vision` (Qwen3-VL-8B), `scout` (Qwen3-8B), `hermes` (Hermes-4-14B). Legacy
`manager`/`reviewer`-Aliase entfernt (Modelle bleiben per Realname ladbar).
- **Thrash behoben** (war NICHT die Session): kaputte Tools global via `agent.disabled_toolsets`
abgeschaltet (browser/vision/computer_use/image_gen/tts/video*/memory). Details: Memory
`hermes-thrash-rootcause-fix`. **`hermes tools disable` greift NICHT am api_server** — nur die config.
- **Cutover teilweise:** `hermes-dashboard` (:9119) disabled (killte 4 v1-MCP-Zombies);
**v1 `mission-control.service` (:9000) läuft noch**`sudo systemctl disable --now mission-control`.
- MCP nutzt `/opt/mission-control/.venv/bin/python` (hat `mcp`-Modul) für v2-Skripte → /opt-Dir bleibt.
## Nächste Schritte (siehe Plan „Nächste Schritte" + docs/CUTOVER.md)
- **Block C:** nesquena hermes-webui (:8787) installieren → „Hermes öffnen" zeigt darauf.
- **Block E:** Ko-Residenz `hermes`+`fast` (swap:false) testen, GTT beobachten.
- **User-sudo:** v1 :9000 stoppen; sudoers für apt-get/reboot erweitern (OS-Update/Reboot).
- Offen/user-seitig: SSH→Windows (OpenSSH am PC), v1-Passwort-Hygiene (Git-Historie).
## Lokal entwickeln/verifizieren
```bash
cd backend && .venv/Scripts/python -m uvicorn app:app --port 9000 # Backend
cd frontend && npm run dev # http://localhost:5173
cd frontend && npm run build # Prod-Build (committet)
```
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-oOOd5nTt.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
-1
View File
@@ -1 +0,0 @@
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-oOOd5nTt.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};
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
import{c as s,j as e}from"./index-oOOd5nTt.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=s("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);function c({icon:a,children:t}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(a,{className:"h-3.5 w-3.5"})," ",t]})}export{r as H,c as S};
-6
View File
@@ -1,6 +0,0 @@
var M=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||M("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?M("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a9 as B,aa as F,ab as E,ac as I,ad as P,u as A,r as f,ae as J,af as Q,c as T,ag as U,j as s,G as D,T as H,b as K,L,M as G,q as V,n as R}from"./index-oOOd5nTt.js";import{J as _}from"./JobsBar-CetMgfaw.js";var c,g,o,h,m,w,C,q,z=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&E(t.mutationKey)!==E(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new z(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=A(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:V.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(_,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(G,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft ":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
import{c as o}from"./index-oOOd5nTt.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const n=o("ChevronDown",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]]);export{n as C};
-6
View File
@@ -1,6 +0,0 @@
import{c as e}from"./index-oOOd5nTt.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=e("CodeXml",[["path",{d:"m18 16 4-4-4-4",key:"1inbqp"}],["path",{d:"m6 8-4 4 4 4",key:"15zrgr"}],["path",{d:"m14.5 4-5 16",key:"e7oirm"}]]);export{o as C};
-6
View File
@@ -1,6 +0,0 @@
import{c as a}from"./index-oOOd5nTt.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=a("FolderOpen",[["path",{d:"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2",key:"usdka0"}]]);export{o as F};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More