Author SHA1 Message Date
HitonabiandClaude Opus 5 ba9ea7743f fix(swap-config): Repo-Abzug auf den Live-Stand gezogen (Coder-Vollkontext)
Ampel / ampel (push) Successful in 22s
Der Abzug stand noch auf --parallel 2 / context 65536, die laufende Box seit
34a9862 auf --parallel 1 / 131072 (gesetzt ueber deploy/coder-vollkontext.sh,
aber nie in den Abzug uebernommen).

Ein Deploy haette den Coder damit zurueck auf den halben Slot gesetzt und den
Fix von 34a9862 rueckgaengig gemacht - genau der Datenverlust-Pfad, den der
vorige Commit in deploy.sh abgesichert hat.

Jetzt inhaltlich deckungsgleich mit /etc/llama-swap/config.yaml; es
unterscheiden sich nur noch Kommentare.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:02:28 +02:00
HitonabiandClaude Opus 5 84dc34c0a3 fix(stack): Mem0 restlos ausgebaut + vier stille Defekte behoben
Systemaudit vom 27.08.2026. Alle Befunde gemessen, nicht vermutet.

VIER STILLE DEFEKTE

1. mc2-steward startete seit Wochen nicht (live: 207.609 Neustarts).
   steward.py importierte services.memory, das beim Mem0-Ausbau geloescht
   wurde -> ImportError bei jedem Start. Re-Warm- und Health-Waechter
   waren damit tot.

2. Jedes Hermes-Update wurde automatisch zurueckgerollt.
   hermes-postcheck.sh prueft vier Dinge, die es seit dem 07.08. nicht mehr
   gibt (Sidecar :8765, /api/memory, memory.provider, mc2-memory-Plugin).
   Die Checks konnten nicht gruen werden -> autoupdate.sh wertete jedes
   Update als rot und rollte es zurueck. Checks ersatzlos entfernt; der
   Tool-Smoke laeuft ohnehin durch den echten Agenten.

3. 7 von 12 Skills waren per Knopfdruck nicht startbar.
   deploy.sh kopiert Skills mit tr '-' '_' nach ~/.hermes/skills/,
   routers/skills.py gab Hermes aber den Ordnernamen MIT Bindestrich.
   Der Knopf meldete Erfolg, ausgefuehrt wurde nichts. Neu: _hermes_name().

4. deploy.sh warf bei jedem Deploy die Live-Modellkonfiguration weg.
   MC2 schreibt /etc/llama-swap/config.yaml selbst; die Repo-Datei ist nur
   ein Abzug (ihm fehlt u.a. kritiker/Devstral). Jetzt: erst sichern, Diff
   zeigen, dann kopieren. MC_DEPLOY_SKIP_SWAP_CONFIG=1 ueberspringt.

MEM0-AUSBAU VOLLENDET (Kriterium 3: 17 -> 0 Dateien)
- mem0_service/, mcp/mcp_memory.py und hermes/plugins/mc2-memory entfernt;
  das Plugin schickte bei JEDEM Turn zwei 404-Requests an tote Routen.
- MEMORY_DB/MEM0_SERVICE_URL, _mem0_reachable(), MC_MEMORY_DB und
  MC_MEM_DEDUPE_ENABLED aus Config/Router/Unit entfernt.
- mem0_ms war strukturell tot (park("retrieve") wird nirgends mehr
  aufgerufen) -> aus Backend, API-Typ und Latenzkarte entfernt.
- Verbinden-Tab: tote Gedaechtnis-MCP-Leitung raus, Status-Kachel bleibt.
- AGENTS.md beschrieb Mem0 noch als aktiv - korrigiert.

GATEWAY-ROBUSTHEIT
- _proxy gab bei ungueltigen Payloads HTTP 500 (gemessen 5/5: Rohtext,
  leerer Body, JSON-Liste, JSON-String, null) -> jetzt 5/5 HTTP 400.
- Bild-Weiche ohne Deckel: 10 Bilder x 2 Versuche x 240 s hielten den
  Client bis zu 80 min. Neu: MC_CODER_IMAGE_MAX (4), Rueckfall auf die
  Vision-Umleitung.
- /v1/models: nicht-JSON von der Engine gab 500 -> jetzt 502.

UNITS UND DEPLOY
- mc2-steward.service, dessen warmset-Drop-in und voice-service.service
  fehlten im Repo, obwohl maintenance.py und stack-postcheck.sh sie
  voraussetzen. 1:1 von der laufenden Box uebernommen.
- deploy.sh startete mc2-steward nie neu; restore.sh liess mc2-gateway und
  mc2-steward mit alter Config weiterlaufen. Beide ergaenzt.

FRONTEND
- useEigenleben rief /api/eigenleben - existiert im Backend nicht und wurde
  nirgends genutzt. Samt Typen entfernt.
- Anleitung beschrieb einen Gedaechtnis-Tab, den es nicht gibt.
- Abgeglichen: alle uebrigen 63 Frontend-Aufrufe treffen echte Routen, alle
  5 SSE-Invalidation-Keys sind gemappt, keine ungefangenen Promises.

Gates: compileall gruen - ruff "All checks passed" - tsc gruen - vite build
gruen (dist aktualisiert) - Importe app/steward/gateway_app gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:58:42 +02:00
HitonabiandClaude Opus 5 34a9862591 fix(kontext): Coder bekommt den ganzen Slot - 65536 auf 131072
Ampel / ampel (push) Successful in 22s
Ursache der haeufigen Komprimierung war kein zu scharfer Automatismus, sondern
ein falscher Wert von mir: opencode.json deklarierte 131072, der Slot hatte aber
nur 65536 (-c 131072 --parallel 2). Beweis im Log: finish_reason=length ->
400 Bad Request -> Wiederholung 200 OK. Diese Wiederholung war die Komprimierung.

Jetzt: coder mit --parallel 1 = volle 131072 (er hat nur einen Verbraucher;
Lucy und explore nutzen hermes, review nutzt heavy). fast behaelt seine zwei
Slots und steht ehrlich auf 65536. Am laufenden Prozess gegengeprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:44:10 +02:00
HitonabiandClaude Opus 5 f3139d2b5d docs(governor): Plugin abgeschaltet - SAVEPOINT-Hook kollidierte mit OpenChambers Komprimierung
Ampel / ampel (push) Successful in 21s
Bei jeder Komprimierung schob der Governor einen zusaetzlichen Auftrag nach
(SAVEPOINT.md schreiben); der Agent verlor dadurch einen Zug an Buchhaltung.
Nachgewiesen im Referenzlauf 22.08. 14:47. Der Abbau war schon am 21.08.
angeordnet - ich hatte das Behalten empfohlen und Zustimmung angenommen,
statt sie einzuholen.

Quelle bleibt im Repo, Wiederherstellung ist ein Copy-Item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:33:14 +02:00
HitonabiandClaude Opus 5 6e573d5551 fix(modellwahl): coder statt fast als Standard - meine Empfehlung war unbelegt
Ampel / ampel (push) Successful in 21s
Bis heute stand fast als Standard mit der Begruendung, es schlage den coder in
Tempo UND Qualitaet. Das Tempo war gemessen, die Qualitaet nie ('keine
agentische Messung', Notiz vom 20.08.). Qwens Zahlen sagen das Gegenteil:
einzelschuss gleichauf (SWE-bench 73,4), agentisch zieht Qwen3.8-27B davon
(Terminal-Bench 73,0, DeepSWE 42,2 gegen 13,3, OSWorld 84,3).

Preis gemessen: gleiche Aufgabe 24,3 s mit fast, 89,3 s mit coder.
Aufteilung jetzt: coder baut, heavy plant, das warme hermes erkundet.
Konfiguration ausserdem ins Repo geholt - sie lag nur an einer Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:41:58 +02:00
HitonabiandClaude Opus 5 2210bf5c40 feat(radar): Curator-Pruefung in den Stack-Radar gefaltet
Ampel / ampel (push) Successful in 22s
Das Repo curator-staleness-check war ein eigener Wachhund mit eigenem
systemd-Timer und eigener CI-Ampel - und ueberwachte einen Cron-Job ueber eine
fest verdrahtete ID (a47910e2ef05), den es laengst nicht mehr gibt. Er haette
bei jedem Lauf Alarm geschlagen fuer etwas, das nicht existiert.

Dieselbe Frage steht jetzt in vier Zeilen im Radar. Repo archiviert.
Falle dabei: find -printf %T@ liefert einen Float, bash bricht damit ab - %Ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:06:25 +02:00
HitonabiandClaude Opus 5 dc78114102 fix(jobs): Doppelversand behoben - Werkzeug-Timeout loeste einen zweiten Aufruf aus
Ampel / ampel (push) Successful in 23s
Erster echter Lauf am 22.08. schickte den Bericht zweimal (07:01:42 und
07:02:14). Ursache: Hermes' terminal-Werkzeug bricht nach 30 s ab, die
Sprachsynthese dauert laenger, der Agent hielt den Aufruf fuer gescheitert und
wiederholte ihn - der Text war da laengst raus.

Jetzt: Text senden, Stimme per setsid abgekoppelt, Rueckkehr nach 1,1 s.
Dazu eine Doppelversand-Sperre ueber den Text-Hash, die jede Wiederholung
innerhalb von zwei Stunden abweist - egal aus welchem Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:59:45 +02:00
HitonabiandClaude Opus 5 259e1b2ca8 docs(coding-lane): Abschluss - SSH-Zugang zu Gitea, ein Schluessel statt acht Zugangsdaten
Ampel / ampel (push) Successful in 21s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:13:02 +02:00
HitonabiandClaude Opus 5 148a039545 fix(deploy): push-und-sync auf den SSH-Weg umgestellt
Ampel / ampel (push) Successful in 22s
Gitea-Zugang laeuft seit 21.08. ueber ssh://gitea@192.168.178.153:2222.
Fehlermeldung nennt jetzt die richtige Pruefung - inklusive der Falle, dass
der SSH-Benutzer 'gitea' heisst und nicht 'git'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:12:18 +02:00
HitonabiandClaude Opus 5 25dbfb2b61 feat(stimme): Lucys echte Stimme auf der Box - pocket-tts 2.1.0 als lucy-stimme.service
Ampel / ampel (push) Successful in 21s
:8650 lieferte ElevenLabs 'Artoria/Saber' = Hermes' Stimme, nicht Lucys (und
Cloud). Jetzt :8021 mit Kyutai pocket-tts, Modell german_24l, geklont aus
ref.mp3 - samt text_norm, Emotions-Presets und Pegel-Abstimmung. Sprachnachricht
als OGG/Opus via ffmpeg. Recherche: pocket ist weiter die richtige Wahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:01:23 +02:00
HitonabiandClaude Opus 5 fa69edc1aa fix(jobs): Persona zurueck - Emojis, Quellen-Links, Commander-Anrede, Stimme in Telegram
Mein erster Prompt verbot Emojis und Deko und ueberstimmte damit SOUL.md.
Formatierung kommt aus der Persona, nicht aus dem Job. Dazu news-melden.sh:
Text via notify.sh, Stimme via voice-service :8650 (Lucys echte deutsche
Stimme, nicht Hermes' englisches edge-TTS) und hermes send MEDIA:.
SOUL.md-Verweise auf die abgeschalteten Werkzeuge kanban/delegation behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:35:02 +02:00
HitonabiandClaude Opus 5 9cf04c427e docs(hermes): Werkzeuge nach Bedarf - tool_search lief schon, Totes abgeschaltet
Gemessen mit prompt-size: cli 99,4 -> 56,5 KB (-43%), cron 63,1 -> 16,4 KB (-74%).
Groesster Fund: der Zugangsweg 'cron' hatte keinen platform_toolsets-Eintrag und
bekam die volle Werkzeugkiste, obwohl nur ein Job ueberhaupt Werkzeuge braucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:20:22 +02:00
HitonabiandClaude Opus 5 f446da8ae6 feat(jobs): KISS-Umbau der Automatik - 10 Mechanismen auf 3 Hermes-Crons
Fakten sammelt ein Skript, Prosa schreibt das Modell. stack-ist.sh prueft
Ergebnisse statt Lebenszeichen und fand beim ersten Lauf sofort zwei echte
Befunde. Abgeschaltet: 4 Crons + 4 Timer, davon einer nie gelaufen und einer
15 Naechte ohne Wirkung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:10:10 +02:00
HitonabiandClaude Opus 5 c2a54bd25d feat(governor): Plugin ins Repo geholt, git push freigegeben, force push bleibt gesperrt
Das Plugin existierte nur im Konfigordner des PCs - ohne Sicherung, ohne Historie.
Zaun-Regel 'git push' entfernt (jede Aenderung soll im git landen), dafuer
gezielte Sperre fuer --force/--mirror/--delete. Beides gemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:06:50 +02:00
HitonabiandClaude Opus 5 56668ee43d feat(coding-bahn): Quelle ist der PC - OpenCode laeuft lokal, Modell kommt ueber MC2 :9001
Kurswechsel: Projekte liegen lokal, OpenChamber arbeitet an lokalen Dateien,
push nach Gitea, Box zieht via projekte-sync nach. Kein Box-Umbau noetig -
MC2 :9001/v1 reicht die Rollen-Aliase bereits durch. Box-Server auf :4096
zurueckgebaut, ufw-Regel entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:56:28 +02:00
HitonabiandClaude Opus 5 c8e5a7162b docs(openchamber): Falle dokumentiert - Ordner-Knopf reicht Windows-Pfade an den Box-Server durch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:40:01 +02:00
HitonabiandClaude Opus 5 00b64b9fdc docs(openchamber): Aufbau erledigt - OpenCode 1.18.20 auf Box, OpenChamber 1.19.0 am PC, Fernanbindung verifiziert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:27:03 +02:00
HitonabiandClaude Opus 5 7c23ebf382 docs: Umbauplan OpenChamber - getrennte Bahnen fuer Coding und Betrieb
Ampel / ampel (push) Successful in 22s
Entscheidung 21.08.: Coding zieht auf OpenChamber (ueber OpenCode), Lucy
bleibt der Hermes-Runtime mit voller SysAdmin-Rolle. Zwei Bahnen statt
einer eierlegenden Wollmilchsau - der Weg, der hier schon einmal ging.

Ausloeser: Hermes Desktops Gateway-Registry akzeptiert kein Benutzer/
Passwort (nur Session-Token oder OAuth), darum fiel der Sessions-Reiter
immer auf das lokale Geraet zurueck.

Rueckbau ist erledigt und in dieser Datei protokolliert (PC entkernt,
Box aufgeraeumt, ufw 9119 zu, Lucy nachweislich unversehrt). Der Aufbau
steht aus und ist hier Schritt fuer Schritt beschrieben - inklusive der
Fallen aus dem Juli-Audit und der heute gemessenen Modellwahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:10:46 +02:00
79 changed files with 2246 additions and 1442 deletions
-6
View File
@@ -1,11 +1,5 @@
{ {
"mcpServers": { "mcpServers": {
"mc2-memory": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
]
},
"mc2-system": { "mc2-system": {
"command": "python", "command": "python",
"args": [ "args": [
+1 -1
View File
@@ -12,7 +12,7 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
- **System:** Bosgame M5 (AMD Strix Halo APU) - **System:** Bosgame M5 (AMD Strix Halo APU)
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar). - **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs. - **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy"). Mem0 läuft als dedizierter semantischer Langzeit-Gedächtnis-Sidecar (`mem0-service` auf `:8765`) und wird von Hermes via MCP (`mcp_memory.py`) eingebunden. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht. **Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
## Sprache (nicht verhandelbar) ## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen, - **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
+6 -9
View File
@@ -19,13 +19,9 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster. # 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_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 DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen. # Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis # dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL). # Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen. # Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server — # Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching # llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
@@ -72,7 +68,7 @@ V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/") HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für # API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent # /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP. # (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str: def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env). """Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht.""" Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
@@ -97,7 +93,8 @@ HERMES_API_KEY = (
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes") HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) --------------- # --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen. # Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
# passen. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/") VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback # Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001 # (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
+4 -7
View File
@@ -1,4 +1,4 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP).""" """Connect-Endpoint: erzeugt IDE-/Agent-Snippets für den Gateway."""
from fastapi import APIRouter from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets, check_health from services.connect import DEFAULT_HOST, build_snippets, check_health
@@ -7,14 +7,11 @@ router = APIRouter(prefix="/api")
@router.get("/connect") @router.get("/connect")
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict: def connect(host: str = DEFAULT_HOST) -> dict:
kwargs = {} return build_snippets(host=host)
if mcp_path:
kwargs["mcp_script_path"] = mcp_path
return build_snippets(host=host, **kwargs)
@router.get("/connect/health") @router.get("/connect/health")
def connect_health() -> dict: def connect_health() -> dict:
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab.""" """Live-Status der drei Leitungen (Gateway, Gedächtnis, Desktop-Gateway) für den Verbinden-Tab."""
return check_health() return check_health()
+33 -1
View File
@@ -22,6 +22,13 @@ _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Deckel für die Bild-Weiche. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
# Request. Über _BILD_MAX Bilder wird gar nicht erst beschrieben — der Aufrufer fällt
# dann auf die normale Vision-Umleitung zurück (ehrlich langsam statt scheinbar hängend).
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von # Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter. # Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
_BILD_BESCHREIB_PROMPT = os.environ.get( _BILD_BESCHREIB_PROMPT = os.environ.get(
@@ -85,6 +92,10 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
for i, part in enumerate(m["content"]): for i, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES: if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
fundstellen.append((m, i, part)) fundstellen.append((m, i, part))
if len(fundstellen) > _BILD_MAX:
log.warning("Bild-Weiche v2: %s Bilder (Deckel %s) — Request geht an das Vision-Modell "
"statt einzeln beschrieben zu werden", len(fundstellen), _BILD_MAX)
return False
for nr, (msg, idx, part) in enumerate(fundstellen, start=1): for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
frage = { frage = {
"model": vision_alias, "model": vision_alias,
@@ -99,7 +110,7 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
text = "" text = ""
for versuch in (1, 2): for versuch in (1, 2):
try: try:
async with httpx.AsyncClient(timeout=240.0) as c: async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage) r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200: if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "" text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
@@ -138,7 +149,15 @@ def _inject_language(body: dict, alias: str) -> None:
async def models(request: Request): async def models(request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
try:
data = r.json() data = r.json()
except ValueError:
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
# statt eines 500ers aus dem Parser.
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
return JSONResponse(
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
"type": "upstream_error"}}, status_code=502)
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell" # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste. # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
@@ -187,7 +206,20 @@ async def models(request: Request):
async def _proxy(path: str, request: Request): async def _proxy(path: str, request: Request):
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
try:
body = await request.json() body = await request.json()
except Exception:
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
"type": "invalid_request_error"}}, status_code=400)
if not isinstance(body, dict):
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
f"'{type(body).__name__}' bekommen.",
"type": "invalid_request_error"}}, status_code=400)
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus _apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
requested = str(body.get("model") or "auto") requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"): if requested.lower() in ("auto", "chat", "coding"):
+25 -6
View File
@@ -7,6 +7,20 @@ from pydantic import BaseModel
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
def _hermes_name(ordner: str) -> str:
"""Repo-Ordnername -> Hermes-Skillname.
deploy/deploy.sh kopiert `deploy/skills/<name>` nach `~/.hermes/skills/<name mit _>`
(`tr '-' '_'`), weil Hermes keine Bindestriche in Skillnamen mag. Ohne dieselbe
Umschreibung hier bekam Hermes den Ordnernamen MIT Bindestrich und fand den Skill
nicht - betroffen waren 7 der 12 Skills (betrieb-playbook, konzept-fliessband,
llm-wiki, morning-report, pc-pfad-cache, projekt-start, trend-radar): der Startknopf
meldete Erfolg, ausgefuehrt wurde nichts. deploy.sh bleibt die Quelle der Wahrheit.
"""
return ordner.replace("-", "_")
class RunSkillRequest(BaseModel): class RunSkillRequest(BaseModel):
skill_name: str skill_name: str
@@ -57,8 +71,11 @@ def list_skills():
skills.append({ skills.append({
"name": entry.name, "name": entry.name,
# Der Name, unter dem Hermes den Skill kennt - auch fuer den Laufend-
# Vergleich, weil im Prozess-cmdline genau dieser Name steht.
"hermes_name": _hermes_name(entry.name),
"description": description or "Keine Beschreibung verfügbar.", "description": description or "Keine Beschreibung verfügbar.",
"running": entry.name in running_skills "running": _hermes_name(entry.name) in running_skills
}) })
return {"skills": skills} return {"skills": skills}
@@ -70,13 +87,15 @@ def run_skill(req: RunSkillRequest):
# Fallback falls lokal (auf Windows) entwickelt wird # Fallback falls lokal (auf Windows) entwickelt wird
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"} return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat # Entspricht: hermes -z "Führe den 'X' Skill aus" chat - mit dem Namen, den Hermes
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus", "chat"] # nach dem Deploy-Sync wirklich kennt (Bindestriche -> Unterstriche, s. _hermes_name).
skill = _hermes_name(req.skill_name)
cmd = [hermes_bin, "-z", f"Führe den '{skill}' Skill aus", "chat"]
try: try:
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{req.skill_name.replace('/', '_')}.log") log_path = os.path.expanduser(f"~/.hermes/logs/skill_{skill.replace('/', '_')}.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True) os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a") as f: with open(log_path, "a", encoding="utf-8") as f:
f.write(f"\n--- Starting Skill: {req.skill_name} ---\n") f.write(f"\n--- Starting Skill: {skill} ---\n")
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT) subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."} return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
except Exception as e: except Exception as e:
+1 -8
View File
@@ -10,7 +10,7 @@ import os
import subprocess import subprocess
import httpx import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from services import backup as backup_svc from services import backup as backup_svc
@@ -42,13 +42,6 @@ def history(minutes: int = 60) -> dict:
return metrics_history.history(minutes) return metrics_history.history(minutes)
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
except Exception:
return False
def _voice_reachable() -> bool: def _voice_reachable() -> bool:
try: try:
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
+6 -6
View File
@@ -3,7 +3,7 @@ Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools + Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den eigenem Gedächtnis wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht. Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
@@ -169,7 +169,7 @@ def voice_metrics() -> dict:
@router.get("/voice/trace") @router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict: def voice_trace(limit: int = 20) -> dict:
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn · """Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit — Generierung). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt.""" damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)} return {"turns": get_trace(limit)}
@@ -289,8 +289,8 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
headers["X-Hermes-Session-Key"] = body.session_key headers["X-Hermes-Session-Key"] = body.session_key
async def gen(): async def gen():
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0 # Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung zu EINEM
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger. # Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice") trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True first = True
first_content = True first_content = True
@@ -325,7 +325,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True} payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, # Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB). # sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion. # Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py).
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"): if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False} payload["chat_template_kwargs"] = {"enable_thinking": False}
try: try:
@@ -342,7 +342,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead) if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
trace.note_ttfb() trace.note_ttfb()
first = False first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) — # Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind. # chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk: if first_content and b'"content"' in chunk:
trace.note_first_content() trace.note_first_content()
+1 -1
View File
@@ -33,7 +33,7 @@ def list_snapshots() -> dict:
@router.get("/zeitmaschine/inhalt") @router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict: def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …).""" """Top-Level-Komponenten eines Snapshots (hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file): if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.") raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file p = backup_svc.BACKUP_DIR / file
+1 -1
View File
@@ -1,5 +1,5 @@
""" """
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config). Voll-Zustands-Backup (Hermes-Configs/Secrets + llama-swap config).
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer); Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md. Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
""" """
+9 -18
View File
@@ -1,7 +1,13 @@
""" """
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem 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 LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001). (reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1).
Gedächtnis ist hier bewusst KEINE eigene Leitung mehr: bis August 2026 gab es dafür einen
Memory-MCP-Server gegen MC2s /api/memory; seit dessen Ablösung (docs/wissen/VERDIKTE.md,
07.08.2026) führt Hermes sein Gedächtnis
selbst — es hängt am Agenten, nicht am Gateway. check_health() prüft es weiterhin (Leitung 2),
es ist nur nichts mehr zu konfigurieren.
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1 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. die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
@@ -40,9 +46,7 @@ def _gw(host: str) -> str:
return f"http://{host}:{PORT}/v1" return f"http://{host}:{PORT}/v1"
def build_snippets(host: str = DEFAULT_HOST, def build_snippets(host: str = DEFAULT_HOST) -> dict:
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
mcp_python: str = "python") -> dict:
gw = _gw(host) gw = _gw(host)
mc_url = f"http://{host}:{PORT}" mc_url = f"http://{host}:{PORT}"
@@ -88,16 +92,6 @@ def build_snippets(host: str = DEFAULT_HOST,
f'export ANTHROPIC_MODEL="coder"' f'export ANTHROPIC_MODEL="coder"'
) )
memory_mcp = json.dumps({
"mcpServers": {
"mission-control-memory": {
"command": mcp_python,
"args": [mcp_script_path],
"env": {"MC_URL": mc_url},
}
}
}, indent=2)
return { return {
"host": host, "host": host,
"gateway_url": gw, "gateway_url": gw,
@@ -116,11 +110,8 @@ def build_snippets(host: str = DEFAULT_HOST,
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · " "API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."}, "Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code, "claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."}, "note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway."},
}, },
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
} }
+1 -1
View File
@@ -1,7 +1,7 @@
""" """
Health-Wächter der Box (Lucy-Proaktivität, Faden A3). Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0, Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway,
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram). (services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
+16 -30
View File
@@ -11,14 +11,14 @@ Zwei Sichten auf dieselben Messungen:
**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT, **Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT,
Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als **Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern — es
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket. **Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 → unsichtbar, im „Generierung"-Bucket.
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global „geparkt" und vom
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge- nächsten Chat-Turn eingesammelt (mit Frist-Check). Kein Turn-ID-Durchreichen durch den Lucy-Client
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client nötig. nötig.
In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset). In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset).
""" """
@@ -34,11 +34,11 @@ _STAGES: dict[str, deque] = {}
_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace) _TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace)
# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst. # Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst.
STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts") STAGES = ("stt", "vision", "chat_ttfb", "chat_first_content", "tts")
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je # Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie. # (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None} _PARKED: dict[str, tuple[float, float] | None] = {"stt": None}
def record_stage(stage: str, ms: float) -> None: def record_stage(stage: str, ms: float) -> None:
@@ -53,8 +53,8 @@ def record_stage(stage: str, ms: float) -> None:
def park(kind: str, ms: float) -> None: def park(kind: str, ms: float) -> None:
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als """Eine Messung, die NICHT im Chat-Request selbst passiert (STT läuft davor), global parken,
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann.""" damit der nächste Chat-Turn sie einsammeln kann."""
if ms is None or ms < 0 or kind not in _PARKED: if ms is None or ms < 0 or kind not in _PARKED:
return return
with _LOCK: with _LOCK:
@@ -71,17 +71,6 @@ def _take_stt(max_age: float = 20.0) -> float | None:
return None return None
def _take_retrieve(since_perf: float, max_age: float = 90.0) -> float | None:
"""Geparkten Mem0-Retrieve einsammeln, wenn er NACH dem Turn-Start kam (Rückruf während des
Turns) und frisch ist."""
with _LOCK:
v = _PARKED.get("retrieve")
if v and v[1] >= since_perf and (time.perf_counter() - v[1]) <= max_age:
_PARKED["retrieve"] = None
return round(v[0], 1)
return None
class Timer: class Timer:
"""Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`. """Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`.
Funktioniert um `await`-Aufrufe herum (enter → await → exit).""" Funktioniert um `await`-Aufrufe herum (enter → await → exit)."""
@@ -102,8 +91,7 @@ class TurnTrace:
"""Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns """Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns
gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats. gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats.
Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen. Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen."""
Unter-Detail: mem0 (Teil VON hirn, wird separat ausgewiesen, aber NICHT zum Balken addiert)."""
def __init__(self, session_id: str = "", kind: str = "voice") -> None: def __init__(self, session_id: str = "", kind: str = "voice") -> None:
self.id = uuid.uuid4().hex[:8] self.id = uuid.uuid4().hex[:8]
@@ -113,7 +101,7 @@ class TurnTrace:
self.session_id = (session_id or "")[:24] self.session_id = (session_id or "")[:24]
self.kind = kind self.kind = kind
self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder) self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder)
self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Mem0 + TTFT) self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Gedächtnis + TTFT)
self.had_images = False self.had_images = False
self.error: str | None = None self.error: str | None = None
@@ -130,7 +118,7 @@ class TurnTrace:
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
def note_first_content(self) -> None: def note_first_content(self) -> None:
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT).""" """Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT)."""
ms = (time.perf_counter() - self._brain0) * 1000.0 ms = (time.perf_counter() - self._brain0) * 1000.0
self.hirn_ms = round(ms, 1) self.hirn_ms = round(ms, 1)
record_stage("chat_first_content", ms) record_stage("chat_first_content", ms)
@@ -138,7 +126,6 @@ class TurnTrace:
def commit(self) -> dict: def commit(self) -> dict:
total = (time.perf_counter() - self.perf0) * 1000.0 total = (time.perf_counter() - self.perf0) * 1000.0
stt = _take_stt() # rollend bereits in /voice/stt erfasst stt = _take_stt() # rollend bereits in /voice/stt erfasst
mem0 = _take_retrieve(self.perf0) # rollend bereits in /api/memory erfasst
# Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende. # Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende.
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
rec = { rec = {
@@ -151,7 +138,6 @@ class TurnTrace:
"vision_ms": self.vision_ms, "vision_ms": self.vision_ms,
"hirn_ms": self.hirn_ms, "hirn_ms": self.hirn_ms,
"gen_ms": gen if (gen is None or gen >= 0) else 0.0, "gen_ms": gen if (gen is None or gen >= 0) else 0.0,
"mem0_ms": mem0,
"total_ms": round(total, 1), "total_ms": round(total, 1),
"error": self.error, "error": self.error,
} }
+5 -8
View File
@@ -1,7 +1,7 @@
""" """
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2). MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
Bisher hingen Re-Warm-Wächter, Health-Wächter (sentry) und Mem0-Auto-Dedupe am Bisher hingen Re-Warm-Wächter und Health-Wächter (sentry) am
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
@@ -11,14 +11,16 @@ Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
In-Process-Nudge aus llamaswap.write_config) In-Process-Nudge aus llamaswap.write_config)
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram; • sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md).
BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem BEWUSST NICHT hier: reminders_loop — der teilt sich Datei UND CRUD-Pfade mit dem
/api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im /api/reminders-Router (Zwei-Schreiber-Risiko auf mc2-reminders.json); er bleibt im
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP). Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED) Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED)
stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein stehen in der MC2-Unit auf 0 und hier auf Default 1 — reiner Konfig-Split, kein
Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback. Verhaltens-Code im Steuerpult angefasst. Zeilen dort entfernen = Rollback.
""" """
@@ -28,7 +30,6 @@ import logging
import os import os
from config import CONFIG_PATH from config import CONFIG_PATH
from services import memory as memory_svc
from services import sentry, warmer from services import sentry, warmer
logging.basicConfig( logging.basicConfig(
@@ -66,10 +67,6 @@ async def main() -> None:
warmer.INTERVAL, CONFIG_WATCH_S) warmer.INTERVAL, CONFIG_WATCH_S)
if sentry.ENABLED: if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
if not tasks: if not tasks:
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.") log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
return return
+1 -1
View File
@@ -28,7 +28,7 @@ hint=""
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu. # Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
ctx="$(cat <<EOF ctx="$(cat <<EOF
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.] [BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint} Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weiterer Dienst: hermes-gateway :8642 (fuehrt sein Gedaechtnis selbst — es gibt KEINEN separaten Gedaechtnis-Dienst mehr). Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch). TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren. PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/. REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
+1 -1
View File
@@ -123,7 +123,7 @@ check_hermes(){
done done
if [ "$state" = "done" ]; then if [ "$state" = "done" ]; then
SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.") SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.")
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft." notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Patches, Tools, Voice) grün — alles läuft."
return return
fi fi
# Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) — # Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
+8 -9
View File
@@ -3,8 +3,10 @@
# zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N. # zurückkommt. Erzeugt EIN Tarball mc2-state-<ts>.tar.gz, behält die letzten N.
# Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button. # Läuft per systemd-Timer (täglich), manuell, oder über den UI-Snapshot-Button.
# #
# Inhalt: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) · # Inhalt: ~/.hermes (config.yaml, .env, plugins/) · /etc/llama-swap/config.yaml ·
# /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.) # known-good/ (Versions-Manifest, s. u.)
# Das abgeloeste Gedaechtnis-Sidecar (/srv/models, Port 8765) faellt seit 27.08.2026 weg —
# sein Datenverzeichnis existiert nicht mehr (Hintergrund: docs/wissen/VERDIKTE.md).
# NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs. # NICHT enthalten (bewusst): GGUF-Modelle (riesig, neu ladbar), MC2-Code (Git), venvs.
# #
# ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git. # ACHTUNG: das Tarball enthält ~/.hermes/.env (Secrets) → chmod 600, nicht in Git.
@@ -20,16 +22,14 @@ RETAIN="${MC_BACKUP_RETAIN:-14}"
# Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht. # Off-Box-Ziel (leer = deaktiviert). Default: Proxmox-Host, eigener Key mit minimalem Recht.
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}" OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}" OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}" LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}" HERMES="${HERMES_HOME:-$HOME/.hermes}"
TS="$(date +%Y%m%d-%H%M%S)" TS="$(date +%Y%m%d-%H%M%S)"
STAGE="$(mktemp -d)" STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap" mkdir -p "$STAGE/hermes" "$STAGE/llama-swap"
[ -d "$MEM0_DIR" ] && cp -a "$MEM0_DIR/." "$STAGE/mem0/" || true
[ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true [ -f "$HERMES/config.yaml" ] && cp -a "$HERMES/config.yaml" "$STAGE/hermes/" || true
[ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true [ -f "$HERMES/.env" ] && cp -a "$HERMES/.env" "$STAGE/hermes/" || true
[ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true [ -d "$HERMES/plugins" ] && cp -a "$HERMES/plugins" "$STAGE/hermes/plugins" || true
@@ -60,7 +60,7 @@ SRC="${MC2_SRC:-$HOME/mission-control-v2}"
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}" HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
UV="$(command -v uv || echo "$HOME/.local/bin/uv")" UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback. # pip freeze je venv; das voice-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
"$1" -m pip freeze > "$2" 2>/dev/null \ "$1" -m pip freeze > "$2" 2>/dev/null \
@@ -68,7 +68,6 @@ freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
|| echo "pip freeze fehlgeschlagen: $1" > "$2" || echo "pip freeze fehlgeschlagen: $1" > "$2"
} }
freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt" freeze_venv "$SRC/backend/.venv/bin/python" "$KG/pip-backend.txt"
freeze_venv "$HOME/.mem0/venv/bin/python" "$KG/pip-mem0.txt"
freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt" freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
{ {
@@ -79,7 +78,7 @@ freeze_venv "$HOME/.voice/venv/bin/python" "$KG/pip-voice.txt"
echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')" echo "llama.cpp : $(LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -m1 -o 'version: .*' || echo '?')"
echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')" echo "llama-swap : $(/usr/local/bin/llama-swap --version 2>/dev/null | head -1 || echo '?')"
echo "python-venvs :" echo "python-venvs :"
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do for P in "$SRC/backend/.venv/bin/python" "$HOME/.voice/venv/bin/python"; do
echo " $P$("$P" --version 2>&1 || echo 'fehlt')" echo " $P$("$P" --version 2>&1 || echo 'fehlt')"
done done
} > "$KG/versions.txt" || true } > "$KG/versions.txt" || true
@@ -92,7 +91,7 @@ cat > "$STAGE/MANIFEST.txt" <<EOF
mc2-state backup mc2-state backup
created : $TS created : $TS
host : $(hostname) host : $(hostname)
inhalt : mem0 (chroma + history.db), hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle) inhalt : hermes (config.yaml, .env, plugins/), llama-swap (config.yaml), known-good/ (Versions-Manifest: pip freeze, Engine, Modelle)
restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz restore : bash ~/mission-control-v2/deploy/restore.sh mc2-state-$TS.tar.gz
EOF EOF
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# coder-vollkontext.sh — gibt dem Coder den ganzen Kontext (23.08.2026).
#
# WARUM: llama-swap startet den Coder mit `-c 131072 --parallel 2`. Das sind
# zwei Slots à 65.536 — und nur diese 65.536 stehen einer Sitzung zur Verfuegung.
# Der Coder hat aber genau EINEN Verbraucher: OpenChamber. Lucy benutzt `hermes`,
# der explore-Subagent auch, `review` benutzt `heavy`. Die zweite Bahn liegt brach.
#
# Mit `--parallel 1` bekommt eine Sitzung die vollen 131.072 — doppelt so viel
# Platz, halb so viel Komprimieren.
#
# ‼️ NICHT waehrend eines laufenden Auftrags ausfuehren: llama-swap laeuft mit
# --watch-config und laedt beim Speichern SOFORT neu. Das schneidet eine
# laufende Sitzung mitten im Satz ab.
#
# Aufruf: bash deploy/coder-vollkontext.sh (Trockenlauf, zeigt den Diff)
# bash deploy/coder-vollkontext.sh --ja (wendet an)
set -uo pipefail
CONF="${LLAMA_SWAP_CONF:-/etc/llama-swap/config.yaml}"
BLOCK="${CODER_BLOCK:- Qwen3.8-27B:}"
if [ ! -r "$CONF" ]; then echo "ABBRUCH: $CONF nicht lesbar"; exit 1; fi
TMP="$(mktemp)"
cp "$CONF" "$TMP"
python3 - "$TMP" "$BLOCK" <<'PY'
import io, re, sys
pfad, block = sys.argv[1], sys.argv[2]
z = io.open(pfad, encoding="utf-8").read().split("\n")
try:
start = next(i for i, l in enumerate(z) if l.startswith(block))
except StopIteration:
print("ABBRUCH: Block '%s' nicht gefunden" % block); sys.exit(1)
ende = next((i for i in range(start + 1, len(z)) if re.match(r"^ [A-Za-z]", z[i])), len(z))
# Streng auf den Coder-Block begrenzt — `fast`/`hermes` MUESSEN ihre zwei Slots
# behalten, dort greifen Lucy und der Explorer wirklich gleichzeitig zu.
n_par = n_ctx = 0
for i in range(start, ende):
if "--parallel 2" in z[i]:
z[i] = z[i].replace("--parallel 2", "--parallel 1"); n_par += 1
if z[i].strip() == "context: 65536":
z[i] = z[i].replace("65536", "131072"); n_ctx += 1
if n_par != 1 or n_ctx != 1:
print(f"ABBRUCH: unerwartet (parallel {n_par}x, context {n_ctx}x) — Konfiguration hat sich geaendert")
sys.exit(1)
io.open(pfad, "w", encoding="utf-8").write("\n".join(z))
PY
if [ $? -ne 0 ]; then rm -f "$TMP"; exit 1; fi
echo "=== Aenderung ==="
diff "$CONF" "$TMP"
echo
if [ "${1:-}" != "--ja" ]; then
echo "TROCKENLAUF — nichts geaendert. Anwenden mit: bash $0 --ja"
rm -f "$TMP"; exit 0
fi
SICHER="${CONF}.bak-$(date +%Y%m%d-%H%M%S)"
sudo -n cp "$CONF" "$SICHER" && echo "Sicherung: $SICHER"
sudo -n cp "$TMP" "$CONF" && echo "angewendet — llama-swap laedt durch --watch-config selbst neu"
rm -f "$TMP"
echo
echo "Warte auf den Neustart des Coders..."
sleep 8
curl -s -m 15 "http://127.0.0.1:8080/v1/models" >/dev/null 2>&1 \
&& echo "llama-swap antwortet wieder" \
|| echo "ACHTUNG: llama-swap antwortet nicht — Sicherung liegt unter $SICHER"
echo
echo "Danach in ~/.config/opencode/opencode.json auf dem PC nachziehen:"
echo " coder: limit.context 65536 -> 131072"
+25 -2
View File
@@ -12,11 +12,30 @@ echo "Aktualisiere Python Abhängigkeiten..."
backend/.venv/bin/pip install -r backend/requirements.txt backend/.venv/bin/pip install -r backend/requirements.txt
# 3. Llama-Swap Konfiguration prüfen und synchronisieren # 3. Llama-Swap Konfiguration prüfen und synchronisieren
#
# ‼️ ACHTUNG (27.08.2026): /etc/llama-swap/config.yaml ist die LEBENDE Wahrheit — MC2
# schreibt sie selbst (backend/services/llamaswap.py::write_config), sobald jemand über
# die Oberfläche ein Modell hinzufügt oder eine Rolle ändert. deploy/llama-swap.config.yaml
# ist nur ein Abzug und hinkt regelmäßig hinterher (aktuell fehlt ihm z. B. der
# `kritiker`/Devstral-Eintrag, den STACK.md und der Modell-Katalog voraussetzen).
# Bis hierher wurde die Live-Datei kommentarlos mit dem Abzug überschrieben — alles per
# UI Hinzugefügte war nach jedem Deploy weg. Jetzt: erst sichern, dann kopieren, und die
# Abweichung sichtbar machen. MC_DEPLOY_SKIP_SWAP_CONFIG=1 überspringt den Schritt ganz.
if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
echo "Llama-Swap Konfiguration hat sich geändert, synchronisiere..." if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" = "1" ]; then
echo "Llama-Swap Konfiguration weicht ab — uebersprungen (MC_DEPLOY_SKIP_SWAP_CONFIG=1)."
else
SWAP_BAK="/etc/llama-swap/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
echo "Llama-Swap Konfiguration hat sich geändert."
echo " Abweichung (live -> Repo-Abzug):"
diff -u /etc/llama-swap/config.yaml deploy/llama-swap.config.yaml | head -40 || true
echo " Sichere die laufende Datei nach $SWAP_BAK"
# NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md) # NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
sudo cp /etc/llama-swap/config.yaml "$SWAP_BAK"
sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
sudo systemctl restart llama-swap sudo systemctl restart llama-swap
echo " Rueckweg bei Verlust: sudo cp $SWAP_BAK /etc/llama-swap/config.yaml"
fi
fi fi
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben) # 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
@@ -35,7 +54,11 @@ for skill_dir in deploy/skills/*; do
done done
# 5. Dienste neu starten # 5. Dienste neu starten
# ‼️ mc2-steward gehoert hier dazu: er laeuft aus DEMSELBEN Checkout (backend/steward.py).
# Ohne ihn lief der Waechter-Prozess nach jedem Deploy mit dem alten Code weiter — der
# Neustart wurde 27.08.2026 nachgetragen. `--user restart` auf einer nicht installierten
# Unit ist folgenlos, darum ohne Vorabpruefung.
echo "Starte Backend neu..." echo "Starte Backend neu..."
systemctl --user restart mc2-gateway.service mission-control-2.service systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
echo "✅ Deployment erfolgreich abgeschlossen!" echo "✅ Deployment erfolgreich abgeschlossen!"
+10 -31
View File
@@ -1,35 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Post-Update-Check nach einem Hermes-Agent-Update: läuft unser geteiltes „Gehirn" # Post-Update-Check nach einem Hermes-Agent-Update: laeuft unser "Gehirn" noch?
# (Mem0 + die mc2-memory-Integration) noch? Exit 0 = alles ok, sonst 1 der Job wird # Exit 0 = alles ok, sonst 1 -> der Job wird im UI rot, damit ein kaputtes Gehirn auffaellt.
# im UI rot, damit ein kaputtes Gehirn sofort auffällt. #
# WICHTIG (27.08.2026) - VIER TOTE CHECKS ENTFERNT: bis hierher prueften die ersten Bloecke
# den abgeloesten Gedaechtnis-Sidecar (:8765), MC2s /api/memory und das zugehoerige
# Hermes-Plugin. Alle drei fielen am 07.08. weg (docs/wissen/VERDIKTE.md) - die Checks KONNTEN nicht
# mehr gruen werden. Folge: jedes Hermes-Update endete rot und wurde von autoupdate.sh
# zurueckgerollt. Kein Ersatz-Check auf Hermes native Gedaechtnis, weil es dafuer keinen
# nachgemessenen Endpunkt gibt - geraten waere eine Fassade. Der Tool-Smoke weiter unten
# laeuft ohnehin durch den ECHTEN Agenten und faengt ein kaputtes Gehirn.
set -uo pipefail set -uo pipefail
MC_URL="${MC_URL:-http://127.0.0.1:9001}" MC_URL="${MC_URL:-http://127.0.0.1:9001}"
MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}"
HERMES="${HERMES_HOME:-$HOME/.hermes}" HERMES="${HERMES_HOME:-$HOME/.hermes}"
fail=0 fail=0
echo "=== Hermes Post-Update: Gehirn-Check ===" echo "=== Hermes Post-Update: Gehirn-Check ==="
if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then
echo "PASS · Mem0-Sidecar erreichbar"
else
echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1
fi
if curl -sf -m 8 "$MC_URL/api/memory" >/dev/null 2>&1; then
echo "PASS · /api/memory antwortet"
else
echo "FAIL · /api/memory antwortet nicht"; fail=1
fi
if grep -qE "^[[:space:]]*provider:[[:space:]]*'?mc2-memory'?" "$HERMES/config.yaml" 2>/dev/null \
&& grep -qE "memory_enabled:[[:space:]]*true" "$HERMES/config.yaml" 2>/dev/null; then
echo "PASS · memory.provider=mc2-memory aktiv"
else
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
fi
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum --- # --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, --- # --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu --- # --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
@@ -49,14 +36,6 @@ else
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)" echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
fi fi
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
>/dev/null 2>&1 ); then
echo "PASS · mc2-memory-Plugin lädt unter dem neuen Hermes"
else
echo "FAIL · mc2-memory-Plugin lädt nicht (MemoryProvider-ABC geändert?)"; fail=1
fi
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten --- # --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
# --- Config-Werten STILL auf Defaults zurück (approvals.mode 'auto' -> manual -> alle --- # --- Config-Werten STILL auf Defaults zurück (approvals.mode 'auto' -> manual -> alle ---
# --- Tools hingen in pending_approval). Solche Warnungen müssen den Job ROT machen. --- # --- Tools hingen in pending_approval). Solche Warnungen müssen den Job ROT machen. ---
+199
View File
@@ -0,0 +1,199 @@
# Die drei Jobs (KISS-Umbau, 21.08.2026)
Vorher: **4 Hermes-Crons + 6 systemd-Timer**, verteilt auf zwei Mechanismen.
Deshalb fiel am 20.08. tagelang niemandem auf, dass ein Waechter fehlte —
niemand schaut an zwei Orten nach.
Jetzt: **drei Jobs, ein Ort.** `hermes cron list` zeigt die gesamte Automatik.
| Job | Wann | Art | Skript |
|---|---|---|---|
| **Daily News Report** | taeglich 07:00 | Agent + Websuche | — |
| **KI und Stack Radar** | samstags 08:00 | `--no-agent` | `stack-radar.sh``stack-ist.sh` |
| **Updates am Sonntag** | sonntags 04:30 | `--no-agent` | `sonntags-update.sh``autoupdate.sh` |
Daneben laufen als stille Rohrleitung weiter: `mc2-backup` (03:33) und
`projekte-sync` (stuendlich). Die melden sich nie, die sichern und synchronisieren nur.
## Der Entwurfsgrundsatz
> **Fakten sammelt ein Skript, Prosa schreibt das Modell.**
Der alte Selbsttest war am 20.08. gruen, waehrend vier Dinge kaputt waren: das
Kritiker-Gate zeigte seit einem Tag auf ein umbenanntes Modell (404), Lucys
`SOUL.md` war blockiert, das Dashboard stand ohne Passwort offen, die CI war rot.
Er hatte geprueft, ob Dienste **antworten** — nicht, ob sie **stimmen**.
`stack-ist.sh` prueft deshalb Ergebnisse: loesen die Rollen-Aliase noch auf? Ist
der letzte Timer-Lauf gutgegangen? Ist eine Sicherung juenger als zwei Tage?
Liefert die oeffentliche Adresse Daten ohne Anmeldung? Beim ersten Lauf hat es
sofort zwei echte Befunde gefunden.
## Kugelsicher heisst konkret
- **Das Modell steht nicht im kritischen Pfad.** Antwortet es nicht, gehen die
Rohbefunde trotzdem raus. Nur die Prosa faellt weg, nie die Meldung.
- **Es meldet immer.** „Alles gruen" ist ein Ergebnis, kein Grund zu schweigen.
- **Kein `set -e`.** Ein einzelner fehlschlagender Test darf den Bericht nicht
abschneiden.
- **Ein Melderweg:** `deploy/notify.sh` erreicht Telegram **und** Lucys
Briefkasten (aus dem sie spricht) in einem Aufruf. Die Jobs laufen mit
`--deliver local`, damit Hermes nicht ein zweites Mal sendet.
- **Kein Fuellmaterial.** Die Prompts verbieten ausgedachte Vorschlaege
ausdruecklich — ein Bericht mit Fuellmaterial wird nicht gelesen, und dann
auch der echte Befund nicht.
## Ausbringen
Die Skripte muessen unter `~/.hermes/scripts/` liegen (Vorgabe von `hermes cron --script`).
Quelle ist dieses Verzeichnis:
```bash
scp deploy/jobs/*.sh hitonabi@192.168.178.151:/home/hitonabi/.hermes/scripts/
ssh hitonabi@192.168.178.151 'cd ~/.hermes/scripts && sed -i "s/\r$//" *.sh && chmod +x *.sh'
```
‼️ Das `sed` ist Pflicht: vom Windows-PC kopierte Dateien haben CRLF, und bash
scheitert daran mit unverstaendlichen Meldungen.
## Abgeschaltet am 21.08.
| Weg | Warum |
|---|---|
| Cron `morgen-digest` | geht im Daily News Report auf |
| Cron `tech-radar` | geht im Stack Radar auf |
| Cron `nacht-wartung` | pruefte Lebenszeichen, nicht Ergebnisse |
| Cron `wissens-sync` | **war nie gelaufen** — kein `last_run_at` |
| Timer `mc2-bagatell` | **15 Naechte hintereinander „0 eingespielt"** |
| Timer `mc2-selfsmoke` | geht in `stack-ist.sh` auf |
| Timer `pbs-backup` | doppelt zu `mc2-backup` und seit 21.08. rot (`/srv/models/mem0` gibt es nicht mehr) |
| Timer `mc2-autoupdate` | wird jetzt vom Cron „Updates am Sonntag" gestartet |
Die Unit-Dateien liegen noch da, nur `disable`d — Rueckbau ist ein Befehl.
---
## Nachtrag 21.08.: Persona, Quellen-Links und Stimme
**Fehler, den ich gemacht hatte:** Mein erster Prompt fuer den Daily News Report
schrieb woertlich *„keine Emojis, keine Aufzaehlungspunkte, keine
Ueberschriften-Deko"* — und hat damit genau das wegoptimiert, was den Bericht
vorher gut machte.
★★ **Die Formatierung kam nie aus dem Job-Prompt.** Die alten Prompts waren
kurz (*„eine praegnante 3-Punkte-Zusammenfassung an den Commander"*). Emojis,
Ton und die Anrede stehen in **`~/.hermes/SOUL.md`** — Lucys Persona:
> Du sprichst den Nutzer IMMER mit **Commander** an.
> Locker, herzlich, schlagfertig, charmant, selbstbewusst.
**Lehre: den Job-Prompt kurz halten und die Persona arbeiten lassen.** Wer im
Prompt Stil verbietet, ueberstimmt die Persona — und merkt es erst, wenn die
Nachricht seelenlos ankommt.
### Stimme in Telegram
`hermes send` kann **`MEDIA:<pfad>`**. Damit geht eine fertige Audiodatei als
Anhang nach Telegram. `deploy/jobs/news-melden.sh` macht daraus einen Aufruf:
```
Agent schreibt zwei Dateien
/tmp/news-text.md (Emojis, Links, Formatierung) -> notify.sh -> Telegram + Briefkasten
/tmp/news-sprich.txt (3-4 Saetze, nichts Vorlesbares fehlt) -> :8650/tts -> WAV -> hermes send MEDIA:
```
‼️ **Nicht Hermes' eingebautes TTS nehmen.** Das steht auf `tts.provider: edge`
mit `en-US-AriaNeural` — englisch. Lucys echte Stimme ist MC2s `voice-service`
auf `:8650` (Piper `de_DE-thorsten-medium`). Deshalb ruft das Skript den
Sidecar direkt per curl.
‼️ **Kein ffmpeg noetig.** Telegram nimmt die WAV direkt an. Fuer eine echte
Sprachnachricht mit Wellenform braeuchte es OGG/Opus und damit ffmpeg — bewusst
nicht installiert, eine Abhaengigkeit weniger.
Der Agent ruft **einen** Befehl auf, alles danach ist deterministisch. Faellt
die Stimme aus, geht der Text trotzdem raus.
### ‼️ Folgefehler der Werkzeug-Abschaltung — gefunden und behoben
Nach dem Abschalten von `kanban` und `delegation` standen in `SOUL.md` noch
zwei Anweisungen, die ins Leere zeigten: *„rufst du sofort dein Werkzeug
delegate_task auf"* und *„Ideen traegst du sofort im Kanban-Auftragsbuch ein"*.
Genau die Klasse stiller Defekt, die am 19.08. das Kritiker-Gate zerlegt hat.
Beide Abschnitte ersetzt (Sicherung: `~/.hermes/SOUL.md.bak-20260821`).
**Merke: wer einen Werkzeugsatz abschaltet, muss `SOUL.md` mitlesen.**
### Kugelsicher-Regeln, die dazugekommen sind
- `hermes cron edit <id> "text"` **speichert nichts** — der Prompt muss ueber
**`--prompt`** kommen. Ohne Flag gibt der Befehl den Text nur aus und die
alte Fassung bleibt stehen. Nach jeder Aenderung in `jobs.json` nachsehen.
- Alte Job-Fassungen liegen im Zustands-Backup:
`tar -xzf /srv/models/mc2-backups/mc2-state-*.tar.gz ./hermes/cron/jobs.json`
- Tagesaktualitaet muss man erzwingen: Datum per `date` feststellen lassen,
mehrere Suchen verlangen, und Meldungen ohne belegbares Datum verwerfen.
---
## Nachtrag 3 (21.08.): Lucys ECHTE Stimme, echte Sprachnachricht
### Der Fehler: :8650 ist nicht Lucy
Ich hatte `/tts` auf MC2s `voice-service` (`:8650`) ohne Angabe von Engine und
Stimme aufgerufen. Dessen `/health` sagt:
```
"engines":["elevenlabs","edge"]
```
Piper und Chatterbox sind dort **gar nicht geladen** — die Vorgabe fiel auf die
erste verfuegbare: ElevenLabs *„Artoria DE · Saber · Hermes-Stimme"*. Das ist
**Hermes' Stimme**, nicht Lucys. Deutsch und weiblich, deshalb faellt es nicht
sofort auf. Beide verfuegbaren Engines sind ausserdem **Cloud** — gegen die
100-%-lokal-Praemisse.
### Lucys Stimme: `lucy-stimme.service` auf `:8021`
| | |
|---|---|
| Engine | **Kyutai pocket-tts 2.1.0** (neueste, seit 04.05. unveraendert) |
| Modell | **`german_24l`** — die volle Fassung, nicht die destillierte |
| Stimme | geklont aus `ref.mp3`, gecacht in `lucy_voice.safetensors` (44 MB) |
| Laeuft | `~/.lucy-stimme/`, systemd-**user**-Dienst, `enable`d, CPU |
| Start | ~48 s Ladezeit, danach ~5 s fuer 3,7 s Audio |
Mitgezogen wurde die **ganze Abstimmung**, nicht nur das Modell: `text_norm.py`
(Symbole/Pfade/URLs → Zahlen → Akronyme), Emotions-Voreinstellungen mit
Anlaufwoertern, Umlaut-Wortliste, Hochpass auf der Referenz, kalibrierter Pegel.
Ohne die klingt pocket nicht wie Lucy.
‼️ Der Sweep im TTS-Plan (*„seriell schlaegt parallel"*) wurde auf einem **9700X**
gemessen. Die Box ist ein Ryzen AI MAX+ 395 — das Ergebnis ist **nicht
uebertragen**, nur uebernommen. Wer Tempo braucht, misst neu.
### Recherche 21.08.: pocket bleibt
Nichts seit Mai schlaegt es auf dieser Achse. Die Alternativen sind 517× groesser:
Qwen3-TTS 0,61,7 Mrd. (Apache 2.0, Deutsch, 3-Sekunden-Klon), NeuTTS Air 0,5 Mrd.
(GGUF), CosyVoice2 0,5 Mrd. — gegen pockets **100 Mio.** Fuer einen taeglichen
Sprachnachrichten-Job auf CPU ist das der falsche Handel.
**Ihr wart bereits auf dem neuesten Stand** — nichts zu aktualisieren.
### Echte Sprachnachricht braucht OGG/Opus
Eine WAV kommt in Telegram als **Dateianhang** an. Fuer das runde Sprachmemo mit
Wellenform braucht es OGG/Opus — dafuer wurde **ffmpeg installiert** (8.0.1):
```bash
ffmpeg -y -i ton.wav -c:a libopus -b:a 32k -ar 48000 -ac 1 ton.ogg
hermes send --to telegram "MEDIA:ton.ogg"
```
Ganze Kette (Text + Stimme + Versand) gemessen: **10 Sekunden**.
### Altlast am Rande
`F:\Coding Stuff\lucy\lucy-tts\Lucy-Startklar.bat` zeigt auf
`mission-control-2\client\lucy-tts` — den Ordner gibt es seit der Repo-Trennung
nicht mehr.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# news-melden.sh — schickt den fertigen Nachrichtenbericht als TEXT und als
# SPRACHNACHRICHT an den Commander (21.08.2026, überarbeitet 22.08.).
#
# Der Agent ruft genau EINEN Befehl auf. Alles danach ist deterministisch —
# Zustellung darf nicht davon abhaengen, ob ein Modell die Reihenfolge einhaelt.
#
# ‼️ WARUM DAS SKRIPT SOFORT ZURUECKKEHRT (Fund vom 22.08., erster echter Lauf):
# Hermes' terminal-Werkzeug bricht nach 30 s ab. Die Sprachsynthese dauert
# laenger. Der Agent sah den Timeout, hielt den Aufruf fuer gescheitert und
# rief das Skript ein zweites Mal auf — der Text ging DOPPELT raus
# (07:01:42 und 07:02:14).
# Deshalb: Text senden, Stimme abgekoppelt im Hintergrund, sofort raus.
# Dazu eine Doppelversand-Sperre, die auch kuenftige Wiederholungen abfaengt.
#
# Erwartet zwei Dateien, die der Agent vorher geschrieben hat:
# $1 Textfassung (mit Emojis, Links, Formatierung — geht nach Telegram)
# $2 Sprechfassung (kurz, ohne Links/Emojis — wird vorgelesen)
#
# ‼️ STIMME: :8021, nicht :8650.
# :8021 = lucy-stimme.service — Kyutai pocket-tts, Modell `german_24l`,
# Stimme geklont aus ref.mp3. DAS ist Lucy. Lokal, auf der CPU.
# :8650 = MC2s voice-service. Dort sind nur `elevenlabs` und `edge` geladen —
# beides CLOUD, und die Vorgabe ist die ElevenLabs-Stimme
# "Artoria/Saber", also HERMES' Stimme, nicht Lucys.
set -uo pipefail
NOTIFY="${NOTIFY:-$HOME/mission-control-v2/deploy/notify.sh}"
VOICE="${VOICE_URL:-http://127.0.0.1:8021}"
HERMES="${HERMES_BIN:-$HOME/.local/bin/hermes}"
TON="/tmp/news-stimme.wav"
TON_OGG="/tmp/news-stimme.ogg"
# ---------------------------------------------- Zweiter Durchgang: Stimme ---
# Wird vom ersten Durchgang abgekoppelt neu gestartet. Laeuft beliebig lange,
# ohne dass irgendein Werkzeug-Timeout hineinredet.
if [ "${1:-}" = "--nur-stimme" ]; then
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
[ -s "$SPRECHDATEI" ] || exit 0
CODE=$(curl -s -m 600 -X POST "$VOICE/tts" \
-H 'Content-Type: application/json' \
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
-o "$TON" -w '%{http_code}' 2>/dev/null)
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
# OGG/Opus. Eine WAV kaeme als Dateianhang an. Mono/48k ist das erwartete Format.
if ! ffmpeg -y -loglevel error -i "$TON" -c:a libopus -b:a 32k -ar 48000 -ac 1 "$TON_OGG" 2>/dev/null; then
TON_OGG="$TON"
fi
"$HERMES" send --to telegram "MEDIA:$TON_OGG" >/dev/null 2>&1
exit 0
fi
# ------------------------------------------- Erster Durchgang: Text + Start ---
TEXTDATEI="${1:-/tmp/news-text.md}"
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
if [ ! -s "$TEXTDATEI" ]; then
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
exit 1
fi
# Doppelversand-Sperre. Greift, egal WARUM ein zweiter Aufruf kommt —
# Timeout, Modell-Wiederholung, versehentlicher Handstart.
MARKE="/tmp/.news-gesendet-$(sha256sum "$TEXTDATEI" | cut -c1-16)"
if [ -f "$MARKE" ]; then
ALTER=$(( $(date +%s) - $(stat -c %Y "$MARKE" 2>/dev/null || echo 0) ))
if [ "$ALTER" -lt 7200 ]; then
echo "gemeldet (dieser Bericht ging vor ${ALTER}s bereits raus — nicht doppelt geschickt)"
exit 0
fi
fi
: > "$MARKE"
find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
if [ -s "$SPRECHDATEI" ]; then
setsid nohup bash "$0" --nur-stimme "$SPRECHDATEI" >/dev/null 2>&1 < /dev/null &
disown 2>/dev/null || true
fi
echo "gemeldet"
exit 0
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# sonntags-update.sh — Job 2: "Updates am Sonntag" (21.08.2026).
#
# Duenner Aufsatz auf deploy/autoupdate.sh. Bewusst KEIN Neubau: autoupdate.sh
# macht seit Juli genau das Richtige — Router -> Engine -> Hermes, jede Ebene
# mit Update-Pruefung, Postcheck, und bei Rot automatischem Rueckbau plus
# Selbst-Pinnung. Es meldet auch schon selbst ueber notify.sh, also Telegram
# UND Lucys Stimme.
#
# Dieser Aufsatz existiert nur, damit der Job unter `hermes cron list` steht
# statt in einem separaten systemd-Timer. Ein Ort fuer alle Automatik — genau
# deshalb ist am 20.08. tagelang niemandem aufgefallen, dass ein Waechter fehlte.
#
# Aufruf ueber Hermes-Cron:
# hermes cron create '30 4 * * 0' --name 'Updates am Sonntag' \
# --no-agent --script sonntags-update.sh --deliver local
set -uo pipefail
AUTOUPDATE="${AUTOUPDATE:-$HOME/mission-control-v2/deploy/autoupdate.sh}"
NOTIFY="${NOTIFY:-$HOME/mission-control-v2/deploy/notify.sh}"
melde() { bash "$NOTIFY" -s "[Box-Update]" "$1" 2>/dev/null || echo "$1" >&2; }
if [ ! -f "$AUTOUPDATE" ]; then
melde "Sonntags-Update ausgefallen: $AUTOUPDATE gibt es nicht. Nichts wurde aktualisiert."
exit 1
fi
# autoupdate.sh meldet waehrend des Laufs selbst (inkl. der KRITISCH-Faelle, in
# denen ein Bericht am Ende zu spaet kaeme). Wir fangen hier nur den Fall ab,
# dass es gar nicht erst durchlaeuft — sonst waere das Schweigen zweideutig.
AUSGABE="$(bash "$AUTOUPDATE" 2>&1)"
CODE=$?
if [ "$CODE" -ne 0 ]; then
melde "Sonntags-Update mit Fehler beendet (exit $CODE). Letzte Zeilen:
$(printf '%s\n' "$AUSGABE" | tail -15)"
fi
# Erfolgsfall: autoupdate.sh hat bereits gemeldet. Leere Ausgabe -> Hermes
# schweigt, also genau eine Nachricht statt zwei.
exit 0
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# stack-ist.sh — der IST-Zustand der Box, in Fakten (21.08.2026).
#
# Wird von Hermes-Cron "stack-radar" ueber --script eingespeist: die Ausgabe hier
# landet im Prompt des Agenten, der daraus den Bericht schreibt.
#
# GRUNDSATZ: Dieses Skript prueft ERGEBNISSE, nicht Lebenszeichen.
# Der alte Selbsttest war am 20.08. gruen, waehrend vier Dinge kaputt waren:
# das Kritiker-Gate zeigte auf ein umbenanntes Modell (404), Lucys SOUL.md war
# blockiert, das Dashboard stand ohne Passwort offen, die CI war rot.
# "Dienst antwortet" haette das nie gefunden. Deshalb wird hier nachgesehen,
# ob Dinge STIMMEN — nicht ob sie laufen.
#
# Nie mit set -e: ein einzelner fehlschlagender Test darf den Bericht nicht
# abschneiden. Fehler werden gemeldet, nicht verschwiegen.
set -uo pipefail
MC2="${MC2_URL:-http://127.0.0.1:9001}"
SWAP="${SWAP_URL:-http://127.0.0.1:8080}"
GITEA="${GITEA_URL:-http://192.168.178.153:3000}"
OEFFENTLICH="${OEFFENTLICH_URL:-https://mc.tobisniceshomelab.ddnsfree.com}"
ok() { printf ' OK %s\n' "$*"; }
warn() { printf ' ACHTUNG %s\n' "$*"; }
bad() { printf ' ROT %s\n' "$*"; }
echo "IST-ZUSTAND DER BOX — $(date '+%d.%m.%Y %H:%M')"
echo
# ---------------------------------------------------------------- Dienste ---
echo "DIENSTE"
# systemd stellt gescheiterten Units ein "●" voran — das muss weg, sonst meldet
# der Bericht lauter Aufzaehlungspunkte als kaputte Dienste.
# EGAL: Units, die dauerhaft scheitern und niemanden stoeren. Ein Bericht, der
# jeden Tag denselben harmlosen Fehler zeigt, erzieht zum Wegsehen.
EGAL="at-spi-dbus-bus.service"
fehl=$(systemctl --user list-units --type=service --state=failed --no-legend --no-pager 2>/dev/null \
| sed 's/^[^a-zA-Z]*//' | awk '{print $1}')
gefunden=0
for d in $fehl; do
case " $EGAL " in *" $d "*) continue ;; esac
bad "Dienst gescheitert: $d"; gefunden=1
done
[ "$gefunden" = "0" ] && ok "kein Dienst im Fehlerzustand"
for d in mc2-gateway mission-control-2 hermes-gateway voice-service; do
systemctl --user is-active --quiet "$d.service" 2>/dev/null \
&& ok "$d laeuft" || bad "$d laeuft NICHT"
done
systemctl is-active --quiet llama-swap.service 2>/dev/null \
&& ok "llama-swap laeuft" || bad "llama-swap laeuft NICHT"
echo
# ------------------------------------------------------------------ Timer ---
# Nicht "ist der Timer aktiv", sondern "ist der letzte Lauf gutgegangen".
echo "TIMER — letzter Lauf"
for t in $(systemctl --user list-timers --no-legend --no-pager 2>/dev/null | awk '{print $NF}'); do
dienst="${t%.service}.service"
case "$dienst" in launchpadlib-*) continue ;; esac # Ubuntu-Eigenes, nicht unseres
code=$(systemctl --user show -p ExecMainStatus --value "$dienst" 2>/dev/null)
wann=$(systemctl --user show -p ExecMainExitTimestamp --value "$dienst" 2>/dev/null | cut -d' ' -f2,3)
if [ -z "$wann" ]; then warn "$dienst ist eingerichtet, aber NIE gelaufen"
elif [ "${code:-0}" = "0" ]; then ok "$dienst zuletzt $wann"
else bad "$dienst zuletzt GESCHEITERT (exit $code) am $wann"; fi
done
echo
# --------------------------------------------------------- Modell-Aliase ---
# Der Fund vom 19./20.08.: Skripte und Configs nennen Modelle beim Eigennamen,
# das Modell wird umbenannt, und alles laeuft still in 404. Hier wird geprueft,
# ob jeder Alias, den irgendwer benutzt, heute noch antwortet.
echo "MODELL-ALIASE (Rollen muessen aufloesen)"
liste=$(curl -s -m 10 "$MC2/v1/models" 2>/dev/null \
| python3 -c 'import sys,json; print(" ".join(m["id"] for m in json.load(sys.stdin)["data"]))' 2>/dev/null)
if [ -z "$liste" ]; then bad "Modell-Liste nicht abrufbar ueber $MC2/v1"
else
ok "verfuegbar: $liste"
for rolle in hermes fast heavy; do
case " $liste " in
*" $rolle "*) ok "Rolle '$rolle' loest auf" ;;
*) bad "Rolle '$rolle' FEHLT — Skripte, die sie nennen, laufen in 404" ;;
esac
done
fi
echo
# ------------------------------------------------------------- Erreichbar ---
echo "ERREICHBARKEIT"
for paar in "MC2:$MC2/api/health" "llama-swap:$SWAP/v1/models" "Gitea:$GITEA/api/v1/version"; do
name="${paar%%:*}"; url="${paar#*:}"
code=$(curl -s -m 10 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
[ "$code" = "200" ] && ok "$name antwortet (200)" || bad "$name antwortet mit '$code'"
done
echo
# ------------------------------------------------ Offen im Internet? --------
# Der Fund vom 21.08.: MC2 hing ueber den Nginx Proxy Manager ungeschuetzt im
# Netz. ufw sah nichts davon, weil der Proxy im LAN steht. Also von aussen
# nachsehen — und zwar auf einem Endpunkt, der ECHTE Daten liefert.
echo "OEFFENTLICHE ERREICHBARKEIT"
code=$(curl -s -m 15 -o /tmp/.ist-oeff -w '%{http_code}' "$OEFFENTLICH/api/system/status" 2>/dev/null)
if [ "$code" = "200" ] && head -c1 /tmp/.ist-oeff 2>/dev/null | grep -q '{'; then
bad "MC2 liefert Systemdaten OHNE Anmeldung ins Internet ($OEFFENTLICH)"
elif [ "$code" = "401" ] || [ "$code" = "403" ]; then
ok "oeffentlicher Zugang verlangt Anmeldung ($code)"
elif [ "$code" = "000" ]; then
ok "von aussen nicht erreichbar"
else
warn "oeffentlicher Zugang antwortet mit '$code' — nachsehen"
fi
rm -f /tmp/.ist-oeff
echo
# ----------------------------------------------------------------- Ports ---
echo "OFFENE PORTS (ufw)"
sudo -n ufw status 2>/dev/null | grep ALLOW | sed 's/^/ /' || warn "ufw-Status nicht lesbar"
echo
# --------------------------------------------------------------- CI-Ampel ---
echo "CI-AMPEL (Gitea)"
T=$(tr -d '[:space:]' < "$HOME/.config/gitea/create-token" 2>/dev/null)
if [ -z "$T" ]; then warn "kein Gitea-Token — CI nicht pruefbar"
else
curl -s -m 20 -H "Authorization: token $T" "$GITEA/api/v1/user/repos?limit=100" 2>/dev/null \
| python3 -c '
import sys, json
try:
repos = json.load(sys.stdin)
except Exception as e:
print(" ACHTUNG Repo-Liste nicht lesbar:", e); sys.exit(0)
print(f" OK {len(repos)} Repos in Gitea")
' 2>/dev/null || warn "Repo-Liste nicht lesbar"
fi
echo
# ------------------------------------------------------------- Sicherung ---
echo "SICHERUNGEN"
# Pfad aus backup.sh: DEST_DIR="$MODELS_DIR/mc2-backups"
SICHER="${MC_BACKUP_DIR:-/srv/models/mc2-backups}"
neuestes=$(find "$SICHER" -maxdepth 2 -type f -newermt '-2 days' 2>/dev/null | head -1)
anzahl=$(find "$SICHER" -maxdepth 2 -type f 2>/dev/null | wc -l)
if [ -n "$neuestes" ]; then
ok "Sicherung juenger als 2 Tage vorhanden ($anzahl Staende in $SICHER)"
elif [ "$anzahl" -gt 0 ]; then
bad "juengste Sicherung ist AELTER als 2 Tage ($anzahl Staende in $SICHER)"
else
bad "gar keine Sicherung in $SICHER"
fi
echo
# ------------------------------------------------------------------ Platz ---
echo "PLATZ UND LAST"
# Nur eindeutige Dateisysteme: / und /srv liegen hier auf demselben Geraet,
# doppelt gemeldet las sich das wie ein Befund.
df -h --output=source,size,used,avail,pcent,target / /srv 2>/dev/null \
| awk '!gesehen[$1]++ {printf " %s\n", $0}'
echo " RAM: $(free -h | awk '/^Mem:/{print $3" von "$2" belegt"}')"
echo
# ------------------------------------------------------------- Warm/Kalt ---
# ---------------------------------------------------------------- Curator ---
# Hierher gefaltet am 22.08.2026 aus dem Repo `curator-staleness-check`.
# Das war ein eigener Wachhund mit eigenem systemd-Timer und eigener CI-Ampel —
# und er ueberwachte einen Cron-Job ueber eine FEST VERDRAHTETE ID
# (a47910e2ef05), den es laengst nicht mehr gibt. Er haette bei jedem Lauf
# Alarm geschlagen fuer etwas, das gar nicht existiert.
# Hier steht dieselbe Frage in vier Zeilen, ohne Repo, Timer und Ampel.
echo "CURATOR"
AN=$(grep -A3 '^curator:' "$HOME/.hermes/config.yaml" 2>/dev/null | grep -m1 'enabled:' | awk '{print $2}')
if [ "${AN:-false}" != "true" ]; then
ok "Curator ist abgeschaltet — nichts zu ueberwachen"
else
# %Ts liefert GANZE Sekunden. %T@ waere ein Float — bash rechnet damit nicht
# und bricht mit "invalid arithmetic operator" ab.
JUENGSTES=$(find "$HOME/.hermes/logs/curator" -type f -name '*.log' -printf '%Ts\n' 2>/dev/null | sort -rn | head -1)
if [ -z "$JUENGSTES" ]; then
bad "Curator ist AN, hat aber noch nie etwas protokolliert"
else
TAGE=$(( ( $(date +%s) - JUENGSTES ) / 86400 ))
if [ "$TAGE" -le 7 ]; then ok "Curator war vor $TAGE Tagen zuletzt aktiv"
elif [ "$TAGE" -le 21 ]; then warn "Curator seit $TAGE Tagen still — laeuft er noch?"
else bad "Curator seit $TAGE Tagen still, obwohl eingeschaltet"
fi
fi
fi
echo
echo "MODELLE IM SPEICHER"
curl -s -m 10 "$SWAP/running" 2>/dev/null \
| python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(" ACHTUNG llama-swap-Status nicht lesbar"); sys.exit(0)
lauf = d.get("running", [])
if not lauf:
print(" OK kein Modell geladen (alles kalt)")
for m in lauf:
print(" OK " + str(m.get("model")) + " - " + str(m.get("state")))
'
echo
echo "ENDE IST-ZUSTAND"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# stack-radar.sh — Job 3: "KI & Stack Radar" (21.08.2026).
#
# Ablauf: IST-Zustand holen (Fakten) -> Modell bewerten lassen (Prosa)
# -> ueber notify.sh melden (Telegram + Lucys Stimme)
#
# KUGELSICHER heisst hier konkret:
# * Die Fakten kommen aus stack-ist.sh, nicht aus dem Modell. Faellt das Modell
# aus, werden die ROTEN Zeilen trotzdem gemeldet — roh statt gar nicht.
# * Es meldet IMMER. "Alles gruen" ist ein Ergebnis, kein Grund zu schweigen.
# * Kein set -e: ein einzelner Fehlschlag darf den Bericht nicht abschneiden.
#
# Aufruf ueber Hermes-Cron:
# hermes cron create '0 8 * * 6' --name 'KI & Stack Radar' \
# --no-agent --script stack-radar.sh --deliver local
# (--no-agent, weil dieses Skript selbst meldet. Leere Ausgabe = Hermes schweigt.)
set -uo pipefail
HIER="$(cd "$(dirname "$0")" && pwd)"
IST_SKRIPT="${IST_SKRIPT:-$HOME/.hermes/scripts/stack-ist.sh}"
NOTIFY="${NOTIFY:-$HOME/mission-control-v2/deploy/notify.sh}"
GATEWAY="${GATEWAY_URL:-http://127.0.0.1:9010/v1}"
MODELL="${RADAR_MODELL:-fast}" # bewusst NICHT heavy: das laedt 68 GB und der
# Radar meldet danach seinen eigenen Speicherverbrauch als Befund
[ -x "$IST_SKRIPT" ] || IST_SKRIPT="$HIER/stack-ist.sh"
melde() {
if [ -x "$NOTIFY" ] || [ -f "$NOTIFY" ]; then
bash "$NOTIFY" -s "[Stack-Radar]" "$1"
else
# Letzte Rueckfallebene: wenigstens ins Log, damit nichts still verschwindet.
echo "[stack-radar] MELDEWEG FEHLT — Bericht folgt roh:" >&2
echo "$1" >&2
fi
}
# ------------------------------------------------------------- 1. Fakten ---
IST="$(bash "$IST_SKRIPT" 2>&1)"
if [ -z "$IST" ]; then
melde "Radar konnte den IST-Zustand nicht erheben — $IST_SKRIPT lieferte nichts. Das ist selbst ein Befund: bitte nachsehen."
exit 1
fi
ROT="$(printf '%s\n' "$IST" | grep -E '^\s+(ROT|ACHTUNG)' || true)"
ANZ_ROT="$(printf '%s\n' "$ROT" | grep -c 'ROT' || true)"
# ------------------------------------------------------- 2. Bewertung ------
# Das Modell bekommt NUR die Fakten und darf nichts dazuerfinden. Es soll
# einordnen, nicht ermitteln.
PROMPT="Du bist der Stack-Radar einer selbstgehosteten KI-Box. Unten steht der
gemessene IST-Zustand. Schreibe einen kurzen Bericht auf Deutsch, per Du.
Regeln:
- Erfinde NICHTS. Nur was unten steht.
- Beginne mit einer Zeile Gesamturteil.
- Dann: was ist ROT oder ACHTUNG, und was waere jeweils der naechste Schritt.
- Verbesserungsvorschlaege NUR, wenn sich aus den Zahlen unten wirklich etwas
Konkretes ergibt. Gibt es nichts, lass den Abschnitt ersatzlos weg. Schreibe
lieber gar keinen Vorschlag als einen ausgedachten - ein Bericht mit
Fuellmaterial wird nicht gelesen.
- Was du an den Messwerten selbst merkwuerdig findest, gehoert unter ACHTUNG,
nicht unter Verbesserungen.
- Wenn alles gruen ist, sag das in einem Satz und hoere auf.
- Keine Ueberschriften-Deko, keine Emojis, hoechstens 200 Woerter.
IST-ZUSTAND:
$IST"
ANTWORT=""
if NUTZLAST="$(python3 -c '
import json, sys
print(json.dumps({
"model": sys.argv[1],
"messages": [{"role": "user", "content": sys.argv[2]}],
"max_tokens": 900,
"temperature": 0.3,
}))' "$MODELL" "$PROMPT" 2>/dev/null)"; then
ANTWORT="$(curl -s -m 300 -X POST "$GATEWAY/chat/completions" \
-H 'Content-Type: application/json' -d "$NUTZLAST" 2>/dev/null \
| python3 -c '
import json, sys
try:
print(json.load(sys.stdin)["choices"][0]["message"]["content"].strip())
except Exception:
pass' 2>/dev/null)"
fi
# ------------------------------------------------------------- 3. Melden ---
if [ -n "$ANTWORT" ]; then
melde "$ANTWORT"
else
# Modell stumm oder kaputt -> die Fakten gehen TROTZDEM raus. Genau dafuer
# steht das Modell nicht im kritischen Pfad.
if [ -n "$ROT" ]; then
melde "Radar: Bewertung durch das Modell hat nicht geklappt — hier die Rohbefunde:
$ROT"
else
melde "Radar: alles gruen (Bewertung durch das Modell hat nicht geklappt, die Messung schon)."
fi
fi
# Hermes bekommt eine LEERE Ausgabe -> keine zweite Nachricht. Gemeldet wurde
# bereits ueber notify.sh, und das erreicht Telegram UND Lucys Stimme.
exit 0
+6 -4
View File
@@ -4,7 +4,7 @@ globalTTL: 0
models: models:
Qwen3.6-35B-A3B: Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns # parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Nebenlast/Hintergrund-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02: # nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template, # Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der # lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
@@ -26,7 +26,7 @@ models:
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja. # nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität. # Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
cmd: | cmd: |
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0
ttl: 5400 ttl: 5400
aliases: aliases:
- coder - coder
@@ -34,7 +34,9 @@ models:
in: [text, image] in: [text, image]
out: [text] out: [text]
tools: true tools: true
context: 65536 # --parallel 1 = der Coder bekommt den ganzen Slot (34a9862, am laufenden Prozess
# gegengeprueft). Er hat nur einen Verbraucher; Lucy/explore nutzen hermes, review heavy.
context: 131072
Muse-Glimmer-30B: Muse-Glimmer-30B:
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting # Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker. # und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
@@ -78,7 +80,7 @@ models:
out: [text] out: [text]
context: 32768 context: 32768
Qwen3-Reranker-0.6B: Qwen3-Reranker-0.6B:
# Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Mem0-Suchtreffer nach echter # Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Gedächtnis-Suchtreffer nach echter
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!). # Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt. # Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
cmd: | cmd: |
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=MC2 Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config) Description=MC2 Zustands-Backup (Hermes-Configs/Secrets + llama-swap config)
Documentation=file:%h/mission-control-v2/docs/BACKUP.md Documentation=file:%h/mission-control-v2/docs/BACKUP.md
[Service] [Service]
+37
View File
@@ -0,0 +1,37 @@
# systemd-USER-Unit für den MC2-Steward — die Wächter-Loops als eigener Prozess (UMBAU v3 P2).
# Re-Warm-Wächter und Health-Sentry überleben damit jeden Steuerpult-Neustart, und der Sentry
# kann auch das Steuerpult SELBST + den mc2-gateway überwachen (MC_SENTRY_WATCH_MC2=1).
# Meldungen gehen per HTTP in den MC2-Briefkasten (MC_ANNOUNCE_HTTP); ist MC2 down, greift
# weiterhin der Telegram-Direktweg (notify.sh).
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
#
# ‼️ NACHGETRAGEN 27.08.2026: Diese Unit lief auf der Box, fehlte aber im Repo — sie war weder
# versioniert noch vom Disaster-Recovery abgedeckt, obwohl backend/routers/system.py,
# services/maintenance.py (USER_SERVICES) und deploy/stack-postcheck.sh sie voraussetzen.
# Inhalt 1:1 von der laufenden Box übernommen (`systemctl --user cat mc2-steward`), nur die
# Description um den entfallenen Mem0-Dedupe-Loop gekürzt. Das Warm-Set-Drop-in liegt
# daneben in mc2-steward.service.d-warmset.conf — beim Ausrollen mitnehmen.
#
# Ablage: ~/.config/systemd/user/mc2-steward.service ; dann:
# systemctl --user daemon-reload && systemctl --user enable --now mc2-steward
[Unit]
Description=MC2 Steward (Wächter: Re-Warm, Health-Sentry)
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 steward.py
Environment=PYTHONPATH=%h/mission-control-v2
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
Environment=MC_ANNOUNCE_HTTP=http://127.0.0.1:9001
Environment=MC_SENTRY_WATCH_MC2=1
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+11
View File
@@ -0,0 +1,11 @@
# Drop-in für mc2-steward.service — Ablage auf der Box:
# ~/.config/systemd/user/mc2-steward.service.d/warmset.conf
#
# 26.07.2026 — Ko-Residenz ist nicht Warm-Pflicht. Die Gruppe `brains` enthaelt seit dem
# Umbau auch Modelle MIT TTL (Coder 90 min, Kritiker 30 min); ohne diese Liste hielte der
# Waechter sie faelschlich fuer dauerhaft faellig und arbeitete gegen die TTLs.
# ANFUEHRUNGSZEICHEN SIND PFLICHT: systemd trennt Environment= an Leerzeichen, ohne sie
# kommt nur das erste Modell an (live erlebt) — das Warm-Ziel waere still zu klein.
[Service]
Environment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3-Reranker-0.6B Qwen3.6-35B-A3B"
Environment=MC_WARMUP_RERANK=reranker
+3 -6
View File
@@ -19,19 +19,16 @@ Environment=MC_PORT=9001
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080 Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models 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
# Gateway-Auszug (UMBAU v3 P1): /v1 roh an den eigenständigen mc2-gateway-Prozess # Gateway-Auszug (UMBAU v3 P1): /v1 roh an den eigenständigen mc2-gateway-Prozess
# durchreichen. Diese Zeile entfernen (+ daemon-reload + restart) = Rollback, MC2 # durchreichen. Diese Zeile entfernen (+ daemon-reload + restart) = Rollback, MC2
# bedient /v1 wieder selbst. # bedient /v1 wieder selbst.
Environment=MC_V1_UPSTREAM=http://127.0.0.1:9010 Environment=MC_V1_UPSTREAM=http://127.0.0.1:9010
# Steward-Auszug (UMBAU v3 P2): Die Wächter-Loops (Re-Warm, Health-Sentry, Mem0-Dedupe) # Steward-Auszug (UMBAU v3 P2): Die Wächter-Loops (Re-Warm, Health-Sentry) laufen jetzt im
# laufen jetzt im eigenen Dienst mc2-steward.service — hier deshalb AUS. Diese drei # eigenen Dienst mc2-steward.service — hier deshalb AUS. Diese zwei Zeilen entfernen
# Zeilen entfernen (+ daemon-reload + restart) = Rollback (Loops wieder im Steuerpult). # (+ daemon-reload + restart) = Rollback (Loops wieder im Steuerpult).
# Der reminders_loop bleibt bewusst HIER (teilt Datei+CRUD mit /api/reminders). # Der reminders_loop bleibt bewusst HIER (teilt Datei+CRUD mit /api/reminders).
Environment=MC_REWARM_ENABLED=0 Environment=MC_REWARM_ENABLED=0
Environment=MC_SENTRY_ENABLED=0 Environment=MC_SENTRY_ENABLED=0
Environment=MC_MEM_DEDUPE_ENABLED=0
# KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den # KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den
# ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates # ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates
# liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default # liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default
@@ -0,0 +1,10 @@
# Arbeitsanweisung (gilt fuer jeden Auftrag)
Arbeite Datei fuer Datei. Lies HOECHSTENS zwei Dateien, bevor du die erste aenderst.
Kein Gesamtplan, keine Vollinventur. Eine halbfertige Aenderung ist wertvoller als
eine vollstaendige Analyse ohne Aenderung.
- Nach jeder geaenderten Datei: kurz sagen, was geaendert wurde, dann zur naechsten.
- Nicht erst das ganze Projekt kartieren. Nicht auf Vollstaendigkeit warten.
- Wenn du unsicher bist, aendere die wahrscheinlichste Datei und pruefe danach.
- Schreiben schlaegt Lesen.
+51
View File
@@ -0,0 +1,51 @@
# OpenCode-Konfiguration des PCs
**Hier liegt die Quelle. `%USERPROFILE%\.config\opencode\` ist die Kopie.**
Am 21.08.2026 habe ich die dortige `opencode.json` ueberschrieben, ohne sie
vorher zu lesen — sie existierte nur an dieser einen Stelle, ohne Sicherung und
ohne Historie. Rekonstruiert aus einer Juli-Sicherung. Damit das nicht nochmal
passiert, liegt sie jetzt hier.
```powershell
Copy-Item "deploy\opencode-config\*" "$env:USERPROFILE\.config\opencode\" -Force
```
## Modellwahl (Stand 22.08.2026)
| Rolle | Modell | warum |
|---|---|---|
| **build** | `aibox/coder` — Qwen3.8-27B (dicht) | macht die eigentliche Arbeit |
| **plan** | `aibox/heavy` — gpt-oss-120b | denkt vor, aendert nichts |
| **explore** | `aibox/hermes` — Qwen3.6-35B-A3B | liest und sucht, immer warm |
| `small_model` | `aibox/hermes` | Titel, Zusammenfassungen |
★★ **Korrektur einer falschen Empfehlung.** Bis zum 22.08. stand hier `fast`
(Qwen3.6-35B-A3B, MoE) als Standard, mit der Begruendung, es schlage den `coder`
„in Tempo und Qualitaet". **Das Tempo war gemessen, die Qualitaet nie** — in der
Notiz vom 20.08. steht fuer den Coder woertlich „keine agentische Messung".
Die veroeffentlichten Zahlen sagen etwas anderes:
| | `fast` (3.6-35B-A3B) | `coder` (3.8-27B) |
|---|---|---|
| SWE-bench Verified | ~73,4 | 73,4 |
| Terminal-Bench 2.1 | — | **73,0** |
| DeepSWE 1.1 | — | **42,2** (Vorgaenger 3.6-27B: 13,3) |
| OSWorld-Verified | — | **84,3** |
**Einzelschuss gleichauf, agentisch ueber viele Schritte zieht der Coder davon**
und genau das ist der Betrieb hier. Alle Zahlen stammen von Qwen selbst, teils
aus eigenen Benchmark-Fassungen; sie sind ein Indiz, kein Beweis.
**Der Preis, gemessen:** dieselbe Leseaufgabe brauchte mit `fast` **24,3 s**,
mit `coder` **89,3 s** — 3,7× laenger in der Wanduhr. Der Coder ist dicht und
bandbreitenlimitiert (17 GB / 215 GB/s ≈ 12,6 t/s, Hardware-Grenze, kein
Konfigfehler). Er hat ausserdem `ttl=5400`, faellt also nach 90 Minuten aus dem
Speicher und muss neu laden.
**Was noch aussteht:** der echte Vergleich an einer echten Aufgabe.
`F:\Coding Stuff\mc2-referenz` liegt genau dafuer bereit — 17 Dateien, davon 5
erledigt, mit objektivem Pruefbefehl `docs/aufgaben/referenz-check.sh`.
Erst dieser Lauf entscheidet die Frage; bis dahin ist die Modellwahl begruendet,
aber nicht bewiesen.
+96
View File
@@ -0,0 +1,96 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"aibox": {
"npm": "@ai-sdk/openai-compatible",
"name": "AI-Box (ueber MC2 :9001)",
"options": {
"baseURL": "http://192.168.178.151:9001/v1",
"apiKey": "local"
},
"models": {
"fast": {
"name": "fast - Qwen3.6-35B-A3B (MoE) - schnell, fuer Erkunden und Kleinkram",
"tool_call": true,
"limit": {
"context": 65536,
"output": 16384
}
},
"heavy": {
"name": "heavy - Planen / Review",
"tool_call": true,
"limit": {
"context": 32768,
"output": 8192
}
},
"hermes": {
"name": "hermes - Erkunden (immer warm)",
"tool_call": true,
"limit": {
"context": 65536,
"output": 8192
}
},
"coder": {
"name": "coder - Qwen3.8-27B (dicht) - Standard fuers Bauen, agentisch am staerksten",
"tool_call": true,
"limit": {
"context": 131072,
"output": 16384
}
}
}
}
},
"model": "aibox/coder",
"small_model": "aibox/hermes",
"instructions": [
"C:/Users/TobisPC/.config/opencode/ARBEITSANWEISUNG.md"
],
"autoupdate": false,
"agent": {
"plan": {
"model": "aibox/heavy",
"permission": {
"edit": "deny",
"bash": "deny"
}
},
"build": {
"model": "aibox/coder",
"permission": {
"edit": "allow",
"webfetch": "allow",
"bash": {
"*": "allow",
"ssh *": "deny",
"scp *": "deny",
"sftp *": "deny",
"ssh arcane@192.168.178.162 *": "allow",
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
"scp *arcane@192.168.178.162*": "allow"
}
}
},
"explore": {
"mode": "subagent",
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten - nur lesen, laeuft auf dem immer warmen Hirn",
"model": "aibox/hermes",
"permission": {
"edit": "deny",
"bash": "deny"
}
},
"review": {
"mode": "subagent",
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code - meldet Befunde, aendert nichts",
"model": "aibox/heavy",
"permission": {
"edit": "deny",
"bash": "deny"
}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
# OpenCode-Plugin: MC2-Governor
**Hier liegt die Quelle. Der Einsatzort ist eine Kopie.**
```
deploy/opencode-plugin/mc2-governor.ts <- versioniert, hier aendern
| kopieren
v
%USERPROFILE%\.config\opencode\plugin\ <- wird von OpenCode automatisch geladen
```
Bis zum 21.08.2026 existierte diese Datei **nur** im Konfigordner des PCs — 11 KB
verhaltensbestimmender Code ohne Sicherung und ohne Historie. Deshalb liegt sie jetzt hier.
## Ausbringen
```powershell
Copy-Item "deploy\opencode-plugin\mc2-governor.ts" "$env:USERPROFILE\.config\opencode\plugin\" -Force
```
Wirkt sofort in der naechsten OpenCode-Sitzung; OpenChamber muss dafuer nicht neu starten,
eine neue Sitzung reicht.
## Was es tut
1. **Werkzeug-Zaun** (`tool.execute.before`) — blockt Handgriffe, die ein Agent nie
unbeaufsichtigt tun darf. Leitplanke gegen Entgleisen, **keine** Sandbox.
2. **Pruef-Tor + Reparaturschleife** (`session.idle`) — sagt der Agent „fertig", laeuft der
Verify-Befehl aus der Datei `VERIFY` im Repo-Wurzelverzeichnis. ROT geht als naechster
Auftrag zurueck an den Agenten, bis zu `MC2_LOOP_MAX_ROUNDS` mal.
‼️ **Schlaeft derzeit:** es gibt in keinem Projekt eine `VERIFY`-Datei. Ohne sie tut
diese Funktion nichts — schadet aber auch nicht.
3. **Savepoint statt Zusammenfassen** (`session.compacted`) — beim Komprimieren fallen
still die Regeln aus dem Kontext; stattdessen wird `SAVEPOINT.md` geschrieben.
4. **Stimme** — Ereignisse gehen an MC2 `/api/voice/announce`, Lucy spricht sie.
Der zugehoerige **Governor-Proxy auf `:8100`** (Token-Zaehler mit Hart-Deckel, Juli 2026)
**existiert nicht mehr** — kein Port, kein Ordner, keine Unit. Nachgemessen am 21.08.2026.
Dieses Plugin haengt nicht an ihm.
## Schalter (Umgebungsvariablen)
| Variable | Default | Wirkung |
|---|---|---|
| `MC2_BOX_URL` | `http://192.168.178.151:9001` | MC2-Basis fuer Meldungen |
| `MC2_FENCE_OFF` | — | `1` schaltet den **ganzen** Zaun ab (alles oder nichts) |
| `MC2_LOOP_AUTOFIX` | an | `0` schaltet die Reparaturschleife ab |
| `MC2_LOOP_MAX_ROUNDS` | `3` | Runden der Reparaturschleife |
| `MC2_LOOP_ANNOUNCE` | an | `0` macht Lucy stumm |
| `MC2_LOOP_SILENT` | — | `1` = Meldungen ohne Ton |
## Aenderung 21.08.2026: `git push` ist frei
Der Zaun blockte urspruenglich **jedes** `git push` („Veroeffentlichen ist Sache des
Menschen"). Das passt nicht mehr: die Bahn ist jetzt PC → Gitea → Box, und jede Aenderung
soll auch im git landen. Ein Agent, der committen, aber nicht pushen darf, laesst Arbeit
auf einem einzelnen Rechner liegen.
**Erzwungenes Pushen bleibt verboten**`--force`, `--mirror`, `--delete`. Das
ueberschreibt fremde Arbeit auf dem Server und ist genau die Klasse Unfall, gegen die der
Zaun existiert. `--force-with-lease` ist bewusst ausgenommen.
Beides gemessen: `git push --dry-run origin main` laeuft durch,
`git push --force --dry-run origin main` wird mit `[MC2-ZAUN]` abgewiesen.
## ‼️ Falle: das `bash`-Werkzeug ist auf Windows kein bash
Es laeuft in **PowerShell**. `tail`, `head`, `grep` und Freunde gibt es dort nicht, und
Pipes verhalten sich anders. Wer dem Agenten Befehle vorgibt, muss das beruecksichtigen.
## ‼️ Falle: Gitea-Anmeldung ist flatterhaft
Der Git Credential Manager haelt fuer die DDNS-Domain ein **OAuth-Token mit einer Stunde
Laufzeit**. Laeuft es ab, scheitert der Push des Agenten mit
`remote: Failed to authenticate user` — ein Push aus PowerShell erneuert es, danach geht es
wieder. Dauerhafte Loesung waere ein langlebiges Gitea-Token auf die **interne** Adresse
`http://192.168.178.153:3000` (die DDNS-Domain ist nachts durch die Zwangstrennung ohnehin
zeitweise tot).
---
## ‼️ ABGESCHALTET am 22.08.2026
**Das Plugin ist nicht mehr aktiv.** Es liegt hier als Quelle und im Konfigordner
unter `plugin-archiv/`, aber nicht mehr in `~/.config/opencode/plugin/`.
**Warum:** Beim ersten echten Arbeitslauf fiel auf, dass bei jeder Komprimierung
zwei Dinge uebereinander passierten — OpenChamber dampfte den Verlauf ein, und der
Governor schob sofort einen zusaetzlichen Auftrag nach („schreib SAVEPOINT.md").
Der Agent verlor dadurch jedes Mal einen Zug an Buchhaltung statt an die Aufgabe.
Nachgewiesen: `SAVEPOINT.md` in `mc2-referenz`, geschrieben 22.08. 14:47 mitten
im Lauf.
Der User hatte den Abbau schon am 21.08. angeordnet („alle Guards, alle
Governor, Stewards — brauchen wir nicht mehr"). Ich hatte damals das Behalten
empfohlen und seine Antwort zum `git push`-Riegel als Zustimmung zum Rest
gewertet. **Das war eine Annahme, keine Entscheidung.**
## Was damit wegfaellt — bewusst
| war | jetzt |
|---|---|
| Sperrliste: `rm -rf`, `sudo`, force push, `curl\|sh`, `ssh` ausser Arcane, `npm publish` | **kein Netz mehr** — der Agent darf alles, was die Shell hergibt |
| Pruef-Tor + Reparaturschleife (`VERIFY`) | schlief ohnehin, es gab nirgends eine `VERIFY`-Datei im PC-Baum |
| SAVEPOINT beim Komprimieren | weg — genau der Grund fuer den Abbau |
| Meldungen an Lucys Stimme | weg |
**Wiederherstellen** (wenn der fehlende Zaun sich raecht):
```powershell
Copy-Item "deploy\opencode-plugin\mc2-governor.ts" "$env:USERPROFILE\.config\opencode\plugin\"
```
Plugins werden beim **Start des OpenCode-Servers** geladen — Aendern wirkt erst
nach einem Neustart von OpenChamber, nicht sofort. Deshalb liess sich das Plugin
auch mitten in einem laufenden Referenzlauf gefahrlos entfernen.
+255
View File
@@ -0,0 +1,255 @@
/**
* MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten.
*
* Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die
* Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden
* Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge:
*
* 1. WERKZEUG-ZAUN (tool.execute.before)
* Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push,
* Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser
* Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 —
* nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen.
*
* 2. PRUEF-TOR + SCHLEIFE (session.idle)
* Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei
* `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler
* geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu
* MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse.
*
* 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted)
* Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper
* "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md
* zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat.
*
* 4. STIMME (MC2 /api/voice/announce)
* Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten.
* Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code.
*
* Schalter (Umgebungsvariablen):
* MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001)
* MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an)
* MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3)
* MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an)
* MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen
* MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle)
*
* Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt —
* am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser.
*/
const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001"
const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0"
const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10)
const SILENT = process.env.MC2_LOOP_SILENT === "1"
const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0"
const FENCE_OFF = process.env.MC2_FENCE_OFF === "1"
/**
* Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile —
* ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt.
* Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen
* Entgleisen, keine Abwehr gegen einen boesartigen Akteur.
*/
const FENCE: Array<{ rx: RegExp; why: string }> = [
// 21.08.2026: normales `git push` ist FREI. Der Weg ist jetzt PC -> Gitea -> Box, und
// jede Aenderung soll auch im git landen — ein Agent, der committen, aber nicht pushen
// darf, laesst Arbeit auf einem einzelnen Rechner liegen. Was schlecht ist, faengt
// hinterher die CI-Ampel; dieser Zaun muss es nicht vorher verhindern.
// Erzwungenes Pushen bleibt verboten: das ueberschreibt fremde Arbeit auf dem Server
// und ist genau die Klasse Unfall, gegen die der Zaun ueberhaupt existiert.
{ rx: /\bgit\s+push\b[^\n]*(--force(?!-with-lease)|\s-f\b|--mirror\b|--delete\b)/,
why: "git push --force/--mirror/--delete — ueberschreibt fremde Arbeit auf dem Server. Normales `git push` ist erlaubt; muss wirklich erzwungen werden, sag es dem Menschen." },
{ rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." },
{ rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." },
{ rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." },
{ rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." },
{ rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." },
{ rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." },
{ rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." },
{ rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." },
{ rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." },
{ rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." },
]
/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */
const rounds = new Map<string, number>()
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
const busy = new Set<string>()
async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") {
if (!ANNOUNCE_ON) return
try {
await fetch(`${BOX_URL}/api/voice/announce`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject,
text,
source: "loop",
priority: SILENT ? "silent" : priority,
}),
signal: AbortSignal.timeout(4000),
})
} catch {
/* best effort — eine stumme Lucy darf den Bau nie aufhalten */
}
}
export const MC2Governor = async ({ client, $, directory, worktree }: any) => {
const root: string = worktree || directory || process.cwd()
/**
* Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS.
* Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und
* nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig.
*/
async function readVerify(): Promise<string | null> {
try {
const { readFile } = await import("node:fs/promises")
const { join } = await import("node:path")
const raw = await readFile(join(root, "VERIFY"), "utf8")
const cmd = raw
.split("\n")
.map((l: string) => l.trim())
.filter((l: string) => l && !l.startsWith("#"))
.join(" && ")
return cmd || null
} catch {
return null
}
}
/**
* Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt.
* `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales
* `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen.
* Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt.
*/
async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> {
try {
const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet()
const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}`
return { ok: res.exitCode === 0, out: out.slice(-4000) }
} catch (e: any) {
return { ok: false, out: String(e?.message ?? e).slice(-4000) }
}
}
/** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */
async function sendPrompt(sessionID: string, text: string): Promise<boolean> {
try {
await client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text }] },
})
return true
} catch {
return false
}
}
return {
// ── 1. Werkzeug-Zaun ───────────────────────────────────────────────────
"tool.execute.before": async (input: any, output: any) => {
if (FENCE_OFF) return
if (input?.tool !== "bash") return
const cmd: string = output?.args?.command ?? ""
if (!cmd) return
for (const rule of FENCE) {
if (rule.rx.test(cmd)) {
await announce(
"[Zaun]",
`Ich habe einen Befehl geblockt: ${rule.why}`,
"silent",
)
// Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem
// Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders.
throw new Error(
`[MC2-ZAUN] Blockiert: ${rule.why}\n` +
`Befehl war: ${cmd}\n` +
`Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` +
`er macht es selbst.`,
)
}
}
},
// ── 2.-4. Ereignisse ───────────────────────────────────────────────────
event: async ({ event }: any) => {
const type: string = event?.type ?? ""
const props: any = event?.properties ?? event ?? {}
const sessionID: string = props.sessionID || props.sessionId || props.id || ""
// ── Savepoint statt Zusammenfassen ──────────────────────────────────
if (type === "session.compacted" || type === "experimental.session.compacting") {
await announce(
"[Sitzung]",
"Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.",
"silent",
)
if (sessionID) {
await sendPrompt(
sessionID,
"[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " +
"Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " +
"erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " +
"Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.",
)
}
return
}
// ── Pruef-Tor + Selbstreparatur ─────────────────────────────────────
if (type !== "session.idle" || !sessionID) return
if (busy.has(sessionID)) return
const cmd = await readVerify()
if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus.
busy.add(sessionID)
try {
const { ok, out } = await runVerify(cmd)
const round = rounds.get(sessionID) ?? 0
if (ok) {
rounds.delete(sessionID)
await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal")
return
}
if (!AUTOFIX || round >= MAX_ROUNDS) {
rounds.delete(sessionID)
await announce(
"[Pruefung]",
`Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` +
`Hier komme ich allein nicht weiter, Commander.`,
"normal",
)
return
}
rounds.set(sessionID, round + 1)
await announce(
"[Pruefung]",
`Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`,
"silent",
)
await sendPrompt(
sessionID,
`[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` +
`Projekts ist fehlgeschlagen.\n\n` +
`Befehl: ${cmd}\n\n` +
`Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` +
`Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` +
`Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` +
`(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`,
)
} finally {
busy.delete(sessionID)
}
},
}
}
export default MC2Governor
+91
View File
@@ -0,0 +1,91 @@
<#
push-und-sync.ps1 — der Handschlag zwischen PC und AI-Box (21.08.2026).
Bahn: PC (Quelle der Wahrheit) -> Gitea -> Box ~/projekte
Warum ueberhaupt ein Skript: `projekte-sync` laeuft auf der Box stuendlich. Wer
gerade gepusht hat, will aber nicht bis zu 60 Minuten warten. Das hier stoesst
denselben Dienst sofort an — kein zweiter Mechanismus, nur ein Ausloeser.
Warum PowerShell und nicht Git-Bash: Push zum Gitea geht vom PC nur ueber den
Git Credential Manager, und den gibt es hier (Lehre vom 24.07.).
Aufruf (im Projektordner):
.\deploy\push-und-sync.ps1
.\deploy\push-und-sync.ps1 -NurSync # nicht pushen, nur Box nachziehen
.\deploy\push-und-sync.ps1 -Pfad "F:\Coding Stuff\rippy"
#>
[CmdletBinding()]
param(
[string] $Pfad = (Get-Location).Path,
[switch] $NurSync,
[string] $BoxHost = "hitonabi@192.168.178.151"
)
$ErrorActionPreference = "Stop"
function Schritt($text) { Write-Host "`n== $text" -ForegroundColor Cyan }
function Gut($text) { Write-Host " $text" -ForegroundColor Green }
function Warn($text) { Write-Host " $text" -ForegroundColor Yellow }
Set-Location $Pfad
# --- 1. Ist das ueberhaupt ein Repo, und was steht an? ---------------------
$null = git rev-parse --is-inside-work-tree 2>$null
if ($LASTEXITCODE -ne 0) { throw "Kein Git-Repository: $Pfad" }
$zweig = (git branch --show-current).Trim()
# Repo-Name aus der Remote-URL, NICHT aus dem Ordnernamen: der lokale Ordner heisst
# z. B. "mission-control-2", das Gitea-Repo aber "mission-control-v2". Der Ordnername
# haette die Box-Abfrage still ins Leere laufen lassen.
$remote = (git remote get-url origin).Trim()
$repo = [System.IO.Path]::GetFileNameWithoutExtension(($remote -split '/')[-1])
Schritt "Repo '$repo', Zweig '$zweig'"
$offen = git status --porcelain
if ($offen) {
Warn "Es liegen unversionierte/ungespeicherte Aenderungen:"
$offen | Select-Object -First 10 | ForEach-Object { Warn " $_" }
Warn "Diese werden NICHT mitgeschickt. Erst committen, dann erneut aufrufen."
}
# --- 2. Pushen ------------------------------------------------------------
if (-not $NurSync) {
$ausstehend = git log --oneline "origin/$zweig..HEAD" 2>$null
if (-not $ausstehend) {
Gut "Nichts zu pushen — Gitea ist bereits auf Stand."
} else {
Schritt "Push nach Gitea ($(($ausstehend | Measure-Object).Count) Commit(s))"
$ausstehend | ForEach-Object { Write-Host " $_" }
git push origin $zweig
if ($LASTEXITCODE -ne 0) {
throw @"
Push fehlgeschlagen.
Seit 21.08. laeuft der Weg ueber SSH: ssh://gitea@192.168.178.153:2222/...
Pruefen mit: ssh -T gitea
Kommt dort keine Begruessung, lauscht Giteas SSH-Server nicht (Port 2222,
START_SSH_SERVER in /etc/gitea/app.ini) oder id_general ist nicht hinterlegt.
Der SSH-Benutzer heisst 'gitea', NICHT 'git' - Gitea laeuft unter diesem Namen.
"@
}
Gut "gepusht"
}
}
# --- 3. Box nachziehen lassen --------------------------------------------
Schritt "Box zieht nach (projekte-sync)"
ssh $BoxHost "systemctl --user start projekte-sync.service"
if ($LASTEXITCODE -ne 0) { throw "Sync-Dienst liess sich nicht starten." }
$stand = ssh $BoxHost "cd ~/projekte/$repo 2>/dev/null && git log --oneline -1 || echo 'NICHT-IN-PROJEKTE'"
if ($stand -eq "NICHT-IN-PROJEKTE") {
Warn "'$repo' liegt nicht unter ~/projekte."
Warn "Bei mission-control-v2 ist das ABSICHT: das Live-Deployment liegt in ~/mission-control-v2"
Warn "und wird bewusst nicht automatisch gezogen — es braucht seinen eigenen Deploy-Schritt."
} else {
Gut "Box steht jetzt auf: $stand"
}
Write-Host ""
+6 -4
View File
@@ -15,11 +15,13 @@ set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}" MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
DEST_DIR="$MODELS_DIR/mc2-backups" DEST_DIR="$MODELS_DIR/mc2-backups"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}"
LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}" LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}" HERMES="${HERMES_HOME:-$HOME/.hermes}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}" SRC="${MC2_SRC:-$HOME/mission-control-v2}"
SERVICES="mem0-service mission-control-2 hermes-gateway" # Alle User-Dienste, die von wiederhergestellter Config abhaengen. mc2-gateway (der
# /v1-Denkpfad) und mc2-steward (Waechter) fehlten hier bis 27.08.2026 — nach einem
# Restore liefen beide mit der ALTEN llama-swap-/Hermes-Config weiter.
SERVICES="mission-control-2 mc2-gateway mc2-steward hermes-gateway"
OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}" OFFSITE="${MC_BACKUP_OFFSITE:-root@192.168.178.108:/var/lib/vz/mc2-backups}"
OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}" OFFSITE_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
OFFSITE_SSH="ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" OFFSITE_SSH="ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
@@ -98,7 +100,6 @@ echo "--- Inhalt ---"; cat "$STAGE/MANIFEST.txt" 2>/dev/null || true; echo "----
if [ "$DRY" = 1 ]; then if [ "$DRY" = 1 ]; then
echo "[dry-run] würde zurücksetzen:" echo "[dry-run] würde zurücksetzen:"
[ -d "$STAGE/mem0" ] && echo " • mem0 → $MEM0_DIR (wird ersetzt)"
[ -f "$STAGE/hermes/config.yaml" ] && echo "$HERMES/config.yaml" [ -f "$STAGE/hermes/config.yaml" ] && echo "$HERMES/config.yaml"
[ -f "$STAGE/hermes/.env" ] && echo "$HERMES/.env" [ -f "$STAGE/hermes/.env" ] && echo "$HERMES/.env"
[ -d "$STAGE/hermes/plugins" ] && echo "$HERMES/plugins/" [ -d "$STAGE/hermes/plugins" ] && echo "$HERMES/plugins/"
@@ -119,7 +120,8 @@ bash "$SRC/deploy/backup.sh" || echo " (Pre-Restore-Backup fehlgeschlagen — f
echo "→ Dienste stoppen…" echo "→ Dienste stoppen…"
systemctl --user stop $SERVICES 2>/dev/null || true systemctl --user stop $SERVICES 2>/dev/null || true
if [ -d "$STAGE/mem0" ]; then rm -rf "$MEM0_DIR"; mkdir -p "$MEM0_DIR"; cp -a "$STAGE/mem0/." "$MEM0_DIR/"; fi # Alte Snapshots (vor 27.08.2026) tragen noch ein Gedaechtnis-Verzeichnis mit — es wird
# bewusst NICHT mehr zurueckgespielt, der zugehoerige Dienst existiert nicht mehr.
[ -f "$STAGE/hermes/config.yaml" ] && { mkdir -p "$HERMES"; cp -a "$STAGE/hermes/config.yaml" "$HERMES/"; } [ -f "$STAGE/hermes/config.yaml" ] && { mkdir -p "$HERMES"; cp -a "$STAGE/hermes/config.yaml" "$HERMES/"; }
[ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/" [ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/"
[ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; } [ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; }
+6 -11
View File
@@ -3,15 +3,14 @@
# dass der komplette Inferenz-Stack noch FUNKTIONIERT — nicht nur "Befehl lief durch". # dass der komplette Inferenz-Stack noch FUNKTIONIERT — nicht nur "Befehl lief durch".
# Exit 0 = alles ok, sonst 1 → der jobengine-Job wird im UI ROT (state=failed). # Exit 0 = alles ok, sonst 1 → der jobengine-Job wird im UI ROT (state=failed).
# #
# Pendant zu hermes-postcheck.sh (prüft das Gehirn/Mem0); dieser prüft Router+Engine+MC2 # Pendant zu hermes-postcheck.sh (prüft das Gehirn); dieser prüft Router+Engine+MC2
# inkl. einer ECHTEN 1-Token-Inferenz (beweist, dass ein Modell wirklich lädt & generiert). # inkl. einer ECHTEN 1-Token-Inferenz (beweist, dass ein Modell wirklich lädt & generiert).
set -uo pipefail set -uo pipefail
SWAP_URL="${MC_LLAMA_SWAP_URL:-http://127.0.0.1:8080}" SWAP_URL="${MC_LLAMA_SWAP_URL:-http://127.0.0.1:8080}"
MC_URL="${MC_URL:-http://127.0.0.1:9001}" MC_URL="${MC_URL:-http://127.0.0.1:9001}"
MEM0_URL="${MEM0_SERVICE_URL:-http://127.0.0.1:8765}"
BRAIN="${MC_WARMUP_MODELS:-fast}"; BRAIN="${BRAIN%% *}" # erstes Modell, falls Liste BRAIN="${MC_WARMUP_MODELS:-fast}"; BRAIN="${BRAIN%% *}" # erstes Modell, falls Liste
EMBED="${MC_WARMUP_EMBED:-embed}"; EMBED="${EMBED%% *}" # Embedding-Modell (Mem0/Gedächtnis) EMBED="${MC_WARMUP_EMBED:-embed}"; EMBED="${EMBED%% *}" # Embedding-Modell (Reranker-Stufe)
fail=0 fail=0
echo "=== Stack Post-Update: Funktionsprüfung ===" echo "=== Stack Post-Update: Funktionsprüfung ==="
@@ -48,14 +47,14 @@ else
echo "FAIL · Inferenz auf '$BRAIN' fehlgeschlagen (Modell lädt/generiert nicht)"; fail=1 echo "FAIL · Inferenz auf '$BRAIN' fehlgeschlagen (Modell lädt/generiert nicht)"; fail=1
fi fi
# 3b. Embedding-Modell (für Mem0/Gedächtnis) lädt und liefert einen Vektor? # 3b. Embedding-Modell lädt und liefert einen Vektor?
eresp="$(curl -s -m 120 -X POST "$SWAP_URL/v1/embeddings" \ eresp="$(curl -s -m 120 -X POST "$SWAP_URL/v1/embeddings" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "{\"model\":\"$EMBED\",\"input\":\"ping\"}" 2>/dev/null)" -d "{\"model\":\"$EMBED\",\"input\":\"ping\"}" 2>/dev/null)"
if printf '%s' "$eresp" | grep -q '"embedding"'; then if printf '%s' "$eresp" | grep -q '"embedding"'; then
echo "PASS · Embedding-Modell '$EMBED' liefert Vektoren" echo "PASS · Embedding-Modell '$EMBED' liefert Vektoren"
else else
echo "FAIL · Embedding-Modell '$EMBED' lädt/antwortet nicht (Mem0/Gedächtnis betroffen)"; fail=1 echo "FAIL · Embedding-Modell '$EMBED' lädt/antwortet nicht"; fail=1
fi fi
# 4. MC2 selbst gesund (Engine + Gateway erreichbar)? # 4. MC2 selbst gesund (Engine + Gateway erreichbar)?
@@ -85,12 +84,8 @@ if systemctl --user is-enabled --quiet mc2-steward 2>/dev/null; then
fi fi
fi fi
# 5. Mem0-Sidecar (Gedächtnis) erreichbar? # (Schritt 5 „Gedächtnis-Sidecar erreichbar" entfiel am 27.08.2026: der Dienst auf :8765 ist
if curl -sf -m 5 "$MEM0_URL/health" >/dev/null 2>&1; then # seit der Ablösung im August tot, der Check konnte nur noch rot werden. docs/wissen/VERDIKTE.md)
echo "PASS · Mem0-Sidecar erreichbar"
else
echo "FAIL · Mem0-Sidecar NICHT erreichbar"; fail=1
fi
if [ "$fail" -eq 0 ]; then if [ "$fail" -eq 0 ]; then
echo "=== STACK OK ✓ ===" echo "=== STACK OK ✓ ==="
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Venv-Audit (monatlich, 17.07.2026): die Python-Nebendienste (Mem0, Voice) altern # Venv-Audit (monatlich, 17.07.2026): die Python-Nebendienste (Voice) altern
# eingefroren — kein Auto-Update-Kanal deckt sie ab. Dieser Audit MELDET nur # eingefroren — kein Auto-Update-Kanal deckt sie ab. Dieser Audit MELDET nur
# (bewusst kein Auto-Update: Python-Deps brechen gern): # (bewusst kein Auto-Update: Python-Deps brechen gern):
# je venv: veraltete Pakete zählen (pip list --outdated) + bekannte # je venv: veraltete Pakete zählen (pip list --outdated) + bekannte
@@ -9,7 +9,7 @@
# idempotent je Monat). Läuft als Hermes-Cron --no-agent (stdout = Telegram). # idempotent je Monat). Läuft als Hermes-Cron --no-agent (stdout = Telegram).
set -uo pipefail set -uo pipefail
VENVS=("$HOME/.mem0/venv:mem0" "$HOME/.voice/venv:voice") VENVS=("$HOME/.voice/venv:voice")
AUDIT_VENV="$HOME/.venv-audit" AUDIT_VENV="$HOME/.venv-audit"
HERMES="$HOME/.local/bin/hermes" HERMES="$HOME/.local/bin/hermes"
MONAT="$(date '+%Y-%m')" MONAT="$(date '+%Y-%m')"
+33
View File
@@ -0,0 +1,33 @@
# systemd-USER-Unit für den MC2 Voice-Sidecar — lokales STT + gestuftes TTS.
#
# ‼️ NACHGETRAGEN 27.08.2026: Diese Unit lief auf der Box, fehlte aber im Repo — weder
# versioniert noch vom Disaster-Recovery abgedeckt, obwohl services/maintenance.py
# (USER_SERVICES) und routers/system.py sie voraussetzen. Inhalt 1:1 von der laufenden Box
# übernommen (`systemctl --user cat voice-service`).
#
# Ablage: ~/.config/systemd/user/voice-service.service ; dann:
# systemctl --user daemon-reload && systemctl --user enable --now voice-service
[Unit]
Description=MC2 Voice Sidecar — lokales STT (faster-whisper) + gestuftes TTS (Piper/Chatterbox)
After=network.target
[Service]
# Eigenes Python-3.12-venv (~/.voice/venv) — torch/chatterbox/faster-whisper passen nicht ins
# 3.14-Backend-venv. Bind 127.0.0.1: nur lokal; MC2 proxyt nach außen (routers/voice.py).
Type=simple
WorkingDirectory=%h/mission-control-v2/voice_service
Environment=VOICE_PORT=8650
Environment=VOICE_STT_MODEL=medium
Environment=VOICE_STT_LANG=de
Environment=VOICE_PIPER_DIR=%h/.voice/voices
Environment=VOICE_PIPER_DEFAULT=de_DE-thorsten-medium
Environment=VOICE_CHATTERBOX_DEVICE=cpu
Environment=VOICE_CHATTERBOX_LANG=de
Environment=TOKENIZERS_PARALLELISM=false
ExecStart=%h/.voice/venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8650
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+1 -1
View File
@@ -4,7 +4,7 @@
# Eingehängt als ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh in llama-swap. # Eingehängt als ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh in llama-swap.
# #
# `fast` = das Agent-Hirn (Qwen3.6-35B-A3B), `embed` = das Embedding-Modell # `fast` = das Agent-Hirn (Qwen3.6-35B-A3B), `embed` = das Embedding-Modell
# (Qwen3-Embedding-0.6B, für Mem0/Gedächtnis). Die Augen (vision/VL-30B) sind seit 07.07. # (Qwen3-Embedding-0.6B). Die Augen (vision/VL-30B) sind seit 07.07.
# ON-DEMAND (User-Entscheid) und gehören NICHT mehr ins Warm-Set — die Lucy-App wärmt sie # ON-DEMAND (User-Entscheid) und gehören NICHT mehr ins Warm-Set — die Lucy-App wärmt sie
# beim Start selbst an. Modelle werden NICHT automatisch zusammen geladen — jedes einzeln # beim Start selbst an. Modelle werden NICHT automatisch zusammen geladen — jedes einzeln
# anstoßen, sonst bleibt es kalt. # anstoßen, sonst bleibt es kalt.
+418
View File
@@ -0,0 +1,418 @@
# Umbau: OpenChamber als Coding-Bahn, Lucy bleibt SysAdmin
_Entschieden 21.08.2026. Rückbau der Hermes-Coding-Bahn ist **erledigt**, der Aufbau von
OpenChamber steht aus. Diese Datei ist die Übergabe an die nächste Sitzung._
## Die Entscheidung
**Zwei getrennte Bahnen** — der Weg, der bei diesem Stack schon einmal funktioniert hat:
| Bahn | Werkzeug | Zweck |
|---|---|---|
| **Betrieb** | Hermes-Runtime (Lucy) | SysAdmin, Telegram, Crons, Gedächtnis, Skills, Stimme, PC-Steuerung |
| **Coding** | **OpenChamber** über OpenCode | Nur programmieren. Getrennt von Lucy. |
| **Motor** | llama-swap `:8080` / MC2-Gateway `:9010` | beide Bahnen teilen sich die Modelle |
| **Steuerpult** | MC2 `:9001` | unverändert |
**Warum getrennt:** Der Versuch, beides in Hermes Desktop zu vereinen, ist an zwei Dingen
gescheitert — die Gateway-Registry akzeptiert kein Benutzer/Passwort (nur Session-Token oder
OAuth, beides für eine selbstgehostete Box ungeeignet), und der Sessions-Reiter fiel dadurch
immer auf „This device" zurück. Details in der Gedächtnisnotiz vom 20./21.08.
## Was bereits erledigt ist (21.08.)
**PC:**
- Hermes Desktop + CLI deinstalliert (`hermes uninstall --full`), Reste von Hand entfernt
- `AppData\Local\hermes`, `AppData\Roaming\Hermes`, `~\.hermes` — alle weg
- Startmenü- und Desktop-Verknüpfungen entfernt
- Env-Variablen bereinigt; **`HERMES_PC_TOKEN` bleibt bewusst** (Lucys PC-Executor auf `:7777`)
- Node v24.19.0 in `Program Files` ist unabhängig → bleibt, erfüllt OpenChambers Anforderung (≥22)
**Box:**
- Bot-Profile `coder` + `debugger` entfernt (gesichert in `~/archiv-aufraeumen-20260820/`)
- `~/.hermes/desktop-ssh/` entfernt, `desktop-auth.env` entfernt
- ufw: Port **9119 wieder zu** — nur noch `22 · 9001 · 8080 · 7681`
- Dashboard bleibt auf `0.0.0.0:9119` gebunden **mit Anmeldung**, damit MC2s `/hermes-ui`-Proxy
nicht mehr passwortlos aus dem LAN erreichbar ist (das war ein offenes Scheunentor).
Zugang: `~/.hermes/dashboard-login.txt` (0600), Benutzer `commander`.
‼️ Zugangsdaten stehen **nur** in `config.yaml` — die frühere `EnvironmentFile` hat sie still
überstimmt und kostete eine Fehlersuche. Eine Quelle, eine Wahrheit.
**Verifiziert nach dem Rückbau:** 5 Dienste aktiv · 4 MCP-Server · 4 Crons · Telegram `connected`
· PC-Executor HTTP 200. Lucy ist vollständig funktionsfähig.
## Was aufzubauen ist
### 1. OpenCode auf der Box
Wurde beim Juli-Rückbau entfernt (`command -v opencode` → leer). Muss neu.
```bash
# Installation (Box)
curl -fsSL https://opencode.ai/install | bash
# Provider auf den lokalen Motor zeigen lassen: ~/.config/opencode/opencode.json
# base_url http://127.0.0.1:9010/v1 (MC2-Gateway, kann model:auto)
# oder http://127.0.0.1:8080/v1 (llama-swap direkt)
# api_key local
```
Als systemd-**User**-Dienst (wie alle anderen — reboot-fest, sudo-frei):
```
opencode serve --hostname 0.0.0.0 --port 4096
```
Danach `ufw allow from 192.168.178.0/24 to any port 4096 proto tcp`.
### 2. OpenChamber auf dem PC
Desktop-Version von den GitHub-Releases (`openchamber/openchamber`, MIT, ~9,1k ★).
**Wichtig:** Die Desktop-Variante bringt eine eigene OpenCode-CLI mit — für den Remote-Betrieb
muss sie ausgeschaltet werden:
```
OPENCODE_HOST = http://192.168.178.151:4096 ← volle Adresse MIT Port, OHNE Pfad
OPENCODE_SKIP_START = true
```
> Fehlt der Port oder hängt ein Pfad dran, **ignoriert OpenChamber die Variable und startet
> stillschweigend seinen eigenen Server.** Genau diese Klasse Fehler hat uns bei Hermes einen
> halben Tag gekostet.
Modelle, Provider und Schlüssel liegen in **OpenCode**, nicht in OpenChamber.
### 3. Modellwahl — nicht wieder den dichten Coder
★★ **`fast` (Qwen3.6-35B-A3B) nehmen, nicht `coder` (Qwen3.8-27B).** Gemessen:
| | Ausgabe | Eingabe | SWE-bench Verified |
|---|---|---|---|
| `fast` Qwen3.6-35B-A3B (MoE) | **95,7 t/s** | 221 t/s | **73,4** |
| `coder` Qwen3.8-27B (dicht) | 12,6 t/s | 144 t/s | keine agentische Messung |
Das dichte Modell ist bandbreitenlimitiert (17 GB ÷ 215 GB/s ≈ 12,6 t/s = Hardware-Limit).
Der MoE ist **schneller und besser**.
### 4. Die Arbeitsanweisung mitnehmen — der wichtigste Fund überhaupt
Der Agent las in Lauf A eine ganze Nacht lang und schrieb **null Dateien**. Mit dieser Zeile
im Auftrag schrieb er in 24 Minuten fünf:
> **„Arbeite Datei für Datei. Lies HÖCHSTENS zwei Dateien, bevor du die erste änderst.
> Kein Gesamtplan, keine Vollinventur. Eine halbfertige Änderung ist wertvoller als eine
> vollständige Analyse ohne Änderung."**
Gehört in OpenChambers System-Prompt bzw. `AGENTS.md` der Projekte.
## Bekannte Fallen (aus dem Juli-Audit + heute)
- **Bus-Faktor 1** bei OpenChamber: ~7080 % der Commits von einer Person. Version **pinnen**,
nicht blind auto-updaten.
- **Windows-Installer unsigniert** → SmartScreen-Warnung ist erwartbar, kein Alarm.
- Nie ohne `--ui-password` über localhost hinaus binden.
- `opencode web` (nicht OpenChamber!) hat einen kaputten Projekt-Finder für `$HOME` — falls
irgendwo ein leerer Projektbaum auftaucht, ist das der Grund.
- Git-Push zum Gitea geht vom PC **nur über PowerShell/GCM**, nicht aus der Git-Bash.
## Erster Test nach dem Aufbau
Die Referenzaufgabe liegt bereit und ist halb erledigt — ideal zum Vergleich:
```
Worktree: ~/projekte/mc2-referenz (Zweig referenz/mem0-ausbau)
Aufgabe: docs/aufgaben/referenzaufgabe-mem0-ausbau.md
Prüfung: bash docs/aufgaben/referenz-check.sh
Stand: 5 von 17 Dateien erledigt (Hermes-Lauf A2, abgebrochen durch SSH-Fehler)
```
Damit lässt sich direkt vergleichen: **Wie lange braucht OpenChamber + `fast` für dieselben
17 Dateien?** Hermes + dichter Coder brauchte hochgerechnet ~80 Minuten; die Rechnung für
`fast` sagt ~30.
---
## Stand nach dem Aufbau (21.08.2026)
**Beide Bahnen stehen. Der Aufbau ist erledigt, der Referenzlauf steht aus.**
### Box (`192.168.178.151`)
| Sache | Wert |
|---|---|
| OpenCode | **1.18.20**, `~/.opencode/bin/opencode` (eigenständiges Binary, kein Node nötig) |
| Dienst | `opencode-server.service` (systemd **user**, `enable`d, Linger=yes → reboot-fest) |
| Lauscht | `0.0.0.0:4096`, Arbeitsverzeichnis `~/projekte` |
| ufw | Regel 5: `4096/tcp ALLOW IN 192.168.178.0/24` |
| Provider | `box``http://127.0.0.1:9010/v1` (MC2-Gateway), `apiKey: local` |
| Modelle | `box/fast` (Standard) · `box/heavy` · `box/hermes` (small_model) · `box/coder` (nur Vergleich) |
| Autoupdate | `false` in `opencode.json` |
Konfiguration: `~/.config/opencode/opencode.json`.
**Nur Rollen-Aliase**, keine Modell-Eigennamen — die Konsolidierung wirft Eigennamen sonst raus (Regel vom 20.08.).
### Arbeitsanweisung — global verdrahtet
Liegt als `~/.config/opencode/ARBEITSANWEISUNG.md` und ist über `instructions` in
`opencode.json` in **jeder** Sitzung aktiv, nicht nur in Projekten mit `AGENTS.md`.
Gegengeprüft: „Wie viele Dateien höchstens?" → `2`. `~` wird von OpenCode expandiert.
### PC
| Sache | Wert |
|---|---|
| OpenChamber | **v1.19.0** (18.08.), `%LOCALAPPDATA%\Programs\@openchamberelectron` |
| Signatur | `NotSigned` — erwartet, kein Alarm |
| `OPENCODE_HOST` | `http://192.168.178.151:4096` (User-Env, mit Port, ohne Pfad) |
| `OPENCODE_SKIP_START` | `true` (User-Env) |
**Verifiziert:** OpenChamber hält 8 offene TCP-Verbindungen zur Box auf `:4096`,
startet **keinen** eigenen OpenCode (kein lokaler Prozess, kein lokaler Port).
Damit ist genau der Punkt genommen, an dem Hermes Desktop gescheitert ist.
**Auto-Update:** kein Riegel nötig. Im Bundle steht `autoUpdater.autoDownload = false` und
`autoInstallOnAppQuit = false` — OpenChamber lädt nie von selbst, es meldet nur und wartet
auf einen Klick. Die Version ist damit faktisch gepinnt.
### ‼️ Offene Sicherheitsentscheidung — bewusst so gewählt
Der Server läuft **ohne Anmeldung** im LAN. Beim Start meldet er selbst:
```
Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
```
Praktisch heißt das: jedes Gerät im `192.168.178.0/24` kann über die API beliebige
Befehle als `hitonabi` auf der Box ausführen — dieselbe Klasse Loch wie das passwortlose
Dashboard auf `:9119`, das am 20.08. zugemacht wurde.
★★ **Ein Riegel wäre da und kostet nichts:** OpenChamber schickt HTTP-Basic-Auth mit, wenn
`OPENCODE_SERVER_PASSWORD` gesetzt ist (Benutzer aus `OPENCODE_SERVER_USERNAME`,
Standard `opencode`). Server-Variable in der systemd-Unit, gleiche Variable als User-Env
auf dem PC — **kein Tunnel, keine Umstellung, der Port bleibt offen im LAN.**
Der User hat am 21.08. **bewusst dagegen entschieden** („so lassen"). Nicht neu aufrollen,
aber hier notiert, damit es kein stiller Defekt bleibt.
### Nächster Schritt: der Referenzlauf
Alles steht bereit, nichts wurde am Prüfstand angefasst:
```
Worktree: ~/projekte/mc2-referenz (Zweig referenz/mem0-ausbau)
Stand: 5 von 17 Dateien erledigt, referenz-check.sh sagt ROT
Prüfung: bash docs/aufgaben/referenz-check.sh
```
Die Arbeitsanweisung wirkt dort über die **globale** Instruktion — `AGENTS.md` im Worktree
wurde absichtlich **nicht** angefasst, damit der Vergleich zu Lauf A/A2 sauber bleibt.
Zu messen: **Wie lange braucht OpenChamber + `fast` für dieselben 17 Dateien?**
Hermes + dichter `coder` brauchte hochgerechnet ~80 Minuten; die Rechnung für `fast` sagt ~30.
### ‼️ Falle: der Ordner-Knopf reicht Windows-Pfade an die Box durch
**Beim ersten Lauf sofort hineingetappt (21.08., ~1 h verloren).** Die Sitzung stand auf:
```
/home/hitonabi/projekte/F:\Coding Stuff\mission-control-2
```
OpenChamber hatte den **Windows-Pfad des PCs** an den Box-Server durchgereicht, der ihn hinten
an sein `WorkingDirectory` klebte. Das Verzeichnis existiert auf der Box nicht → der Agent hat
kein Arbeitsverzeichnis, es geht **keine einzige Anfrage** an den Motor raus. In der Oberfläche
sieht das aus wie „hängt": Nachricht steht da, eine Zusammenfassung erscheint, danach nichts.
`tokens: 0/0`.
**Ursache, im Bundle nachgesehen:** OpenChamber öffnet Ordner über Electrons natives
`showOpenDialog` — den **lokalen** Windows-Dateibrowser. Einen Fern-Auswähler gibt es nicht.
Im Fernbetrieb liefert dieser Knopf also *immer* einen unbrauchbaren Pfad.
**Der richtige Weg:** Seitenleiste → **„Projekt hinzufügen"**. Das ist ein eigener Dialog
(`directoryExplorerDialog`) mit Baum **und** Eingabefeld („Enter path or select from tree…"),
und der geht über den Server — also über die Box.
**Diagnose in einem Befehl** — wenn wieder „nichts passiert", zeigt das den Grund sofort:
```bash
ssh hitonabi@192.168.178.151 "curl -s http://127.0.0.1:4096/session | python3 -c \"import json,sys; s=json.load(sys.stdin); s.sort(key=lambda x: x['time']['updated'], reverse=True); x=s[0]; print(x['directory'], x.get('model'), x.get('tokens'))\""
```
Steht dort ein Pfad mit `F:\` oder `tokens 0/0`, ist es diese Falle.
**Merke:** Ein Projekt taucht in der Server-Liste erst auf, wenn dort einmal eine Sitzung lief.
`mc2-referenz` wurde am 21.08. mit einem harmlosen Lauf angemeldet (`box/hermes`, „Antworte nur
mit OK") — Worktree danach nachgemessen unverändert: 5 geänderte Dateien, HEAD `e04ace2`.
---
## ▶▶ Kurswechsel 21.08. (nachmittags): Quelle ist der PC, nicht die Box
Der Fernbetrieb oben war **falsch herum gedacht**. Der User arbeitet so — und so ist es
jetzt gebaut:
```
F:\Coding Stuff\… ← Quelle der Wahrheit, liegt IMMER lokal
│ OpenChamber startet seine EIGENE OpenCode-CLI und arbeitet an lokalen Dateien
│ git push (PowerShell/GCM)
Gitea 192.168.178.153:3000
│ projekte-sync (stuendlich, --ff-only) — oder sofort per Skript
AI-Box ~/projekte/… ← Spiegel fuer Deploy / CI / Lucy
└─ von der Box kommt NUR noch das Modell (Inferenz ueber HTTP)
```
**Damit faellt die Windows-Pfad-Falle ersatzlos weg** — es gibt keine Fernpfade mehr.
### ★★ Kein Box-Umbau noetig: MC2 `:9001/v1` ist der Modellweg
Erst war geplant, den Gateway `:9010` ins LAN zu binden. Ueberfluessig — die Gateway-Unit
sagt es selbst: *„LAN-Clients kommen weiter ueber MC2 `:9001/v1`, das roh hierher
durchreicht."* Der Port ist seit jeher offen.
Vom PC aus gemessen: `:9001/v1/models` liefert **die Rollen-Aliase** (`fast`, `heavy`,
`hermes`, `coder`), `fast` antwortet in **0,7 s**. Kein neuer Port, keine Neubindung,
keine ufw-Regel — und **keine Modell-Eigennamen** in der PC-Konfiguration (die Regel vom
20.08. bleibt gewahrt). llama-swap `:8080` waere die Alternative gewesen, liefert aber nur
Eigennamen (`Qwen3.6-35B-A3B`) → genau der stille 404 vom 19.08.
### Was auf dem PC steht
`C:\Users\TobisPC\.config\opencode\opencode.json` — Provider `aibox`
`http://192.168.178.151:9001/v1`, Standard `aibox/fast`, `small_model` `aibox/hermes`.
Die Juli-Struktur ist erhalten: Agenten `plan`(heavy) · `build`(fast) · `explore`(hermes) ·
`review`(heavy), inklusive **IDE-Zaun** (`ssh`/`scp`/`sftp` deny, gezielte Arcane-Ausnahme;
Catch-all `*: allow` steht ZUERST, weil OpenCode `findLast` auswertet).
Arbeitsanweisung als `ARBEITSANWEISUNG.md` daneben, ueber `instructions` global geladen.
Das Plugin `plugin/mc2-governor.ts` wird automatisch mitgeladen: Werkzeug-Zaun,
Pruef-Tor-Schleife, Savepoint statt Zusammenfassen, Meldungen an Lucys Stimme.
‼️ Es blockt **`git push`** absichtlich — *„Veroeffentlichen ist Sache des Menschen."*
Der Agent codet, gepusht wird von Hand (siehe Skript unten).
**Gebuendelte CLI:** OpenCode **1.18.18** in OpenChambers `resources\opencode-cli`.
Im lokalen Betrieb vergibt OpenChamber dem Server automatisch ein Passwort
(`OPENCODE_SERVER_PASSWORD`, lifecycle-owned) — `/config` antwortet von aussen mit `401`.
Der Schutz, den der Box-Server nicht hatte, ist hier also gratis dabei.
**Env-Variablen `OPENCODE_HOST` und `OPENCODE_SKIP_START` sind wieder ENTFERNT.**
Sie erzwingen den Fernbetrieb; mit ihnen startet OpenChamber keine eigene CLI.
### Box zurueckgebaut
`opencode-server.service` ist **gestoppt, deaktiviert und geloescht**, die ufw-Regel fuer
`4096` wieder entfernt. Damit erledigt sich die offene Sicherheitsfrage von heute Mittag
von selbst: der ungeschuetzte Dienst existiert nicht mehr. ufw steht wieder auf
`22 · 9001 · 8080 · 7681`.
### `deploy/push-und-sync.ps1` — der Handschlag
```powershell
.\deploy\push-und-sync.ps1 # committen -> pushen -> Box zieht sofort nach
.\deploy\push-und-sync.ps1 -NurSync # nur nachziehen
.\deploy\push-und-sync.ps1 -Pfad "F:\Coding Stuff\rippy"
```
Stoesst `projekte-sync` sofort an, statt bis zu 60 Minuten auf den Timer zu warten —
**kein zweiter Mechanismus, nur ein Ausloeser.** Meldet danach, auf welchem Commit die Box steht.
‼️ **Falle beim Bauen gefunden:** Der Repo-Name darf **nicht** aus dem Ordnernamen kommen —
lokal heisst es `mission-control-2`, in Gitea `mission-control-v2`. Das Skript liest ihn
aus der Remote-URL. Getestet gegen beide Faelle: MC2 (Live-Deployment, liegt als
Verknuepfung unter `~/projekte`) und `rippy` (normales Projekt, `a14449e`).
### Offener Punkt: PC-Remote haengt an der DDNS-Domain
Der PC pusht nach `https://git.tobisniceshomelab.ddnsfree.com/…`, die Box nutzt intern
`http://192.168.178.153:3000`. Die Domain ist nachts durch die Zwangstrennung zeitweise
tot (Lehre vom 24.07.). Umstellen mit:
```bash
git remote set-url origin http://192.168.178.153:3000/Hitonabi/mission-control-v2.git
```
Noch **nicht** gemacht — aendert Git-Konfiguration in den Repos des Users.
---
## Abschluss der Coding Lane (21.08.2026, abends)
### Der Zugang: ein SSH-Schluessel, kein Token mehr
**Der Ausloeser:** Mitten in der Sitzung scheiterte ein Push, der eine Stunde
vorher noch ging — `remote: Failed to authenticate user`. Der Git Credential
Manager haelt fuer Gitea ein **OAuth-Token mit einer Stunde Laufzeit**. Damit ist
HTTP fuer einen Agenten, der selbst pushen soll, strukturell ungeeignet.
**Was dabei herauskam:** Giteas SSH war **nie funktionsfaehig**. In der `app.ini`
stand zwar `DISABLE_SSH = false` und `SSH_PORT = 22`, aber im Container gibt es
**keinen `git`-Benutzer** und keine `authorized_keys` — Port 22 gehoert dem
System-sshd. Es sah nur so aus, als koennte man ueber SSH klonen.
**Loesung:** Giteas **eingebauten** SSH-Server eingeschaltet (Port 22 ist belegt,
deshalb 2222). Der verwaltet die Schluessel selbst, direkt aus der Datenbank —
kein `git`-Benutzer, keine `authorized_keys`.
```ini
# /etc/gitea/app.ini, [server] (Sicherung: app.ini.bak-20260821)
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
START_SSH_SERVER = true
SSH_LISTEN_HOST = 0.0.0.0
```
‼️‼️ **Der SSH-Benutzer heisst `gitea`, NICHT `git`.** Gitea laeuft unter diesem
Namen und weist alles andere ab. Die Fehlermeldung steht nur im Gitea-Log, nicht
beim Client — ssh sagt bloss `Permission denied (publickey)`:
```
Invalid SSH username git - must use gitea for all git operations via ssh
```
**Auf dem PC** (`~/.ssh/config`) — noetig, weil hier **acht** Schluessel liegen
und ssh sonst der Reihe nach durchprobiert, bis Gitea abbricht:
```
Host 192.168.178.153 gitea gitea.heimnetz
HostName 192.168.178.153
Port 2222
User gitea
IdentityFile ~/.ssh/id_general
IdentitiesOnly yes
```
Der Schluessel `id_general` ist in Gitea hinterlegt (Nr. 3) — **derselbe, mit dem
du auf Box, pve und Arcane kommst.** Von acht Zugangsdaten auf einen.
Alle sieben PC-Repos stehen jetzt auf
`ssh://gitea@192.168.178.153:2222/Hitonabi/<repo>.git`.
Das TTT2-Projekt bleibt unberuehrt — es haengt an einer anderen Domain und ist
als „Finished" markiert.
### Gemessen, nicht angenommen
| Test | Ergebnis |
|---|---|
| `ssh -T gitea` | „Hi there, Hitonabi! You've successfully authenticated with the key named id_general" |
| Push aus PowerShell | 8 Commits, `7c23ebf..25dbfb2` |
| **Push aus dem Agenten** | durchgelaufen, **keine Passwortabfrage** |
| Erzwungener Push aus dem Agenten | vom Werkzeug-Zaun abgewiesen |
| `push-und-sync.ps1` komplett | Commit → Push → Box zieht nach |
### Der Sonderfall MC2
`projekte-sync` zieht `mission-control-v2` **absichtlich nicht** — die Box
bedient daraus den laufenden Dienst auf `:9001`. Nach einem Push steht dort
weiter der alte Commit, und das ist richtig so. Das Live-Deployment braucht
seinen eigenen, bewussten Schritt. Alle anderen Projekte werden normal gezogen.
### Fallen fuer das naechste Mal
- **`sed` mit `$` und `\n` ueber PowerShell zerlegt sich.** Der erste Anlauf,
die `app.ini` zu aendern, lief ins Leere und die Datei blieb unveraendert —
ohne Fehlermeldung. In einzelne Ersetzungen zerlegen und **danach nachsehen**.
- Ein Dienst-Neustart nach einer Konfig-Aenderung ist noch keine Bestaetigung:
Gitea startete brav neu — mit der alten Datei.
- Acht SSH-Schluessel auf dem PC sind selbst ein Befund. `IdentitiesOnly yes`
ist Pflicht, sonst bricht die Gegenseite vorher ab.
+93
View File
@@ -0,0 +1,93 @@
# Hermes: Werkzeuge nach Bedarf (21.08.2026)
## Die Frage war: kann Hermes Werkzeuge je nach Aufgabe an- und abschalten?
**Ja — auf drei Ebenen, und die wichtigste lief schon.**
### 1. Automatisch zur Laufzeit — `tool_search` (war bereits an)
```
tools:
tool_search:
enabled: true
threshold_pct: 10
listing: auto
listing_max_tokens: 4000
```
Im Log nachweisbar:
```
tool_search activated (tier 1): 47 core/visible tools kept,
59 deferred (~5636 tokens), listing full (budget ~4000 tokens)
```
Hermes haelt einen Kern sichtbar und schiebt den Rest hinter eine Suche; der Agent
zieht nach, was er braucht. **Hier war nichts einzurichten.**
Aber: das Aufschieben allein sparte nur 1.1005.600 Tokens, waehrend `kanban` mit
22,7 KB im sichtbaren Kern blieb. Der Hebel lag woanders.
### 2. Global abschalten — `agent.disabled_toolsets`
```bash
hermes config set agent.disabled_toolsets "[computer_use, image_gen, video, video_gen, kanban, delegation]"
hermes tools disable session_search
```
Abgeschaltet **anhand von 9 Tagen echter Nutzung**, nicht nach Gefuehl:
| Werkzeugsatz | Groesse | Nutzung 13.21.08. |
|---|---|---|
| `kanban` (14 Werkzeuge) | 22,7 KB | 35× lesen, **1× schreiben** |
| `session_search` | 6,8 KB | **1×** |
| `delegation` | 5,6 KB | Fan-out, seit OpenChamber tot |
‼️ `kanban` kennt `hermes tools disable` **nicht** — es haengt an `toolsets` /
`platform_toolsets` und muss ueber `agent.disabled_toolsets` weg.
### 3. Pro Zugangsweg — `platform_toolsets.<platform>`
★★ **Der groesste Fund.** Es gibt einen eigenen Zugangsweg `cron` (17 Sitzungen im
Log), fuer den **kein** Eintrag existierte — Cron-Jobs bekamen also die volle
Werkzeugkiste. Von den drei Jobs braucht nur der Nachrichtenbericht ueberhaupt
Werkzeuge, und der braucht zwei:
```bash
hermes config set platform_toolsets.cron "[web, terminal]"
```
`web` fuer die Suche, `terminal` um `notify.sh` aufzurufen. Mehr nicht.
Ausserdem moeglich, hier nicht gebraucht: `hermes -t TOOLSETS` setzt den
Werkzeugsatz fuer einen einzelnen Aufruf.
## Gemessen mit `hermes prompt-size`
Vorlast **je Anfrage**, Systemprompt plus Werkzeug-Schemata:
| Zugangsweg | vorher | nachher | |
|---|---|---|---|
| `cli` | 99,4 KB | **56,5 KB** | 43 % |
| `cron` | 63,1 KB | **16,4 KB** | **74 %** |
Rund 11.000 Tokens weniger bei jedem interaktiven Zug, rund 12.000 bei jedem
Cron-Lauf. Das ist Kontext, der vorher fuer Werkzeugbeschreibungen draufging,
die nie aufgerufen wurden.
## Nachgeprueft, nicht angenommen
- Lucy interaktiv: Terminal, Dateien, Websuche, Gedaechtnis — alle vier da.
- Daily News Report mit nur `[web, terminal]` durchgelaufen und zugestellt.
Der Bericht war sogar besser als der Lauf davor.
- Sicherung der Konfiguration liegt unter `~/.hermes/config.yaml.bak-kiss-20260821`.
## Noch drin, wenn es noch schlanker soll
- `api_server` traegt 44,8 KB (19 Werkzeuge) — mehr als `cli`. Dort haengt noch
`hermes-pc-control`, das in 9 Tagen **null Mal** aufgerufen wurde und an dem
eine geplante Aufgabe auf dem PC plus ein eigenes Token haengen.
- MCP `hermes-web-fetch` (1 Aufruf) doppelt das eingebaute `web_extract`.
- MCP `mission-control-voice` (1 Aufruf) doppelt das eingebaute `text_to_speech`.
- Die alten Eintraege `kanban` / `delegation` stehen noch in `platform_toolsets`;
`agent.disabled_toolsets` sticht sie, aber aufgeraeumt ist es nicht.
+2 -1
View File
@@ -66,7 +66,8 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
- **v0.20.4** (git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**. - **v0.20.4** (git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
- **Bot Mode:** Unterstützt Multi-Bot-Roster (`Lucy` als Haupt-Agentin, `Coder` mit `Qwen3-Coder-Next`, `Debugger` mit `Muse-Glimmer-30B`). - **Bot Mode:** Unterstützt Multi-Bot-Roster (`Lucy` als Haupt-Agentin, `Coder` mit `Qwen3-Coder-Next`, `Debugger` mit `Muse-Glimmer-30B`).
- `approvals.mode: smart`, `approvals.cron_mode: deny`. - `approvals.mode: smart`, `approvals.cron_mode: deny`.
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-memory, mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice. - MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
## Automatik-Fahrplan (was nachts von allein läuft) ## Automatik-Fahrplan (was nachts von allein läuft)
@@ -1,4 +1,4 @@
import{c as A,I as P,f as M,k as B,u as D,r as z,j as e,J as T,b as a,K as g,M as b,N as W,t as f,W as j,O as G,X as I,C as N,n as y,q as i}from"./index-CCbyojll.js";/** import{c as A,I as P,f as M,k as B,u as D,r as z,j as e,J as T,b as a,K as g,M as b,N as W,t as f,W as j,O as G,X as I,C as N,n as y,q as i}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as W,a1 as H,a2 as F,u as G,k as T,r as b,j as e,R as _,b as y,L as m,C as E,_ as O,D as P,a0 as z,a3 as R,a4 as V,X as D,T as C,F as K,n as $,a5 as q,q as U}from"./index-CCbyojll.js";import{S,H as Q}from"./SectionLabel-BfFwUeZU.js";import{C as M}from"./chevron-down-SPeGIim5.js";/** import{c as W,a1 as H,a2 as F,u as G,k as T,r as b,j as e,R as _,b as y,L as m,C as E,_ as O,D as P,a0 as z,a3 as R,a4 as V,X as D,T as C,F as K,n as $,a5 as q,q as U}from"./index-DNt66je4.js";import{S,H as Q}from"./SectionLabel-DKDWJ8hi.js";import{C as M}from"./chevron-down-BTSzJRag.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as d,aj as S,r as h,j as e,ak as M,L as p,a0 as z,a5 as C,al as D,am as A,b as B,an as Z,u as E,k as L,n as k,q}from"./index-CCbyojll.js";import{R as j}from"./rotate-ccw-DuKtbjZS.js";/** import{c as d,aj as S,r as h,j as e,ak as M,L as p,a0 as z,a5 as C,al as D,am as A,b as B,an as Z,u as E,k as L,n as k,q}from"./index-DNt66je4.js";import{R as j}from"./rotate-ccw-CGv9dXzB.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
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,4 +1,4 @@
import{c as $,j as e,b as S,a2 as ve,u as Se,k as De,r as d,n as z,a6 as Ke,_ as O,L as D,X as Q,a7 as xe,F as T,a3 as he,C as Ce,T as ze,a4 as Ee,a8 as pe,q as Ae,R as fe}from"./index-CCbyojll.js";import{S as Le,H as X}from"./SectionLabel-BfFwUeZU.js";import{C as ee}from"./code-xml-AZtn6xxf.js";import{L as Pe}from"./layers-Dpl2QlP2.js";import{C as U}from"./chevron-down-SPeGIim5.js";import{R as We}from"./rotate-ccw-DuKtbjZS.js";/** import{c as $,j as e,b as S,a2 as ve,u as Se,k as De,r as d,n as z,a6 as Ke,_ as O,L as D,X as Q,a7 as xe,F as T,a3 as he,C as Ce,T as ze,a4 as Ee,a8 as pe,q as Ae,R as fe}from"./index-DNt66je4.js";import{S as Le,H as X}from"./SectionLabel-DKDWJ8hi.js";import{C as ee}from"./code-xml-BiSOeCkP.js";import{L as Pe}from"./layers-BakyhGhx.js";import{C as U}from"./chevron-down-BTSzJRag.js";import{R as We}from"./rotate-ccw-CGv9dXzB.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
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-CCbyojll.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}; 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-DNt66je4.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 +1 @@
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-CCbyojll.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView}; import{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-DNt66je4.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};
@@ -1,4 +1,4 @@
import{c as E,u as he,a as qe,r as j,j as e,C as Y,L as Fe,b as k,d as Re,q as V,B as re,e as Ie,E as ze,f as le,g as Be,h as Ae,i as Pe,k as Ge,l as ee,X as me,H as be,T as se,s as Oe,m as Te,n as q,o as U,p as He,S as ge,t as Qe,P as Ue,v as Ve,w as We,x as Ze,y as Je}from"./index-CCbyojll.js";import{J as Xe}from"./JobsBar-Ckecoyqy.js";import{C as Ye}from"./code-xml-AZtn6xxf.js";import{R as et}from"./rotate-ccw-DuKtbjZS.js";import{C as ue,Z as ie,L as tt,a as rt}from"./zap-CRNNnxfs.js";import{C as De}from"./chevron-down-SPeGIim5.js";import{L as st}from"./layers-Dpl2QlP2.js";/** import{c as E,u as he,a as qe,r as j,j as e,C as Y,L as Fe,b as k,d as Re,q as V,B as re,e as Ie,E as ze,f as le,g as Be,h as Ae,i as Pe,k as Ge,l as ee,X as me,H as be,T as se,s as Oe,m as Te,n as q,o as U,p as He,S as ge,t as Qe,P as Ue,v as Ve,w as We,x as Ze,y as Je}from"./index-DNt66je4.js";import{J as Xe}from"./JobsBar-DG8VDiAV.js";import{C as Ye}from"./code-xml-BiSOeCkP.js";import{R as et}from"./rotate-ccw-CGv9dXzB.js";import{C as ue,Z as ie,L as tt,a as rt}from"./zap-cdYTHDpR.js";import{C as De}from"./chevron-down-BTSzJRag.js";import{L as st}from"./layers-BakyhGhx.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as s,j as e}from"./index-CCbyojll.js";/** import{c as s,j as e}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
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-CCbyojll.js";import{J as _}from"./JobsBar-Ckecoyqy.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}}/** 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-DNt66je4.js";import{J as _}from"./JobsBar-DG8VDiAV.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 * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as o}from"./index-CCbyojll.js";/** import{c as o}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as e}from"./index-CCbyojll.js";/** import{c as e}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
-6
View File
@@ -1,6 +0,0 @@
import{c as a}from"./index-CCbyojll.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
@@ -1,4 +1,4 @@
import{c as a}from"./index-CCbyojll.js";/** import{c as a}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as t}from"./index-CCbyojll.js";/** import{c as t}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-CCbyojll.js";/** import{c as a}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CCbyojll.js"></script> <script type="module" crossorigin src="/assets/index-DNt66je4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dv1-nhwH.css"> <link rel="stylesheet" crossorigin href="/assets/index-qQDuX0H0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -2,8 +2,8 @@ import { Gauge } from "lucide-react"
import { useVoiceTrace } from "@/lib/queries" import { useVoiceTrace } from "@/lib/queries"
import type { VoiceTurn } from "@/lib/api" import type { VoiceTurn } from "@/lib/api"
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Mem0 ist ein Unter-Detail // Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Der Gedächtnis-Abruf läuft
// INNERHALB von „Hirn" und wird NICHT als eigenes Segment addiert (sonst Doppelzählung). // Hermes-intern und ist für MC2 nicht messbar — er steckt in „Hirn".
const SEGMENTS = [ const SEGMENTS = [
{ key: "stt_ms", label: "STT", color: "#f59e0b" }, { key: "stt_ms", label: "STT", color: "#f59e0b" },
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" }, { key: "vision_ms", label: "Sehen", color: "#a78bfa" },
@@ -39,7 +39,6 @@ function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | n
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) { function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ") const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
+ (t.mem0_ms != null ? ` (davon Gedächtnis ${fmt(t.mem0_ms)})` : "")
return ( return (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span> <span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
@@ -103,7 +102,6 @@ export function LatencyCard() {
{latest && !latest.error && ( {latest && !latest.error && (
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70"> <div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")} {SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
{latest.mem0_ms != null && <span className="text-muted-foreground/50"> · davon Gedächtnis {fmt(latest.mem0_ms)}</span>}
</div> </div>
)} )}
</div> </div>
@@ -128,7 +126,7 @@ export function LatencyCard() {
)} )}
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70"> <div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
Hirn" = Zeit bis zum ersten Wort (Agent + Mem0-Suche + Modell); Tool-Runden stecken in „Antwort". Reine Hirn" = Zeit bis zum ersten Wort (Agent + Gedächtnis-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht. Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
</div> </div>
</div> </div>
+6 -52
View File
@@ -338,7 +338,6 @@ export interface ConnectResp {
gateway_url: string gateway_url: string
mc_url: string mc_url: string
tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway) tools: Record<string, ConnectTool> // Leitung 1 — Modell (IDEs/Agenten → Gateway)
memory: ConnectTool // Leitung 2 — Gedächtnis (separater MCP-Server)
} }
export interface ConnectLine { export interface ConnectLine {
@@ -347,20 +346,12 @@ export interface ConnectLine {
} }
export interface ConnectHealth { export interface ConnectHealth {
gateway: ConnectLine gateway: ConnectLine
memory: ConnectLine memory: ConnectLine // Leitung 2 — Hermes-natives Gedächtnis (nur Status, nichts zu konfigurieren)
desktop_gateway: ConnectLine desktop_gateway: ConnectLine
} }
export interface Memory { // Graph-Ansicht des Wissens-Vaults (GET /api/wissen/graph) — trägt aus historischen Gründen
id: string // noch „Memory" im Namen; die Daten kommen aus dem Vault, nicht aus einem Gedächtnis-Dienst.
content: string
category: string
source: string
created_at: string
updated_at: string
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
}
export interface MemoryGraphNode { export interface MemoryGraphNode {
id: string id: string
content: string content: string
@@ -372,13 +363,6 @@ export interface MemoryGraph {
edges: { source: string; target: string; weight: number }[] edges: { source: string; target: string; weight: number }[]
} }
export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number
removed: number
applied: boolean
}
export interface AgentStatus { export interface AgentStatus {
gateway_url: string gateway_url: string
box_console_url?: string box_console_url?: string
@@ -443,7 +427,8 @@ export interface TokenStats {
} }
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt): // Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
// stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren). // stt · vision · hirn · gen. Der Gedächtnis-Abruf läuft seit der Mem0-Ablösung Hermes-intern
// (kein Rückruf an MC2) und steckt daher unsichtbar in hirn_ms.
export interface VoiceTurn { export interface VoiceTurn {
id: string id: string
ts: number // Epoch-Sekunden ts: number // Epoch-Sekunden
@@ -452,9 +437,8 @@ export interface VoiceTurn {
had_images: boolean had_images: boolean
stt_ms: number | null stt_ms: number | null
vision_ms: number | null vision_ms: number | null
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT) hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Gedächtnis + LLM-TTFT)
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
total_ms: number total_ms: number
error: string | null error: string | null
} }
@@ -583,36 +567,6 @@ export interface ChronikResp {
items: ChronikItem[] items: ChronikItem[]
} }
// ── Eigenleben („Von allein": Skills + Vorschlags-Bilanz der Box) ────────────
export interface EigenlebenSkill {
id: string
name: string
kategorie: string | null
aktiv: boolean | null // null = nicht ermittelbar (Dev-Modus)
beschreibung: string
version: string
autor: string
herkunft: "selbst" | "repo" | "bundled"
herkunft_label: string
genutzt: number
zuletzt_genutzt: string | null
erstellt: string | null
status: string
geaendert: number
}
export interface EigenlebenEintrag {
art: string // "angenommen" | "abgelehnt" | sonstiger Chronik-State
titel: string
detail: string
ts: number
}
export interface EigenlebenResp {
available: boolean
skills: EigenlebenSkill[]
selbst_erstellt: number
bilanz: { eintraege: EigenlebenEintrag[]; angenommen: number; abgelehnt: number }
}
// ── Wissens-Vault (Traum-Notizen, read-only) ───────────────────────────────── // ── Wissens-Vault (Traum-Notizen, read-only) ─────────────────────────────────
export interface WissenFile { export interface WissenFile {
path: string path: string
-23
View File
@@ -13,7 +13,6 @@ import {
type ConnectHealth, type ConnectHealth,
type DiscoverResp, type DiscoverResp,
type DraftsResp, type DraftsResp,
type EigenlebenResp,
type GroupsResp, type GroupsResp,
type HermesBrainResp, type HermesBrainResp,
type Health, type Health,
@@ -21,7 +20,6 @@ import {
type IdeenLogResp, type IdeenLogResp,
type IdeenResp, type IdeenResp,
type Job, type Job,
type Memory,
type MemoryGraph, type MemoryGraph,
type ModelsResp, type ModelsResp,
type Reminder, type Reminder,
@@ -54,13 +52,11 @@ export const qk = {
drafts: (target?: string) => ["drafts", target ?? ""] as const, drafts: (target?: string) => ["drafts", target ?? ""] as const,
connect: (params?: string) => ["connect", params ?? ""] as const, connect: (params?: string) => ["connect", params ?? ""] as const,
connectHealth: ["connect-health"] as const, connectHealth: ["connect-health"] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
memoryGraph: ["memory-graph"] as const, memoryGraph: ["memory-graph"] as const,
voiceTrace: ["voice-trace"] as const, voiceTrace: ["voice-trace"] as const,
auftragsbuch: ["auftragsbuch"] as const, auftragsbuch: ["auftragsbuch"] as const,
ideen: ["ideen"] as const, ideen: ["ideen"] as const,
chronik: ["chronik"] as const, chronik: ["chronik"] as const,
eigenleben: ["eigenleben"] as const,
wissen: ["wissen"] as const, wissen: ["wissen"] as const,
zeitmaschine: ["zeitmaschine"] as const, zeitmaschine: ["zeitmaschine"] as const,
reminders: ["reminders"] as const, reminders: ["reminders"] as const,
@@ -125,13 +121,6 @@ export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlic
export const useWissen = () => export const useWissen = () =>
useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") }) useQuery({ queryKey: qk.wissen, queryFn: () => api<WissenResp>("/api/wissen") })
export const useEigenleben = (refetchInterval: number = TAKT.traege) =>
useQuery({
queryKey: qk.eigenleben,
queryFn: () => api<EigenlebenResp>("/api/eigenleben"),
refetchInterval,
})
export const useZeitmaschine = (refetchInterval: number = TAKT.traege, enabled = true) => export const useZeitmaschine = (refetchInterval: number = TAKT.traege, enabled = true) =>
useQuery({ useQuery({
queryKey: qk.zeitmaschine, queryKey: qk.zeitmaschine,
@@ -248,18 +237,6 @@ export const useConnectHealth = () =>
refetchInterval: 15000, refetchInterval: 15000,
}) })
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
useQuery({
queryKey: qk.memory(opts?.q, opts?.category),
queryFn: () => {
const p = new URLSearchParams()
if (opts?.q) p.set("q", opts.q)
if (opts?.category) p.set("category", opts.category)
return api<Memory[]>(`/api/memory?${p}`)
},
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
})
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */ /** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
export function invalidate(qc: QueryClient, ...keys: (readonly unknown[])[]) { export function invalidate(qc: QueryClient, ...keys: (readonly unknown[])[]) {
for (const key of keys) qc.invalidateQueries({ queryKey: key }) for (const key of keys) qc.invalidateQueries({ queryKey: key })
+10 -62
View File
@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { import {
Check, Copy, Cpu, Brain, Globe, FolderOpen, Laptop, ArrowRight, Check, Copy, Cpu, Brain, Globe, Laptop, ArrowRight,
Info, CircleCheck, CircleX, Loader2, ChevronDown, ChevronRight, Info, CircleCheck, CircleX, Loader2, ChevronDown, ChevronRight,
} from "lucide-react" } from "lucide-react"
import { useConnect, useConnectHealth } from "@/lib/queries" import { useConnect, useConnectHealth } from "@/lib/queries"
@@ -82,14 +82,12 @@ function defaultHost(): string {
export function ConnectView() { export function ConnectView() {
const [host, setHost] = useState(localStorage.getItem("mc_host") || defaultHost()) const [host, setHost] = useState(localStorage.getItem("mc_host") || defaultHost())
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
// Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg. // Legacy-Tools (roher /v1-Zugang) leben im Ausklapper — Hermes Desktop ist der Hauptweg.
const [active, setActive] = useState("kilo") const [active, setActive] = useState("kilo")
const [sonstigeOffen, setSonstigeOffen] = useState(false) const [sonstigeOffen, setSonstigeOffen] = useState(false)
const [copied, setCopied] = useState<"desktop" | "model" | "memory" | null>(null) const [copied, setCopied] = useState<"desktop" | "model" | null>(null)
const params = new URLSearchParams({ host }) const params = new URLSearchParams({ host })
if (mcpPath) params.set("mcp_path", mcpPath)
const { data, error: dataErr } = useConnect(params.toString()) const { data, error: dataErr } = useConnect(params.toString())
const { data: health, isLoading: healthLoading } = useConnectHealth() const { data: health, isLoading: healthLoading } = useConnectHealth()
const error = dataErr ? String(dataErr) : "" const error = dataErr ? String(dataErr) : ""
@@ -99,15 +97,10 @@ export function ConnectView() {
if (v) localStorage.setItem("mc_host", v) if (v) localStorage.setItem("mc_host", v)
} }
function saveMcpPath(v: string) {
setMcpPath(v)
localStorage.setItem("mc_mcp_path", v)
}
const desktop = data?.tools["hermes_desktop"] const desktop = data?.tools["hermes_desktop"]
const tool = data?.tools[active] const tool = data?.tools[active]
async function copy(which: "desktop" | "model" | "memory", snippet?: string) { async function copy(which: "desktop" | "model", snippet?: string) {
if (!snippet) return if (!snippet) return
await navigator.clipboard.writeText(snippet) await navigator.clipboard.writeText(snippet)
setCopied(which) setCopied(which)
@@ -123,7 +116,7 @@ export function ConnectView() {
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<span className="text-foreground">Hermes Desktop</span> ist die Tür zur Box einmal verbinden, fertig. <span className="text-foreground">Hermes Desktop</span> ist die Tür zur Box einmal verbinden, fertig.
Für Spezialfälle gibt es darunter die rohen Leitungen (Gateway&nbsp;/v1 + Gedächtnis-MCP) für sonstige Tools. Für Spezialfälle gibt es darunter den rohen Gateway-Zugang (&nbsp;/v1) für sonstige Tools.
</p> </p>
</div> </div>
@@ -165,7 +158,7 @@ export function ConnectView() {
<StatusBadge line={health?.memory} loading={healthLoading} /> <StatusBadge line={health?.memory} loading={healthLoading} />
</div> </div>
<div className="mt-1 text-[11px] font-mono text-violet-300/80"> <div className="mt-1 text-[11px] font-mono text-violet-300/80">
MCP · mcp_memory.py · separat Hermes-nativ · im Agenten · nichts einzurichten
</div> </div>
</div> </div>
</div> </div>
@@ -187,9 +180,9 @@ export function ConnectView() {
</div> </div>
</div> </div>
<p className="mt-3.5 text-[11px] text-muted-foreground leading-relaxed"> <p className="mt-3.5 text-[11px] text-muted-foreground leading-relaxed">
Der Gateway liefert <span className="text-foreground">nur das LLM</span>. Das geteilte Gedächtnis läuft über einen Der Gateway liefert <span className="text-foreground">nur das LLM</span>. Das Gedächtnis führt
<span className="text-foreground"> eigenen MCP-Server</span> und das Desktop-Gateway bietet die Agenten-Schnittstelle <span className="text-foreground"> Hermes selbst</span> (nichts einzurichten die Kachel zeigt nur, ob es lebt), und das
alle drei Leitungen werden unabhängig eingerichtet. IDEs wählen die <span className="text-foreground">echten Modelle</span> Desktop-Gateway bietet die Agenten-Schnittstelle. IDEs wählen die <span className="text-foreground">echten Modelle</span>
{" "}(coder · heavy · vision); daneben gibt es die virtuellen Lanes <span className="text-foreground">chat</span> und {" "}(coder · heavy · vision); daneben gibt es die virtuellen Lanes <span className="text-foreground">chat</span> und
{" "}<span className="text-foreground">coding</span>, die je Anfrage selbst das passende Modell wählen (Alltags-Chat, simple Tools). {" "}<span className="text-foreground">coding</span>, die je Anfrage selbst das passende Modell wählen (Alltags-Chat, simple Tools).
</p> </p>
@@ -260,12 +253,12 @@ export function ConnectView() {
className="flex w-full items-center gap-2 p-4 text-sm font-space font-bold text-foreground cursor-pointer hover:text-primary transition-colors" className="flex w-full items-center gap-2 p-4 text-sm font-space font-bold text-foreground cursor-pointer hover:text-primary transition-colors"
> >
{sonstigeOffen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {sonstigeOffen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
Sonstige Tools (roher /v1-Zugang + Gedächtnis-MCP) Sonstige Tools (roher /v1-Zugang)
<span className="text-[10px] font-medium text-muted-foreground normal-case">Kilo Code · Claude Code · Eigenbau</span> <span className="text-[10px] font-medium text-muted-foreground normal-case">Kilo Code · Claude Code · Eigenbau</span>
</button> </button>
{sonstigeOffen && ( {sonstigeOffen && (
<div className="grid gap-5 lg:grid-cols-2 p-4 pt-0"> <div className="grid gap-5 p-4 pt-0">
{/* Leitung 1 — Modell anbinden */} {/* Leitung 1 — Modell anbinden */}
<div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-primary/70 bg-card/30 p-4"> <div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-primary/70 bg-card/30 p-4">
<div> <div>
@@ -312,51 +305,6 @@ export function ConnectView() {
</> </>
)} )}
</div> </div>
{/* Leitung 2 — Gedächtnis anbinden */}
<div className="space-y-3 rounded-2xl border border-border/60 border-t-2 border-t-violet-500/70 bg-card/30 p-4">
<div>
<div className="flex items-center gap-2 text-sm font-space font-bold text-foreground">
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-violet-500/15 text-violet-300 text-[11px]">2</span>
Gedächtnis anbinden
<span className="text-[10px] font-medium text-muted-foreground normal-case">optional</span>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">Ein MCP-Block gilt zusätzlich für <em>jedes</em> Tool aus Schritt 1.</p>
</div>
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-violet-500/5 border border-violet-500/20 text-[11px] text-muted-foreground leading-relaxed">
<Info className="h-4 w-4 text-violet-400 shrink-0 mt-0.5" />
<span>{data.memory.note}</span>
</div>
{/* Der Pfad wirkt NUR auf dieses Snippet — deshalb wohnt das Feld hier,
nicht mehr prominent in der Kopfzeile („Wann brauche ich das?"). */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<FolderOpen className="h-3.5 w-3.5 text-violet-400" /> Wo liegt mcp_memory.py auf DIESEM PC?
</label>
<input
value={mcpPath}
onChange={(e) => saveMcpPath(e.target.value)}
aria-label="Lokaler MCP-Scriptpfad"
spellCheck={false}
placeholder="z.B. F:\Coding Stuff\mission-control-2\mcp\mcp_memory.py"
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 font-mono text-xs outline-none focus:ring-1 focus:ring-violet-500/50 text-foreground"
/>
<p className="text-[10px] text-muted-foreground/70">
Nur nötig, wenn du das Gedächtnis in einem PC-Tool anschließt das Script läuft lokal
und spricht mit der Box. Der Pfad landet direkt im Snippet unten.
</p>
</div>
<CodeWindow
tool={data.memory}
fileName="mcp.json"
accent="violet"
copied={copied === "memory"}
onCopy={() => copy("memory", data.memory.snippet)}
/>
</div>
</div> </div>
)} )}
</div> </div>
+6 -4
View File
@@ -345,10 +345,12 @@ description: Treibt Entwicklung mit strikter TDD-Praxis
</div> </div>
</div> </div>
<Stack> <Stack>
Dein Gedächtnis (Tab <strong>Gedächtnis</strong>) ist <strong>Mem0</strong>-basiert: auto-lernend &amp; Dein Gedächtnis führt <strong>Hermes</strong> selbst — auto-lernend und semantisch. Der Agent lernt im
semantisch, <strong>graph-first</strong> dargestellt, mit 4-Typen-Taxonomie Hintergrund nach jedem Turn, egal ob du per Desktop, Konsole, Telegram oder Lucy mit ihm sprichst.
(<Pill>Identität</Pill> · <Pill>Wissen</Pill> · <Pill>Regeln</Pill> · <Pill>Ereignisse</Pill>). Der Agent <br />Deshalb hat das Steuerpult <strong>keinen eigenen Gedächtnis-Tab</strong>: bis August 2026 lag das
lernt im Hintergrund nach jedem Turn — egal ob du per Desktop, Konsole, Telegram oder Lucy mit ihm sprichst. Gedächtnis in einem MC2-Sidecar, seit dessen Ablösung ist Hermes die einzige Quelle — angesehen und
gepflegt wird es dort, nicht hier. Unter <strong>Wissen</strong> findest du weiterhin die Graph-Sicht
auf Lucys Wissens-Vault.
<br /><strong>Gotcha:</strong> ein gelöschter Fakt kann wieder auftauchen, solange die Original-Nachricht noch im Verlauf steht (additive Extraktion). <br /><strong>Gotcha:</strong> ein gelöschter Fakt kann wieder auftauchen, solange die Original-Nachricht noch im Verlauf steht (additive Extraktion).
</Stack> </Stack>
</Card> </Card>
-250
View File
@@ -1,250 +0,0 @@
"""
MC2 Memory — Hermes-Memory-Provider, der ans geteilte **Mem0-Gedächtnis** hängt (über MC2 :9001,
das den Mem0-Sidecar :8765 proxyt). Macht das Gedächtnis für Hermes **hands-off**:
- `sync_turn` → nach jedem Turn werden die Turn-Messages an `/api/memory/learn` geschickt;
Mem0 EXTRAHIERT dauerhafte Fakten selbst (infer=True), dedupliziert semantisch. Nicht-blockierend
(Hintergrund-Worker), damit der Agent nie auf die LLM-Extraktion wartet.
- `prefetch` → vor jedem Turn semantische Suche gegen die Nutzer-Message; relevante Fakten werden
als Kontext eingeblendet (automatischer Recall).
**Context-only:** `get_tool_schemas()` liefert `[]` → der Agent bekommt KEINE Memory-Tools. Damit
entfällt das Tool-Loop-Risiko, wegen dem das native Memory-Toolset abgeschaltet ist
(`agent.disabled_toolsets: [..., memory]` bleibt). Single Source of Truth bleibt der MC2-Sidecar
(NoThink-Fix + alleiniger Chroma-Besitzer) — dieser Provider ist nur ein dünner HTTP-Client.
Aktivierung (in ~/.hermes/config.yaml):
memory:
memory_enabled: true
provider: mc2-memory
"""
from __future__ import annotations
import logging
import os
import queue
import re
import threading
import time
from typing import Any, Optional
import httpx
from agent.memory_provider import MemoryProvider
log = logging.getLogger(__name__)
MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
RECALL_LIMIT = int(os.environ.get("MC2_MEMORY_RECALL_LIMIT", "5"))
# 0.5 statt 0.3: nur starke Treffer einblenden → weniger Rausch-Fakten UND ein stabilerer
# Prompt-Prefix (oft leer), damit --cache-reuse über Turns greift.
RECALL_MIN_SCORE = float(os.environ.get("MC2_MEMORY_RECALL_MIN_SCORE", "0.5"))
# 25 statt 12: triviale Kurz-Turns (Begrüßung/Quittung) gar nicht erst zum Lernen schicken.
MIN_USER_LEN = int(os.environ.get("MC2_MEMORY_MIN_USER_LEN", "25")) # zu Kurzes nicht lernen
# Anti-Memory-Poisoning (Stufe 0 Security): aus Turns mit untrusted Bildschirm-/Web-Inhalt NICHT
# auto-lernen. Lucys Bildschirm-Sicht prependet diesen Marker an die User-Message (voice.py); auf dem
# Schirm sichtbarer Text könnte Injection-Anweisungen tragen, die sonst DAUERHAFT ins Gedächtnis
# wandern und Tage später ungefragt wiederverwendet werden.
SKIP_UNTRUSTED = os.environ.get("MC2_MEMORY_SKIP_UNTRUSTED", "1") not in ("0", "false", "False", "no")
UNTRUSTED_MARKERS = ("[Bildschirm-Sicht",)
# Lern-Buendelung (Phase 2, 19.07.2026): Die LLM-Extraktion lief bisher nach JEDEM Turn und
# konkurrierte dabei mit Lucys Hirn um die Slots (gleiches Modell, --parallel 2). Jetzt werden
# Turns gesammelt und als EIN Gespraechs-Block extrahiert: nach BATCH_TURNS Turns oder wenn
# BATCH_IDLE_S Sekunden Ruhe war. Ein Batch = ein Extraktionslauf statt N.
BATCH_TURNS = max(1, int(os.environ.get("MC2_MEMORY_BATCH_TURNS", "4")))
BATCH_IDLE_S = float(os.environ.get("MC2_MEMORY_BATCH_IDLE", "180"))
# Triviale Turns, die keinen dauerhaften Fakt tragen (reine Begrüßung/Quittung, Aufwärm-/Test-
# Kommandos) → gar nicht erst zum Auto-Lernen schicken. Spart Extraktions-Läufe und verhindert
# Müll-Fakten aus Meta-/Ablauf-Turns.
_TRIVIAL_TURN_RE = re.compile(
r"^\s*(?:(?:hi|hallo|hey|moin|servus|guten\s+(?:morgen|tag|abend)|danke\w*|"
r"ok(?:ay)?|alles\s+klar|passt|super|top|cool|nice|ja|nein|jo|jup|jap)[\s.,!?]*)+$"
r"|^\s*(?:aufw(?:ä|ae)rmen|/?test)\b",
re.IGNORECASE,
)
def _is_trivial_turn(text: str) -> bool:
return bool(_TRIVIAL_TURN_RE.search(text or ""))
def _headers() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
class MC2MemoryProvider(MemoryProvider):
def __init__(self) -> None:
self._session_id = ""
self._agent_context = "primary"
self._q: queue.Queue[list] = queue.Queue(maxsize=200)
self._worker: threading.Thread | None = None
self._stop = threading.Event()
self._recall_cache: dict[str, str] = {}
# Turn-Puffer der Lern-Buendelung (siehe BATCH_TURNS/BATCH_IDLE_S).
self._buf: list[list[dict[str, str]]] = []
self._buf_lock = threading.Lock()
self._buf_last_add = 0.0
# -- Identität / Verfügbarkeit -------------------------------------------
def name(self) -> str:
return "mc2-memory"
def is_available(self) -> bool:
try:
r = httpx.get(f"{MC_URL}/api/health", timeout=3.0)
return r.status_code == 200
except Exception:
return False
def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id
# Nur im primären Agent-Kontext lernen (cron/subagent-Systemprompts nicht einlernen).
self._agent_context = kwargs.get("agent_context", "primary")
if self._worker is None or not self._worker.is_alive():
self._stop.clear()
self._worker = threading.Thread(target=self._run, name="mc2-memory-learn", daemon=True)
self._worker.start()
def system_prompt_block(self) -> str:
return (
"Du hast ein dauerhaftes, geteiltes Langzeitgedächtnis (MC2/Mem0). Relevante Fakten "
"werden dir vor einem Turn automatisch eingeblendet; neue dauerhafte Fakten über den "
"Nutzer und das Projekt werden nach dem Turn automatisch gelernt — du musst dafür "
"nichts tun."
)
# -- Recall (vor dem Turn) -----------------------------------------------
def prefetch(self, query: str, *, session_id: str = "") -> str:
q = (query or "").strip()
if len(q) < 3:
return ""
if q in self._recall_cache:
return self._recall_cache[q]
try:
r = httpx.get(f"{MC_URL}/api/memory", params={"q": q}, headers=_headers(), timeout=5.0)
r.raise_for_status()
items = r.json()
except Exception as exc: # nie den Turn blockieren/abbrechen
log.debug("mc2-memory prefetch failed: %s", exc)
return ""
facts = [
i for i in items
if i.get("score") is None or float(i.get("score", 0)) >= RECALL_MIN_SCORE
][:RECALL_LIMIT]
out = ""
if facts:
lines = "\n".join(f"- {f.get('content', '')}" for f in facts)
out = f"Relevante Fakten aus dem Langzeitgedächtnis:\n{lines}"
# Cache klein halten
if len(self._recall_cache) > 64:
self._recall_cache.clear()
self._recall_cache[q] = out
return out
# -- Lernen (nach dem Turn) ----------------------------------------------
def sync_turn(self, user_content: str, assistant_content: str, *,
session_id: str = "", messages: list[dict[str, Any]] | None = None) -> None:
if self._agent_context != "primary":
return
u = (user_content or "").strip()
if len(u) < MIN_USER_LEN:
return
if _is_trivial_turn(u):
log.debug("mc2-memory: trivialer Turn (Begrüßung/Quittung/Aufwärmen) — Auto-Lernen übersprungen")
return
if SKIP_UNTRUSTED and any(m in u for m in UNTRUSTED_MARKERS):
log.debug("mc2-memory: Turn mit untrusted Bildschirm-/Web-Inhalt — Auto-Lernen übersprungen")
return
msgs: list[dict[str, str]] = [{"role": "user", "content": u}]
a = (assistant_content or "").strip()
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls).
# Bewusst NUR die Tool-NAMEN mitlernen ("hat X per Tool Y geprüft") — Tool-ERGEBNISSE
# sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
if messages:
tools = []
for m in messages:
for tc in (m.get("tool_calls") or []):
name = ((tc.get("function") or {}).get("name") or "").strip()
if name and name not in tools:
tools.append(name)
if tools:
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
if a:
msgs.append({"role": "assistant", "content": a[:4000]})
with self._buf_lock:
self._buf.append(msgs)
self._buf_last_add = time.time()
full = len(self._buf) >= BATCH_TURNS
if full:
self._drain_buffer()
# Neue Fakten können gelandet sein → Recall-Cache invalidieren.
self._recall_cache.clear()
def _drain_buffer(self) -> None:
"""Gepufferte Turns als EINEN Gespraechs-Block in die Lern-Queue geben."""
with self._buf_lock:
if not self._buf:
return
merged: list[dict[str, str]] = []
for turn in self._buf:
merged.extend(turn)
self._buf.clear()
try:
self._q.put_nowait(merged)
except queue.Full:
log.debug("mc2-memory learn queue full — Batch übersprungen")
def _post_learn(self, msgs: list) -> None:
try:
httpx.post(f"{MC_URL}/api/memory/learn",
json={"messages": msgs, "source": "hermes"},
headers=_headers(), timeout=90.0)
except Exception as exc:
log.debug("mc2-memory learn POST failed: %s", exc)
def _run(self) -> None:
while not self._stop.is_set():
try:
msgs = self._q.get(timeout=5.0)
except queue.Empty:
# Idle-Flush: Ruhe im Gespraech → angesammelte Turns jetzt lernen.
with self._buf_lock:
idle = (self._buf and
time.time() - self._buf_last_add >= BATCH_IDLE_S)
if idle:
self._drain_buffer()
continue
try:
self._post_learn(msgs)
finally:
self._q.task_done()
def _flush(self) -> None:
"""Offene Turns garantiert rausschreiben — bei Session-Ende/CLI-Exit, wo der
Prozess sofort beendet wird (daemon-Worker würde sonst mitten im POST sterben)."""
self._drain_buffer()
self._stop.set()
if self._worker and self._worker.is_alive():
self._worker.join(timeout=95.0) # laufenden Worker-POST zu Ende lassen
while True: # vom Worker nicht mehr abgeholte Turns
try:
msgs = self._q.get_nowait()
except queue.Empty:
break
self._post_learn(msgs)
# -- Context-only: keine Agent-Tools → kein Tool-Loop --------------------
def get_tool_schemas(self) -> list[dict[str, Any]]:
return []
def on_session_end(self, messages: list[dict[str, Any]]) -> None:
self._flush()
def shutdown(self) -> None:
self._flush()
def register(ctx) -> None:
ctx.register_memory_provider(MC2MemoryProvider())
-5
View File
@@ -1,5 +0,0 @@
name: mc2-memory
version: 1.0.0
description: "MC2 Memory — context-only Provider, der Hermes ans geteilte Mem0-Gedächtnis (MC2-Sidecar) hängt: automatisches Lernen nach jedem Turn + semantischer Recall davor. Keine Agent-Tools (kein Tool-Loop)."
pip_dependencies:
- httpx
+3 -3
View File
@@ -216,7 +216,7 @@ def system_status() -> str:
@mcp.tool() @mcp.tool()
def list_services() -> str: def list_services() -> str:
"""Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Mem0, Voice).""" """Erreichbarkeit aller Stack-Dienste (Engine, Gateway, Hermes, Voice)."""
data = _get("/api/system/services") data = _get("/api/system/services")
return "\n".join( return "\n".join(
f"{'' if s['ok'] else ''} {s['name']} ({s['url']})" f"{'' if s['ok'] else ''} {s['name']} ({s['url']})"
@@ -227,7 +227,7 @@ def list_services() -> str:
@mcp.tool() @mcp.tool()
def restart_service(service: str) -> str: def restart_service(service: str) -> str:
"""Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 | """Startet einen erlaubten Dienst neu. Erlaubt: llama-swap (Engine) | mission-control-2 |
hermes-gateway | hermes-terminal | mem0-service | voice-service. Der Engine-Neustart hermes-gateway | hermes-terminal | voice-service. Der Engine-Neustart
(llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist.""" (llama-swap = System-Dienst) kann das Box-Passwort verlangen, wenn kein NOPASSWD gesetzt ist."""
res = _post("/api/system/restart", {"service": service}) res = _post("/api/system/restart", {"service": service})
return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}" return f"Restart {service}: {'ok' if res.get('ok') else 'Fehler — ' + res.get('err', '')}"
@@ -235,7 +235,7 @@ def restart_service(service: str) -> str:
@mcp.tool() @mcp.tool()
def backup_now() -> str: def backup_now() -> str:
"""Erstellt SOFORT ein Voll-Backup (mem0 + Configs + Secrets). Nutze dies vor riskanten """Erstellt SOFORT ein Voll-Backup (Hermes-Configs + Secrets + llama-swap). Nutze dies vor riskanten
Änderungen oder wenn der Nutzer ein Backup will.""" Änderungen oder wenn der Nutzer ein Backup will."""
r = _post("/api/system/backup") r = _post("/api/system/backup")
return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}" return f"Backup erstellt: {json.dumps(r, ensure_ascii=False)}"
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env python3
"""
Mission Control 2.0 — Memory MCP Server (geteiltes Gedächtnis für ALLE Tools).
Stdio-MCP-Server. Läuft als Subprocess von Cline/Claude Code/OpenCode/Hermes.
Ruft die /api/memory-Endpunkte von Mission Control via HTTP auf.
Tool-Beschreibungen bewusst als GUARDS formuliert (konditional, nicht imperativ),
damit kleine Modelle (z.B. Hermes 14B) nicht in Aufruf-Schleifen laufen.
Env: MC_URL (default http://192.168.178.151:9001), MC_TOKEN (optional).
Install (auf dem Rechner des Tools): pip install mcp httpx
"""
import os
import httpx
from mcp.server.fastmcp import FastMCP
MC_URL = os.environ.get("MC_URL", "http://192.168.178.151:9001").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
mcp = FastMCP("mission-control-memory")
def _h() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
def _get(path: str, **params):
r = httpx.get(f"{MC_URL}{path}", headers=_h(), params=params, timeout=10)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict):
r = httpx.post(f"{MC_URL}{path}", headers=_h(), json=data, timeout=10)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict):
r = httpx.put(f"{MC_URL}{path}", headers=_h(), json=data, timeout=10)
r.raise_for_status()
return r.json()
def _delete(path: str):
r = httpx.delete(f"{MC_URL}{path}", headers=_h(), timeout=10)
r.raise_for_status()
return r.json()
@mcp.tool()
def get_memories(category: str = "") -> str:
"""Lädt gespeicherte Fakten/Entscheidungen aus dem geteilten Gedächtnis.
Nutze dies EINMAL am Session-Beginn, wenn du Projekt-Kontext brauchst — nicht wiederholt.
category: identity | knowledge | rules | events | (leer = alle)"""
items = _get("/api/memory", **({"category": category} if category else {}))
if not items:
return "Keine Memories gespeichert."
icon = {"identity": "👤", "knowledge": "🔵", "rules": "📋", "events": "🕒"}
return "\n".join(
f"{icon.get(m['category'], '·')} [{m['category']}] {m['content']} (ID: {m['id'][:8]})"
for m in items
)
@mcp.tool()
def search_memories(q: str) -> str:
"""Sucht SEMANTISCH (nach Bedeutung, nicht nur Stichwort) in den Fakten. Nutze dies
VOR add_memory (Dubletten-Check) oder wenn du eine konkrete frühere Entscheidung suchst.
Treffer sind nach Relevanz sortiert."""
items = _get("/api/memory", q=q)
if not items:
return f"Keine Treffer für '{q}'."
def _line(m):
sc = f" {m['score']:.2f}" if isinstance(m.get("score"), (int, float)) else ""
return f"[{m['category']}{sc}] {m['content']} (ID: {m['id'][:8]})"
return "\n".join(_line(m) for m in items)
@mcp.tool()
def add_memory(content: str, category: str = "knowledge", source: str = "agent") -> str:
"""Speichert EINEN dauerhaften Fakt im geteilten Gedächtnis (alle Tools sehen ihn).
Nur aufrufen, WENN gerade etwas Dauerhaftes entstanden ist (Konvention, Architektur-
Entscheidung, Tech-Version, Nutzer-Präferenz) UND es noch nicht existiert (vorher
search_memories!). Knapp & atomar. Existiert ein passender Eintrag → update_memory.
category: identity (Nutzer/Vorlieben) | knowledge (Fakten/Stack) | rules (Regeln/Konventionen) | events (Ereignisse/Entscheidungen)"""
m = _post("/api/memory", {"content": content, "category": category, "source": source})
return f"Gespeichert (ID: {m['id'][:8]}): {content}"
@mcp.tool()
def update_memory(memory_id: str, content: str = "", category: str = "") -> str:
"""Aktualisiert einen bestehenden Eintrag (nur gesetzte Felder)."""
data = {k: v for k, v in (("content", content), ("category", category)) if v}
if not data:
return "Nichts zu aktualisieren."
m = _put(f"/api/memory/{memory_id}", data)
return f"Aktualisiert: {m['content']}"
@mcp.tool()
def delete_memory(memory_id: str) -> str:
"""Löscht einen veralteten/falschen Eintrag anhand seiner ID."""
_delete(f"/api/memory/{memory_id}")
return f"Eintrag {memory_id[:8]} gelöscht."
@mcp.tool()
def learn(text: str) -> str:
"""Lässt das Gedächtnis AUTOMATISCH aus einem Gesprächsausschnitt lernen: reiche
rohe Nutzer-Aussagen/Turns durch — das Gedächtnis EXTRAHIERT die dauerhaften Fakten
selbst und mischt sie ein (ohne Dubletten). Nutze dies, wenn der Nutzer beiläufig etwas
Dauerhaftes über sich/seine Vorlieben/das Projekt erwähnt hat — du musst nicht selbst
den Fakt formulieren. Für einen bereits fertig formulierten Einzel-Fakt → add_memory."""
res = _post("/api/memory/learn", {"text": text})
items = res.get("results", []) if isinstance(res, dict) else []
learned = [r for r in items if r.get("event") in (None, "ADD", "UPDATE")]
if not learned:
return "Nichts Neues gelernt (keine dauerhaften Fakten erkannt)."
return "Gelernt:\n" + "\n".join(f"· {r.get('content')}" for r in learned)
if __name__ == "__main__":
mcp.run()
-483
View File
@@ -1,483 +0,0 @@
#!/usr/bin/env python3
"""
Mem0-Sidecar für Mission Control 2.0 — das auto-lernende, semantische Gedächtnis.
WARUM ein eigener Dienst? Mem0 + chromadb laufen nur unter Python 3.12 (`~/.mem0/venv`),
das MC2-Backend aber unter Python 3.14 (kann mem0 nicht importieren). Darum kapselt dieser
schlanke FastAPI-Dienst die Mem0-Memory-Instanz und exponiert sie auf localhost. MC2
(`backend/services/memory.py`) spricht ihn per HTTP an — die `/api/memory`-API-Form bleibt
nach außen unverändert (UI + MCP-Server kompatibel).
Aufbau:
- Vektor-Store : Chroma embedded (kein Docker auf der Box) — Pfad MEM0_CHROMA_PATH.
- Embeddings : llama.cpp `embed`-Rolle über llama-swap (/v1/embeddings, 1024 Dim).
- LLM-Extraktion: lokales `fast`-Hirn (Qwen3.6) über llama-swap (/v1/chat/completions).
Thinking wird abgeschaltet (NoThinkLLM), sonst bricht die JSON-Extraktion ab.
Läuft als systemd-User-Dienst (deploy/mem0-service.service) im `~/.mem0/venv`.
Bind: 127.0.0.1 (nur lokal; MC2 proxyt nach außen).
"""
import logging
import os
import re
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from mem0 import Memory
from mem0.configs.llms.openai import OpenAIConfig
from mem0.llms.openai import OpenAILLM
from pydantic import BaseModel
log = logging.getLogger("mem0_service")
# --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ----------
USER_ID = os.environ.get("MEM0_USER_ID", "mission-control") # ein geteiltes Gehirn
EMBED_URL = os.environ.get("MEM0_EMBED_URL", "http://127.0.0.1:8080/v1")
LLM_URL = os.environ.get("MEM0_LLM_URL", "http://127.0.0.1:8080/v1")
EMBED_MODEL = os.environ.get("MEM0_EMBED_MODEL", "embed")
LLM_MODEL = os.environ.get("MEM0_LLM_MODEL", "fast")
EMBED_DIMS = int(os.environ.get("MEM0_EMBED_DIMS", "1024"))
CHROMA_PATH = os.environ.get("MEM0_CHROMA_PATH", "/srv/models/mem0/chroma")
HISTORY_DB = os.environ.get("MEM0_HISTORY_DB", "/srv/models/mem0/history.db")
COLLECTION = os.environ.get("MEM0_COLLECTION", "mc2")
API_KEY = os.environ.get("MEM0_API_KEY", "sk-local") # llama.cpp ignoriert den Key
LLM_MAX_TOKENS = int(os.environ.get("MEM0_LLM_MAX_TOKENS", "2048"))
SEARCH_TOP_K = int(os.environ.get("MEM0_SEARCH_TOP_K", "50"))
# --- Rerank-Zweitstufe (07.07.2026): Die Vektor-Suche findet ÄHNLICHES, der Reranker ordnet
# nach ECHTER Relevanz (Qwen3-Reranker via llama-swap /v1/rerank). Es ändert sich NUR die
# Reihenfolge: `score` bleibt der Vektor-Score (RECALL_MIN_SCORE im Hermes-Plugin bleibt
# kalibriert), Konsumenten, die vorne abschneiden (RECALL_LIMIT), bekommen automatisch die
# relevantesten Fakten. Jeder Fehler → lautlos Vektor-Reihenfolge (Gedächtnis bricht NIE).
RERANK_ENABLED = os.environ.get("MC_RERANK_ENABLED", "1") != "0"
RERANK_URL = os.environ.get("MC_RERANK_URL", "http://127.0.0.1:8080/v1/rerank")
RERANK_MODEL = os.environ.get("MC_RERANK_MODEL", "Qwen3-Reranker-0.6B")
RERANK_TIMEOUT = float(os.environ.get("MC_RERANK_TIMEOUT", "8"))
RERANK_CANDIDATES = int(os.environ.get("MC_RERANK_CANDIDATES", "20")) # nur die Top-N neu ordnen (Latenz)
# Kategorien = fundierte Memory-Taxonomie (semantisch/prozedural/episodisch), bewusst knapp (4).
# Mem0-OSS kann nicht nativ klassifizieren (Cloud-Feature) → wir lassen das Hirn beim Lernen
# einordnen (classify_facts). Die Beschreibungen steuern die Treffsicherheit der Auto-Zuordnung.
CATEGORIES = ("identity", "knowledge", "rules", "events")
DEFAULT_CATEGORY = "knowledge"
CATEGORY_DESCRIPTIONS = {
"identity": "Identität & Vorlieben des Nutzers: wer er ist (Name, Rolle), wie er angesprochen werden "
"will, persönliche Vorlieben und seine Arbeitsweise.",
"knowledge": "Wissen & Fakten: stabile Fakten über Stack, Projekte, Tools, Infrastruktur und Umgebung.",
"rules": "Regeln & Konventionen: verbindliche Anweisungen und Workflows — wie etwas gemacht werden "
"soll, Do's und Don'ts.",
"events": "Ereignisse & Entscheidungen: was passiert ist, getroffene Entscheidungen, zeitgebundene Vorgänge.",
}
class NoThinkLLM(OpenAILLM):
"""`fast` (Qwen3.6) ist ein Thinking-Modell. Unter response_format=json_object
verbrennt das Reasoning Tokens und schneidet die JSON-Fakten ab (leeres Ergebnis).
`enable_thinking=false` (chat_template_kwargs) → deterministische, schnelle Extraktion."""
def generate_response(self, messages, response_format=None, tools=None,
tool_choice="auto", **kwargs):
eb = dict(kwargs.pop("extra_body", {}) or {})
eb.setdefault("chat_template_kwargs", {"enable_thinking": False})
return super().generate_response(
messages, response_format=response_format, tools=tools,
tool_choice=tool_choice, extra_body=eb, **kwargs,
)
def build_memory() -> Memory:
os.environ.setdefault("OPENAI_API_KEY", API_KEY)
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
os.makedirs(CHROMA_PATH, exist_ok=True)
os.makedirs(os.path.dirname(HISTORY_DB), exist_ok=True)
cfg = {
"vector_store": {"provider": "chroma", "config": {
"collection_name": COLLECTION, "path": CHROMA_PATH}},
"embedder": {"provider": "openai", "config": {
"model": EMBED_MODEL, "embedding_dims": EMBED_DIMS,
"openai_base_url": EMBED_URL, "api_key": API_KEY}},
"llm": {"provider": "openai", "config": {
"model": LLM_MODEL, "openai_base_url": LLM_URL, "api_key": API_KEY,
"temperature": 0.1, "max_tokens": LLM_MAX_TOKENS}},
"history_db_path": HISTORY_DB,
# Sprache zwingend Deutsch erzwingen — mem0s englischer Extraktions-Prompt zieht sonst
# Richtung Englisch. Eigennamen/Fachbegriffe bleiben unangetastet.
"custom_instructions": (
"RELEVANZ (ZWINGEND, WICHTIGSTE REGEL): Extrahiere NUR dauerhaft nützliche Fakten ÜBER DEN "
"NUTZER (Identität, Vorlieben, Arbeitsweise), seine Projekte/Entscheidungen/Stände, stabile "
"Fakten über Stack/Tools/Infrastruktur sowie verbindliche Regeln und Workflows. "
"NIEMALS FAKTEN ÜBER DEN ASSISTENTEN SELBST (ZWINGEND): Extrahiere KEINE Aussagen über den "
"Assistenten/die KI (Hermes, Lucy), seine Fähigkeiten, seinen Zustand, seine Tools/MCP-Server, "
"seine Cronjobs, Commit-/Versionsstände oder welche Modelle er nutzt. Das Subjekt eines Fakts "
"ist IMMER der Nutzer oder sein Projekt — niemals 'der Assistent', 'Hermes' oder 'Lucy'. "
"KEINE ZEITGEBUNDENEN ZUSTÄNDE: Ignoriere transiente Momentaufnahmen (Fehler-/Job-Läufe, "
"Commit-Rückstand, Versions-Updates, 'gerade dabei'-/'hochgefahren'-/'aufgewärmt'-Status, "
"Test- und Onboarding-Fragen). IGNORIERE VOLLSTÄNDIG und gib KEINE Fakten zurück bei: "
"Tagesnachrichten und Welt-/Politik-/Börsen-/Promi-Ereignissen, Wetter, allgemeinem Weltwissen "
"ohne Bezug zum Nutzer, vom Assistenten erzeugten Zusammenfassungen oder Recherche-Ergebnissen, "
"Smalltalk, sowie Meta-Aussagen über den Gesprächsverlauf (z.B. 'der Nutzer fragt nach X', "
"'der Nutzer möchte Y prüfen', 'der Assistent tat Z'). Speichere nur, was in Wochen noch "
"nützlich ist; im Zweifel NICHT speichern. "
"SPRACHE (ZWINGEND): Formuliere ALLE Fakten auf DEUTSCH — unabhängig von der "
"Eingabesprache, niemals auf Englisch übersetzen. Eigennamen, Befehle, Pfade, Modell- und "
"Produktnamen sowie etablierte Fachbegriffe (z.B. PowerShell-Push, Deploy, Qwen3.6, Neovim, "
"Vulkan) bleiben unverändert. Schreibe jeden Fakt knapp und atomar — genau eine Aussage."
),
}
m = Memory.from_config(cfg)
# LLM gegen die Thinking-freie Variante tauschen (gleiche Verbindung).
m.llm = NoThinkLLM(OpenAIConfig(
model=LLM_MODEL, openai_base_url=LLM_URL, api_key=API_KEY,
temperature=0.1, max_tokens=LLM_MAX_TOKENS,
))
return m
_mem: Memory | None = None
def mem() -> Memory:
global _mem
if _mem is None:
_mem = build_memory()
return _mem
@asynccontextmanager
async def lifespan(_app: FastAPI):
logging.basicConfig(level=logging.INFO)
try:
mem() # Memory beim Start aufbauen (Chroma öffnen, Modelle anpingen)
log.info("Mem0 bereit (chroma=%s, embed=%s, llm=%s)", CHROMA_PATH, EMBED_MODEL, LLM_MODEL)
except Exception:
log.exception("Mem0-Init fehlgeschlagen (Dienst läuft, /health meldet down)")
yield
app = FastAPI(title="MC2 Mem0 Sidecar", lifespan=lifespan)
# --- Mapping Mem0 <-> MC2-API-Form -----------------------------------------------
def _to_item(r: dict, score=None) -> dict:
meta = r.get("metadata") or {}
out = {
"id": r.get("id"),
"content": r.get("memory", ""),
"category": meta.get("category") or "stable",
"bereich": meta.get("bereich") or "",
"source": meta.get("source") or "auto",
"created_at": r.get("created_at") or "",
"updated_at": r.get("updated_at") or r.get("created_at") or "",
}
if score is not None:
try:
out["score"] = round(float(score), 4)
except (TypeError, ValueError):
pass
return out
class MemIn(BaseModel):
content: str
category: str = DEFAULT_CATEGORY
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = DEFAULT_CATEGORY
classify: bool = True # Hirn ordnet die gelernten Fakten automatisch ein
@app.get("/health")
def health() -> dict:
try:
mem().get_all(filters={"user_id": USER_ID}, top_k=1)
return {"ok": True}
except Exception as exc:
raise HTTPException(503, f"mem0 nicht bereit: {exc}")
def _rerank(query: str, items: list[dict]) -> list[dict]:
"""Suchtreffer nach echter Relevanz umordnen; bei jedem Fehler unverändert zurück."""
if not (RERANK_ENABLED and len(items) > 1):
return items
try:
import httpx
r = httpx.post(RERANK_URL, json={
"model": RERANK_MODEL,
"query": query,
"documents": [i.get("content", "") for i in items],
"top_n": len(items),
}, timeout=RERANK_TIMEOUT)
r.raise_for_status()
results = r.json().get("results") or []
ranked = []
for s in sorted(results, key=lambda x: x.get("relevance_score", 0.0), reverse=True):
idx = s.get("index")
if isinstance(idx, int) and 0 <= idx < len(items):
item = dict(items[idx])
item["rerank_score"] = round(float(s.get("relevance_score", 0.0)), 4)
ranked.append(item)
# Nur übernehmen, wenn der Reranker ALLE Kandidaten bewertet hat (sonst Verlustgefahr).
return ranked if len(ranked) == len(items) else items
except Exception as exc:
log.debug("rerank übersprungen: %s", exc)
return items
@app.get("/memory")
def list_memory(q: str = "", category: str = "", bereich: str = "") -> list[dict]:
"""q gesetzt → semantische Suche (mit Relevanz-Score). Sonst → alle Fakten.
bereich='alltag'|'projekt' filtert (Fakten OHNE Bereichs-Feld laufen immer mit,
damit Alt-Bestand nie verschwindet)."""
if q:
res = mem().search(q, filters={"user_id": USER_ID}, top_k=SEARCH_TOP_K)
items = [_to_item(r, r.get("score")) for r in res.get("results", [])]
# Zweitstufe: die vorderen Kandidaten nach echter Relevanz ordnen (Rest bleibt hinten dran).
items = _rerank(q, items[:RERANK_CANDIDATES]) + items[RERANK_CANDIDATES:]
else:
res = mem().get_all(filters={"user_id": USER_ID}, top_k=1000)
items = [_to_item(r) for r in res.get("results", [])]
items.sort(key=lambda i: i.get("created_at", ""), reverse=True)
if category:
items = [i for i in items if i["category"] == category]
if bereich in ("alltag", "projekt"):
items = [i for i in items if i.get("bereich") in (bereich, "")]
return items
@app.post("/memory", status_code=201)
def add_memory(body: MemIn) -> dict:
"""Manueller/agentischer Einzel-Fakt → VERBATIM speichern (infer=False, keine
LLM-Umformung). Auto-Lernen aus Gesprächen läuft über /learn (infer=True)."""
content = body.content.strip()
if not content:
raise HTTPException(400, "content leer")
res = mem().add(
[{"role": "user", "content": content}],
user_id=USER_ID, infer=False,
metadata={"category": body.category, "source": body.source},
)
results = res.get("results", [])
if not results:
raise HTTPException(500, "Mem0 hat nichts gespeichert")
new_id = results[0]["id"]
full = mem().get(new_id) or {}
return _to_item(full) if full else _to_item({"id": new_id, "memory": content,
"metadata": {"category": body.category, "source": body.source}})
@app.put("/memory/{mid}")
def update_memory(mid: str, body: MemUp) -> dict:
existing = mem().get(mid)
if not existing:
raise HTTPException(404, "Eintrag nicht gefunden")
cur = _to_item(existing)
new_content = body.content.strip() if body.content is not None else cur["content"]
new_cat = body.category if body.category is not None else cur["category"]
mem().update(mid, data=new_content,
metadata={"category": new_cat, "source": cur["source"]})
full = mem().get(mid) or {}
return _to_item(full)
@app.delete("/memory/{mid}")
def delete_memory(mid: str) -> dict:
if not mem().get(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
mem().delete(mid)
return {"ok": True}
@app.get("/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Graph: Knoten = Fakten, Kanten = semantische Ähnlichkeit (Kosinus
der gespeicherten Embeddings, je Knoten die top_k Nachbarn ≥ min_score). Für die
Obsidian-artige Gedächtnis-Visualisierung im UI."""
items = mem().get_all(filters={"user_id": USER_ID}, top_k=2000).get("results", [])
nodes = [_to_item(r) for r in items]
node_ids = [n["id"] for n in nodes]
idx = {nid: i for i, nid in enumerate(node_ids)}
# Embeddings direkt aus der Chroma-Collection ziehen (kein Re-Embedding).
col = mem().vector_store.collection
raw = col.get(include=["embeddings"])
raw_ids = raw.get("ids") or []
raw_embs = raw.get("embeddings")
edges: list[dict] = []
vecs = [None] * len(node_ids)
have = 0
if raw_embs is not None:
for rid, emb in zip(raw_ids, raw_embs):
if rid in idx and emb is not None:
vecs[idx[rid]] = emb
have += 1
if have >= 2:
import numpy as np
present = [i for i, v in enumerate(vecs) if v is not None]
M = np.array([vecs[i] for i in present], dtype=float)
norms = np.linalg.norm(M, axis=1, keepdims=True)
norms[norms == 0] = 1.0
Mn = M / norms
sim = Mn @ Mn.T
seen: set[tuple[int, int]] = set()
for a in range(len(present)):
order = np.argsort(-sim[a])
cnt = 0
for b in order:
if b == a:
continue
s = float(sim[a][b])
if s < min_score:
break
i, j = present[a], present[b]
key = (min(i, j), max(i, j))
if key not in seen:
seen.add(key)
edges.append({"source": node_ids[i], "target": node_ids[j],
"weight": round(s, 3)})
cnt += 1
if cnt >= top_k:
break
return {"nodes": nodes, "edges": edges}
def classify_facts(facts: list[tuple[str, str]]) -> dict:
"""Ordnet jeden Fakt per LLM einer Kategorie UND einem Bereich zu →
{id: {"category": <slug>, "bereich": "alltag"|"projekt"}}.
Bereich (19.07.2026, Trennung der Welten): "alltag" = der Nutzer als Person
(Vorlieben, Leben, Arbeitsweise, Entscheidungen), "projekt" = Technik-/Code-/
Infrastruktur-Stände. Grundlage für einen späteren Recall-Filter, damit
Projekt-Schutt nicht in Alltags-Gespräche streut — OHNE zweites Gedächtnis.
Nutzt dasselbe (Thinking-freie) Hirn wie die Extraktion. Fehler = leeres Mapping."""
if not facts:
return {}
import json as _json
import re as _re
cat_lines = "\n".join(f"- {k}: {v}" for k, v in CATEGORY_DESCRIPTIONS.items())
system = (
"Du bist ein präziser Klassifikator für ein Langzeitgedächtnis. Ordne jeden Fakt GENAU EINER "
"Kategorie zu. Verfügbare Kategorien (nur diese Slugs verwenden):\n" + cat_lines +
"\nOrdne zusätzlich jeden Fakt GENAU EINEM Bereich zu: 'alltag' (der Nutzer als Person: "
"Vorlieben, Leben, Anrede, Arbeitsweise, grundsätzliche Entscheidungen) oder 'projekt' "
"(Technik: Code, Modelle, Server, Infrastruktur, Projekt- und Fehlerstände). "
"Antworte NUR als JSON-Objekt der Form "
"{\"<id>\": {\"kategorie\": \"<slug>\", \"bereich\": \"alltag|projekt\"}, …}."
)
body = "\n".join(f"{fid}: {txt}" for fid, txt in facts)
try:
resp = mem().llm.generate_response(
messages=[{"role": "system", "content": system},
{"role": "user", "content": "Fakten:\n" + body}],
response_format={"type": "json_object"},
)
m = _re.search(r"\{.*\}", resp or "", _re.DOTALL)
data = _json.loads(m.group(0)) if m else {}
out: dict = {}
for k, v in data.items():
if isinstance(v, str): # Alt-Form {"id": "slug"} weiter akzeptieren
if v in CATEGORIES:
out[str(k)] = {"category": v, "bereich": ""}
elif isinstance(v, dict):
cat = v.get("kategorie") or v.get("category") or ""
ber = v.get("bereich") or ""
if cat in CATEGORIES or ber in ("alltag", "projekt"):
out[str(k)] = {"category": cat if cat in CATEGORIES else DEFAULT_CATEGORY,
"bereich": ber if ber in ("alltag", "projekt") else ""}
return out
except Exception:
log.exception("Auto-Klassifikation fehlgeschlagen")
return {}
# Deterministischer Guard: mem0-OSS extrahiert trotz custom_instructions hartnäckig Fakten ÜBER
# den Assistenten selbst (Hermes/Lucy) und transiente Zustände (Commit-Rückstand, Cronjob-Läufe,
# Onboarding-/Test-Fragen). Diese Muster werden nach der Extraktion verworfen (der Fakt wird wieder
# gelöscht) — reine Sicherungsschicht, unabhängig vom Extraktions-LLM.
_JUNK_FACT_PATTERNS = [
r"^\s*(?:der|die|das)\s+assistent", # "Der Assistent ..." (Selbstbeschreibung)
r"\bassistent(?:in)?\s+(?:hermes|lucy)\b", # "... Assistent Hermes/Lucy ..."
r"\bhermes[- ]agent\b",
r"commits?\s+hinter", # Versions-Rückstand (transient)
r"\bcronjob\b",
r"beim\s+letzten\s+(?:lauf|mal)",
r"onboarding[-\s]?frage|\btest-?frage\b",
r"hat\s+den\s+assistenten\s+gebeten",
r"auf\s+betriebstemperatur|hochgefahren",
r"aktualisiert\s+sein\s+ged(?:ä|ae)chtnis", # Selbst-Meta über das Gedächtnis
]
_JUNK_FACT_RE = re.compile("|".join(_JUNK_FACT_PATTERNS), re.IGNORECASE)
def _is_junk_fact(text: str) -> bool:
"""True, wenn der extrahierte Fakt Assistenten-Meta oder ein transienter Zustand ist."""
return bool(_JUNK_FACT_RE.search(text or ""))
@app.post("/learn")
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst
(infer=True). Anschließend ordnet das Hirn jeden neuen Fakt automatisch einer Kategorie zu."""
msgs = body.messages
if not msgs and body.text:
msgs = [{"role": "user", "content": body.text}]
if not msgs:
raise HTTPException(400, "text oder messages erforderlich")
res = mem().add(
msgs, user_id=USER_ID, infer=True,
metadata={"category": body.category, "source": body.source},
)
results = res.get("results", [])
# Guard: frisch extrahierte Assistenten-Meta/transiente Fakten sofort wieder verwerfen.
kept = []
for r in results:
if r.get("event") in ("ADD", "UPDATE") and _is_junk_fact(r.get("memory", "")):
try:
mem().delete(r["id"])
except Exception:
log.debug("Junk-Fakt-Löschung fehlgeschlagen für %s", r.get("id"))
log.info("mem0 guard: Junk-Fakt verworfen: %s", (r.get("memory") or "")[:80])
continue
kept.append(r)
results = kept
cats: dict = {}
if body.classify:
to_classify = [(r["id"], r.get("memory", "")) for r in results
if r.get("id") and r.get("memory") and r.get("event") in ("ADD", "UPDATE")]
cats = classify_facts(to_classify)
for r in results:
info = cats.get(r.get("id"))
if info:
meta = {"category": info["category"], "source": body.source}
if info.get("bereich"):
meta["bereich"] = info["bereich"]
try:
mem().update(r["id"], data=r.get("memory", ""), metadata=meta)
except Exception:
log.debug("Kategorie-Update fehlgeschlagen für %s", r.get("id"))
return {"results": [
{"id": r.get("id"), "content": r.get("memory"), "event": r.get("event"),
"category": (cats.get(r.get("id")) or {}).get("category", body.category),
"bereich": (cats.get(r.get("id")) or {}).get("bereich", "")}
for r in results
]}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("MEM0_PORT", "8765")))
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""
Einmalige Migration: alte SQLite-Fakten (`memories`-Tabelle aus dem v1/v2-Memory)
→ Mem0. Läuft im ~/.mem0/venv. Idempotent über eine Marker-Datei.
Jeder Alt-Eintrag wird VERBATIM übernommen (infer=False), Kategorie/Source als
Metadaten. Beim aktuellen Box-Stand ist die DB leer → No-Op, aber für
Reproduzierbarkeit/künftige Migrationen vorhanden.
Aufruf: ~/.mem0/venv/bin/python migrate.py [pfad/zur/mc2-memory.db]
"""
import os
import sqlite3
import sys
from app import USER_ID, mem # nutzt dieselbe Mem0-Instanz/Config
DEFAULT_DB = os.environ.get("MC_MEMORY_DB", "/srv/models/mc2-memory.db")
MARKER = os.environ.get("MEM0_MIGRATION_MARKER", "/srv/models/mem0/.migrated_sqlite")
def main() -> int:
db_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DB
if os.path.exists(MARKER):
print(f"Migration bereits erledigt (Marker {MARKER}).")
return 0
if not os.path.exists(db_path):
print(f"Keine Alt-DB unter {db_path} — nichts zu migrieren.")
os.makedirs(os.path.dirname(MARKER), exist_ok=True)
open(MARKER, "w").close()
return 0
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT content, category, source FROM memories ORDER BY created_at"
).fetchall()
except sqlite3.OperationalError:
rows = []
conn.close()
m = mem()
n = 0
for r in rows:
content = (r["content"] or "").strip()
if not content:
continue
m.add([{"role": "user", "content": content}], user_id=USER_ID, infer=False,
metadata={"category": r["category"] or "stable",
"source": r["source"] or "migrated"})
n += 1
os.makedirs(os.path.dirname(MARKER), exist_ok=True)
open(MARKER, "w").close()
print(f"Migriert: {n} Einträge aus {db_path} → Mem0.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-9
View File
@@ -1,9 +0,0 @@
# Mem0-Sidecar-Deps im ~/.mem0/venv (Python 3.12). BEWUSST GEPINNT — Upgrades nur
# absichtlich, weil der Sidecar mem0-INTERNA nutzt (NoThink-LLM-Swap, Roh-Zugriff auf die
# Chroma-Collection für den Graphen, Annahmen übers Result-Format). Ein unkontrolliertes
# mem0/chromadb-Upgrade kann das still brechen.
# Upgrade-Prozedur (Backup → Pin anheben → smoke_test.py): siehe docs/UPGRADE.md.
mem0ai==2.0.8
chromadb==1.5.9
fastapi==0.138.1
uvicorn[standard]==0.49.0
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""
Smoke-Test für den Mem0-Sidecar — VOR und NACH jedem mem0/chromadb-Upgrade laufen lassen.
Prüft genau die Pfade, die mem0-INTERNA nutzen (LLM-Extraktion/NoThink, Auto-Einordnung,
Embeddings/Suche, Roh-Chroma-Graph). Nutzt eine WEGWERF-Collection unter /tmp — das echte
Gedächtnis (/srv/models/mem0) wird NICHT angefasst.
Aufruf: ~/.mem0/venv/bin/python ~/mission-control-v2/mem0_service/smoke_test.py
Exit 0 = alles grün, sonst 1.
"""
import os
import shutil
import sys
import tempfile
import time
# WICHTIG: Env vor dem Import von app setzen (app liest die Pfade beim Modul-Load).
_TMP = tempfile.mkdtemp(prefix="mem0_smoke_")
os.environ["MEM0_CHROMA_PATH"] = os.path.join(_TMP, "chroma")
os.environ["MEM0_HISTORY_DB"] = os.path.join(_TMP, "history.db")
os.environ["MEM0_COLLECTION"] = f"smoke_{int(time.time())}"
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import app
_fails: list[str] = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(("PASS" if cond else "FAIL"), "", name, ("" if cond else f":: {detail}"))
if not cond:
_fails.append(name)
def main() -> int:
app.mem() # Memory bauen (öffnet Chroma, pingt Modelle)
# 1) Verbatim add + Rücklesen
r = app.add_memory(app.MemIn(content="Smoke: der Himmel ist blau.", category="knowledge", source="smoke"))
check("verbatim add + get", bool(r.get("id")) and "blau" in r.get("content", ""), str(r)[:160])
# 2) Embeddings / semantische Suche (Bedeutung, nicht Wort)
res = app.list_memory(q="Welche Farbe hat der Himmel?")
check("semantische Suche", any("blau" in i.get("content", "") for i in res), str(res)[:160])
# 3) Auto-Lernen: LLM-Extraktion (NoThink) + Deutsch erzwungen
lr = app.learn(app.LearnIn(text="Ich heiße Max und programmiere am liebsten in Go.", source="smoke"))
items = lr.get("results", [])
check("Auto-Lernen extrahiert Fakten", len(items) > 0, str(lr)[:160])
joined = " ".join((i.get("content") or "") for i in items).lower()
check("Extraktion auf Deutsch", any(w in joined for w in ("heißt", "programmiert", "mag", "bevorzugt", "name", "nutzt")), joined[:160])
# 4) Auto-Einordnung in die 4 gültigen Kategorien
cats = {i.get("category") for i in items if i.get("category")}
check("Auto-Einordnung gültige Kategorie", bool(cats) and cats <= set(app.CATEGORIES), str(cats))
# 5) Graph aus den Roh-Embeddings (Chroma-Interna)
g = app.graph()
check("Graph liefert Knoten", len(g.get("nodes", [])) >= 1, str({k: len(v) for k, v in g.items()}))
print("\n" + ("ALLE TESTS GRÜN ✓" if not _fails else f"FEHLGESCHLAGEN: {_fails}"))
return 0 if not _fails else 1
if __name__ == "__main__":
try:
code = main()
finally:
shutil.rmtree(_TMP, ignore_errors=True)
sys.exit(code)