Author SHA1 Message Date
HitonabiandClaude Opus 5 3669a6e7dc llama-swap: --reasoning-budget 2048 fuers Hirn (Deckel gegen Denk-Ausreisser der abliterierten Variante)
Ampel / ampel (push) Failing after 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:23:17 +02:00
HitonabiandClaude Opus 5 cd54fe4a0f modelle: Hirn und Coder auf Uncensored-Varianten (Pruefstand-gestuetzt), Pruefstand-Skript ins Repo
Ampel / ampel (push) Failing after 24s
Pruefstand 17.09. (deploy/bench/pruefstand-hirn.py, Kandidat auf :5899, Hermes-Tool-Smoke per
--provider pruefstand durch den echten Agenten):
- Qwen3.6-35B-A3B-Uncensored (HauhauCS Aggressive, Q4_K_M) + giocom-DFlash: 100,8 t/s @13k
  (Original 92,8), Prefill 13,1 s, Tools 11/12 (1 Denk-Ausreisser), Hermes-Smoke gruen -> hermes/fast.
- Qwen3.8-27B-Uncensored (Heretic, Q4_K_M, eigene mmproj) + DFlash2: 30,4 t/s @13k (Original 26,2),
  Tools 6/6, Coding 3/3 ausgefuehrt (= Original), Hermes-Smoke gruen -> coder/heavy.
- Nemotron 3.5 Lightning 30B-A3B (Q4_0 + MTP): Hirn-Klasse (10,5 s Prefill @13k, 91,8 t/s, Tools 3/3,
  Hermes gruen) - liegt als Reserve-Kandidat auf der Box, nicht besetzt.
Keys/Aliase/Flags unveraendert, nur Pfade; Originale bleiben liegen (Rueckweg = Pfad zurueck).
Nebenbefund: Bild + DFlash2 beim Coder = HTTP 500 "failed to process speculative batch" - Vorbestand
seit 04.09., unabhaengig vom Modell (ohne Draft antworten beide "Rot"); Bilder gehen ueber die
Gateway-Bildweiche zu vision. Live seit 17.09. ~22:10, Stack- und Gehirn-Check gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:10:54 +02:00
HitonabiandClaude Opus 5 1e7a6d974f doku: approvals bleiben aus (User-Entscheid 17.09.), sudo-Stand und Config-Schema v45 nachgezogen
Ampel / ampel (push) Failing after 24s
Live-Wahrheit statt Juli-Stand: approvals.mode 'off' / cron_mode auto (seit spaetestens
KISS-Umbau 21.08., am 17.09. vom User bestaetigt), Box-sudo NOPASSWD: ALL, Hermes-Config
v45 mit bewusst deaktiviertem connections-Toolset und Curator 14/30 Tage. Das alte Verdikt
"cron_mode deny bleibt" ist in VERDIKTE.md als ueberholt markiert, FALLEN.md-Cron-Notiz angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:52:32 +02:00
HitonabiandClaude Opus 5 61f226e86d wartung: Engine b11026 braucht --load-mode none (statt --no-mmap); hermes update ohne eigenen Gateway-Neustart
Ampel / ampel (push) Failing after 23s
Befund 17.09.: Sonntags-Update hatte beide Ebenen gepinnt (Hermes 06.09., Engine 13.09.),
die Box stand zwei Wochen still. Live nachgestellt und behoben:

- llama.cpp hat --no-mmap zwischen b10819 und b10936 gestrichen ("invalid argument"):
  jedes Modell starb 2 s nach dem Start, der Stack-Check sah nur "rot". Ersatz
  --load-mode none in llama-swap-Config, CMD_TEMPLATE und Bench-Skripten. Gemessen auf
  b11026: Coder+DFlash2 32,0 t/s (vorher 30,0), Hirn 85 t/s, alle sieben Rollen laden.
- hermes update endet mit Exit 1, wenn sein Fleet-Check nach dem eigenen Gateway-Neustart
  keine Zeilen sieht (#93406) - unter systemd bei uns der Normalfall. Die &&-Kette des
  MC2-Jobs brach ab, autoupdate.sh rollte zurueck und pinnte, obwohl der Gehirn-Check gruen
  war. Jetzt --no-gateway-restart: Neustart und Urteil gehoeren dem Job.
- Box live: Engine b11026, Hermes v0.21.3 (main @ dd13b475), UI mit /hermes-ui/-Basis neu
  gebaut, Pins geloest. STACK.md/FALLEN.md nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:25:34 +02:00
HitonabiandClaude Fable 5.1 6809d357c0 doku: Einstieg fuer den Qwen3-TTS/audio.cpp-Pruefstand (Binaries auf der Box, Modelle, CLI, Messlatte)
Ampel / ampel (push) Failing after 24s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:13:16 +02:00
HitonabiandClaude Fable 5.1 502a6010f1 doku(raphael): Ausroll-Stand 04.09. 19:10 festgehalten (MC2 f9f2e11 deployt, Lucy be686c4, Stimmserver Artoria v2 / 8 Threads / 2 Worker, Box kann nicht klonen, ZONOS2 verworfen, Ampel-Runner tot)
Ampel / ampel (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:10:24 +02:00
HitonabiandClaude Fable 5.1 f9f2e116e0 doku(raphael): Box-Handschritte erledigt + erste E2E-Messung (TTFB 1,45 s, RTF 0,97, workers=0)
Ampel / ampel (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:29:34 +02:00
HitonabiandClaude Fable 5.1 479fecd2c6 Auftragsbuch ausgebaut: Karten-Annahme, Bagatell-Annahme, Lucy-Annahme raus — Gate ist Ampel + User-Merge
Ampel / ampel (push) Failing after 24s
Die Vorschlags-Inbox (routers/services auftragsbuch, AuftragsbuchView, /auftraege, Cockpit-
Kachel + Handlungsbedarf-Eintrag, deploy/auftrag-annehmen.sh, lucy-annahme.sh, bagatell-
annahme.sh, docs/AUFTRAGSBUCH.md) war seit 02.08.2026 ungenutzt: Kanban-Tools seit 21.08. aus,
v3-Umbau lief ueber Branch -> Ampel -> Merge -> deploy.sh, PC-Executor-IP in Box und Config
veraltet. Doku behauptete den Karten-Weg trotzdem als Standard.

Jetzt:
- Cockpit: Kachel "Ideen" (offen/haengend) statt "Auftragsbuch"; Handlungsbedarf zeigt haengende
  Ideen und springt in die Ideen-Ansicht.
- Guide/Schaubild: Kreislauf Idee -> Ideen-Queue -> IDE am PC -> Ampel -> DEIN Merge -> Live.
- events.py: kein Auftragsbuch-Fingerprint mehr; config.py: PC_EXECUTOR_URL-Default auf .22.
- Skills/Skripte: selbst-inventur ohne Karten-Abschnitt, morning-report nennt offene Branches,
  konzept-fliessband verweist auf die Ideen-Ansicht.
- Doku: STACK "Deploy & Pipeline" = Branch -> Ampel -> User-Merge -> deploy.sh (einziger Weg),
  GRENZEN/ARBEITSWEISE/FALLEN/README/BEDIENUNG/RUNBOOK/GEMINI_BRIEFING/gitea-workflow angepasst,
  ZIELBILD Punkt 9, OFFENE-FAEDEN + RAPHAEL.md: erledigt.
- Bewusst geblieben: werkstatt-SOUL/projektstart-SOUL (Werkstatt-Persona, eigener Faden),
  Chronik-Kategorie "auftragsbuch" fuer historische Eintraege.
Auf der Box bereits erledigt (Hand): mc2-bagatell.timer deaktiviert + Units archiviert,
PC-Executor-URL in ~/.hermes/config.yaml auf .22.

Baut auf wartung/lucy-stimme-proxy-raphael auf (Proxy /api/lucy/stimme/*).
Gates: eslint 0 Fehler, vitest 59/59, tsc + vite build gruen, frontend/dist committet,
py_compile gruen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:25:05 +02:00
HitonabiandClaude Fable 5.1 df8d088fac doku(raphael): Ausroll-Weg korrigiert — Branch -> Ampel -> User-Merge -> deploy.sh, nicht Auftragsbuch
Ampel / ampel (push) Failing after 23s
Der vorige Commit sprach von Karten annehmen. Befund: die Auftragsbuch-Logik liegt noch im Code
und die Box listet Branches als Karten, ist aber seit 02.08. ungenutzt (Kanban-Tools seit 21.08.
aus, v3-Umbau lief ueber Branch/Ampel/Merge/deploy.sh, PC-Executor-IP in der Box veraltet).
RAPHAEL.md beschreibt jetzt den echten Weg mit Befehlen; OFFENE-FAEDEN traegt die Leiche als
Entscheid-Punkt ein. Kein Code geaendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:07:03 +02:00
HitonabiandClaude Fable 5.1 b570e9410d feat(voice): Lucys Stimme ins LAN (/api/lucy/stimme) + Raphael-Zielbild
Ampel / ampel (push) Failing after 24s
Die Desktop-Lucy spricht nach dem Raphael-Umbau (Lucy-Repo, feature/raphael-innere-stimme)
nicht mehr mit einem eigenen pocket_server am PC, sondern mit lucy-stimme.service (:8021,
pocket-tts german_24l) auf der Box — dieselbe Stimme wie die Telegram-Sprachnachrichten.
Der Dienst bindet nur Loopback, darum reicht MC2 ihn jetzt duenn durch:
  GET  /api/lucy/stimme/health       -> pocket /health (ok|loading)
  POST /api/lucy/stimme/tts          -> WAV (Warm-up, Jobs)
  POST /api/lucy/stimme/tts/stream   -> PCM16-Stream, X-Sample-Rate durchgereicht,
                                        Upstream schliesst bei Client-Abbruch (Barge-in)
config: LUCY_STIMME_URL (Env MC_LUCY_STIMME_URL, Default http://127.0.0.1:8021).
Kein Frontend-Build noetig (nur Backend + Doku).

Doku: docs/wissen/RAPHAEL.md (Entscheid, Annahme-Reihenfolge — DIESE Karte zuerst —,
Box-Handschritte fuer die OpenAI-Fassade + Hermes-TTS auf openai/base_url :8021, Mobil via
Telegram, SOUL.md-Vorschlag im Raphael-Ton), ZIELBILD Punkt 8, OFFENE-FAEDEN, wissen/README.
VERDIKTE.md bewusst unveraendert — Ersatz erst nach Ohr-Test.
Gates: py_compile + ruff gruen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:02:18 +02:00
HitonabiandClaude Fable 5.1 e9c2599542 fix(radar): nur Aenderungen ans Modell, und kein Fehlalarm nach dem Neustart
Ampel / ampel (push) Failing after 23s
Erster Lauf des Upstream-Blicks: das Modell machte aus "unveraendert"-Zeilen
naechste Schritte und aus "Timer seit Neustart noch nicht gelaufen" ein hohes
Risiko. Beides filtert jetzt das Skript: nur ACHTUNG-Zeilen (plus Hinweis)
gehen in den Prompt, und ein Timer mit naechstem Termin auf einer Box, die
juenger als einen Tag ist, gilt als wartend, nicht als kaputt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:53:55 +02:00
HitonabiandClaude Fable 5.1 a8a6ce3b29 feat(radar): Blick nach draussen als Skript — und heavy zieht auf Qwen3.8-27B
Warum: DFlash2 fuer den Coder lag vom 19.08. bis 04.09. ungenutzt auf der
Platte, weil der KISS-Radar nur nach innen sah. stack-upstream.py fragt die
Watchlist als Fakten ab (GitHub-Issues/PRs/Releases/Branches, PyPI, neue
Repos eines HF-Autors, Zeilen einer URL), vergleicht mit dem letzten Lauf und
meldet nur Aenderungen als ACHTUNG NEU. stack-radar.sh haengt das an den
IST-Zustand; faellt es aus, bleibt der Innen-Bericht vollstaendig.
Erster Lauf fand sofort: MMQ-Issue 21284 ist geschlossen, pocket-tts 3.1.0,
Electron 44 — alles Dinge, die der Radar seit Juli haette melden sollen.

heavy: gpt-oss-120b (AA-Index 24, 60 GB) gibt die Rolle an Qwen3.8-27B ab
(Index 52, 17 GB, 31 t/s mit DFlash2) — ein Modell, zwei Rollen. gpt-oss
bleibt ohne Alias als Rollback. Live gemessen ueber :9010 mit Reasoning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:52:50 +02:00
HitonabiandClaude Fable 5.1 0944b1d93e feat(llama-swap): DFlash2-Draft für den Coder — gemessen 12,6 → 31 t/s
Ampel / ampel (push) Successful in 35s
Qwen3.8-27B lief seit 19.08. ohne sein Entwurfsmodell, obwohl es auf der Box
lag: Mainline-llama.cpp konnte DFlash2 erst seit 27.08. (PR 27342) plus
Vulkan-Fix 28.08. (PR 27812); die Engine vom 1.9. (b10733) kann beides.
Auf der Box gemessen (Vulkan, 32k, Code-Prompt): ohne Draft 12,6 t/s,
mit Q4_K_M-Draft 31 t/s bei Akzeptanz 0,78. n-max 5, f16-KV und der
Q8-Draft bringen nichts. Live-Config und Repo-Kopie sind identisch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:45:54 +02:00
HitonabiandClaude Opus 5 1c8f285151 Merge: v3-Umbau P0-P6 — Frontend-Architektur, Sicherheit, Werkzeug-Verlauf
Ampel / ampel (push) Successful in 35s
Zehn Commits vom Zweig umbau/v3-p0-ballast, Ampel dort gruen.

  P0  Ballast raus            dist 27,4 MB -> 1,65 MB, Startbuendel halbiert
  P1  Sicherheit + Tests      Geheimnisse aus dem Browser, Vitest/ESLint ins Gate
  P2  Router                  Unterzustand in der URL, Fehlergrenze pro Route
  P3  Store + Statusleiste    ein Client-Speicher, aria-live, Ultrawide-Deckel
  P4  Stream                  Messwerte gepusht statt gepollt
  P5  Ideen-Bereich           1031-Zeilen-Monolith zerlegt
  P6  Werkzeug-Verlauf        Hermes' Log lesen statt patchen — fand einen vier
                              Tage alten stillen Fehler (web_extract)
      Palette 2.0             vier Kategorien, Rueckfrage-Regel per Test verdrahtet
      React 19                Budget bewusst 125 -> 140 kB
      Chronik-Dichteleiste    zeigt WANN, bevor man liest WAS
  +   Hermes-Probe            720 Auth-Fehler/Tag weniger in Hermes' Log
  +   Zeitzone                ruff DTZ007 — die Ampel hatte recht

59 Tests (vorher 0) · ESLint 0 Fehler · 20 von 22 Befunden erledigt.
Rueckweg: git reset --hard 3d1881f && bash deploy/deploy.sh

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:28:22 +02:00
HitonabiandClaude Opus 5 041ede7f8d fix(agent): Zeitstempel mit Zeitzone — die Ampel hatte recht
Ampel / ampel (push) Successful in 36s
Die Ampel wurde ROT: ruff DTZ007, `datetime.strptime()` ohne Offset in
services/agent_aktivitaet.py. Lokal war alles gruen, weil ich ruff nicht laufen
liess — nur py_compile, tsc, vitest und den Build. Mein Fehler, nicht der der Regel.

Und die Regel ist keine Schikane: AGENTS.md haelt fest, dass naive Zeiten ueber
MC_LOCAL_TZ aufzuloesen sind, nie zu raten. Hermes schreibt Ortszeit ohne Offset;
das so durchzureichen waere bequem gewesen (der Klient zeigt sie nur an) — aber
sobald jemand spaeter damit RECHNET (Dauer ueber Mitternacht, Abgleich mit einem
Cron-Plan), waere es eine Falle, die erst zur Zeitumstellung zuschnappt.

  vorher:  2026-08-28T07:03:18
  jetzt:   2026-08-28T07:03:18+02:00

Gegengeprueft am echten Box-Log; die Anzeige schneidet weiterhin Stelle 11-19
heraus und zeigt unveraendert 07:03:18.

`ruff check .` laeuft jetzt ueber das ganze Repo sauber durch — ab hier gehoert
es vor jeden Commit, der Python anfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:04:08 +02:00
HitonabiandClaude Opus 5 33032f8fb0 feat(chronik): P6 — Dichteleiste zeigt, WANN die Box aktiv war (§4.5)
Ampel / ampel (push) Failing after 25s
Der Zugewinn gegenueber einer reinen Liste: Ein stiller Tag und eine durchgearbeitete
Nacht sehen in einer Liste gleich aus — man muss scrollen, um es zu merken. Die
Dichteleiste macht das Muster sichtbar, bevor man den ersten Eintrag liest.

48 Stufen ueber den gezeigten Zeitraum, Achsenbeschriftung an beiden Enden, und beim
Filtern zeigt sie nur die Treffer (dann in kraeftigerem Ton).

Bewusst eigenes SVG-freies Markup statt einer Diagramm-Bibliothek: Es sind Rechtecke
ueber einer Zeitachse. Recharts dafuer zu laden waere 95 kB fuer etwas, das in 30
Zeilen passt — genau der Reflex, der in P0 das Startbuendel aufgeblaeht hatte.

Eine Kleinigkeit mit Absicht: Ein einzelner Eintrag bekommt 12 % Mindesthoehe. Ohne
sie saehe ein ruhiger Tag aus wie gar keine Aktivitaet — genau die Verwechslung, die
die Leiste verhindern soll. Dafuer gibt es einen eigenen Test.

Verifiziert gegen die echte Box:
  150 Eintraege vom 02.08. bis 28.08., 25 von 48 Stufen belegt, Hoehen gestaffelt
  Filter "update" -> 30 von 150, Leiste zeigt nur noch die Treffer und wird
    hervorgehoben, Adresse /chronik?q=update

59 Tests gruen (5 neue) · ESLint 0 Fehler · Einstieg 133 400 B gzip / Budget 140 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:02:07 +02:00
HitonabiandClaude Opus 5 3249c8e942 build(react): P6 — Sprung auf React 19, und das Budget bewusst angehoben
React 18.3.1 -> 19.2.8, dazu @types/react(-dom) 19.

## Nichts brach

  tsc --noEmit sauber, 54/54 Tests gruen, ESLint 0 Fehler
  Im Browser gegen die ECHTE Box: Cockpit mit 2 Diagrammen und 4 Flaechen,
    Statusleiste live (40,8/122,7 GB, 38 °C, 532 748 115 Token), 8 Karten
  NULL Konsolen-Warnungen — keine veralteten Muster im Bestand

Das eigentliche Risiko war `react-force-graph-2d` (peer: react "*", also ungeprueft,
und es umhuellt eine Nicht-React-Bibliothek). Gegen den echten Vault der Box getestet:
Canvas 500x560, 4498 gezeichnete Pixel, keine Fehlerflaeche. Es laeuft.

## Das Budget hat angeschlagen — und ich habe es trotzdem angehoben

Gemessen kostet der Sprung 14 645 B gzip (118 762 -> 133 407) und riss damit das
in P0 gesetzte Budget von 125 000. Das Gate hat also getan, was es soll.

Beim Router (P2) habe ich in derselben Lage NICHT das Budget angefasst, sondern den
Platz zurueckgeholt (Schublade und Palette hinter lazy()). Hier geht das nicht: Der
Zuwachs IST die Plattform, es gibt nichts wegzulassen.

Also angehoben — einmalig, auf 140 000, mit der Begruendung IM Ampel-Skript, damit ein
spaeterer Leser sieht, dass es ein ueberlegter Schritt war und kein Nachgeben:
MC2 ist eine LAN-Appliance; 14 kB sind ueber Gigabit-Ethernet keine messbare Wartezeit.
Das Budget existiert gegen DRIFT — einen 24-MB-Avatar, eine 95-kB-Diagramm-Bibliothek
auf der Startseite — nicht gegen einen bewussten Plattform-Schritt. Der relative
Abstand zum Deckel bleibt derselbe wie vorher (~5 %).

## Ehrlich zum Nutzen

React 19 bringt diesem Projekt HEUTE wenig Konkretes: use(), Actions und
Server-Komponenten sind hier nicht im Spiel. Der Grund ist die Zukunft — 18 altert,
und der React Compiler ist der Pfad, auf dem die 18 offenen Warnungen aus
eslint-plugin-react-hooks (setState im Effekt, Ref-Zugriff im Render) irgendwann
nicht mehr nur Warnungen sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:59:58 +02:00
HitonabiandClaude Opus 5 24a0694b80 feat(palette): P6 — die Palette wird zum Bedienwerkzeug (§4.6)
Bisher listete Strg+K die elf Navigations-Eintraege: ein Sprungbrett. Jetzt tippt
man, WAS man will, nicht wo es liegt.

  Gehe zu   die 11 Ziele — direkt aus NAV, damit es nie zwei Listen gibt
  Tun       Wartung/Logs oeffnen, Updates suchen, Snapshot, Motor neu starten
  Finden    das Getippte in Chronik oder Wissens-Vault suchen
  An Lucy   das Getippte als Idee erfassen

## Die Sicherheitsregel ist verdrahtet, nicht nur beschrieben

Die Palette ist schnell genug, dass "Strg+K, mot, Enter" den Motor neu startet,
bevor man es zu Ende gedacht hat. Jeder eingreifende Befehl traegt darum
`rueckfrage`; die Oberflaeche haelt dann an und sagt erst, was passieren WIRD:

  "Alle geladenen Modelle fallen dabei aus dem Speicher und muessen neu aufwaermen —
   das dauert je nach Modell ueber 20 Sekunden. Laufende Antworten brechen ab."

Ein Test prueft die Regel generisch: Jeder Befehl, dessen Label auf "neu starten",
"loeschen", "deploy", "reboot" o. AE. passt, MUSS eine Rueckfrage haben. Wer kuenftig
einen eingreifenden Befehl ohne Rueckfrage ergaenzt, macht die Ampel rot.

"An Lucy" erfasst mit `art: "idee"` — durchdenken, nicht bauen. `"projekt"` wuerde
sofort ein Repo anlegen; das darf aus einem Tastendruck nie passieren. Auch das ist
getestet.

## Die "Finden"-Eintraege fuehren nicht ins Leere

Sie brauchten erst ein Ziel — sonst haette ich genau die leere Leitung gebaut, die ich
beim Werkzeug-Verlauf vermieden habe:

  ChronikView   neues Suchfeld, filtert ueber Betreff/Text/Quelle, Wort in der URL
                (/chronik?q=…), Trefferzahl in der Ueberschrift, "Filter aufheben"
                im Leer-Zustand
  WissenView    hatte das Suchfeld schon, aber nur lokal — jetzt aus /wissen?suche=
                gespeist und damit teilbar

## Verifiziert im Browser

  Strg+K oeffnet, "Gehe zu" (11) + "Tun" (5); leere Gruppen bleiben verborgen
  Text tippen -> "Finden" (2) und "An Lucy" (1) erscheinen
  "Motor neu starten" traegt den Chip "fragt nach"; Auswahl zeigt die Rueckfrage
    mit vollem Folgentext — es wird NICHTS gestartet
  "Abbrechen" fuehrt zurueck in die Liste, keine Meldung, kein Aufruf

54 Tests gruen (10 neue) · ESLint 0 Fehler · Einstieg 118 762 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:56:26 +02:00
HitonabiandClaude Opus 5 a494d320d7 feat(agent): P6 — Werkzeug-Verlauf, und der Blocker war nur eine halbe Wand
Der Blueprint sah eine "Live Agent Matrix" mit Denkstrom vor (§4.4). In P4 hatte ich
sie als blockiert gemeldet: Lucys Denkschritte entstehen im Hermes-Prozess, und
AGENTS.md verbietet es, dessen Quellcode zu patchen.

Beim Nachsehen zeigte sich, dass die HAELFTE davon offen daliegt.
`~/.hermes/logs/agent.log` protokolliert JEDEN Werkzeug-Ruf:

  INFO [cron_195e…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
  WARNING [cron_195e…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}

Eine Log-Datei zu LESEN ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug,
wie lange, mit welchem Ergebnis, in welchem Lauf — ist fuer "was tut sie gerade und wo
haengt es" oft nuetzlicher als der Fliesstext ihrer Gedanken.

## Es hat sich beim ersten Blick bezahlt gemacht

Der Parser lief gegen den echten Box-Log und meldete sofort:

  web_extract      2 Rufe   2 Fehler   <-- IMMER ROT
      DuckDuckGo (ddgs) is a search-only backend and cannot extract URL content.
  web_search      20 Rufe   0 Fehler   Schnitt 1,77 s

Nachgesehen: `web_extract` scheitert seit MINDESTENS dem 25.08. jeden Morgen um 07:00
mit derselben Meldung — im Daily-News-Cron, vier Tage lang, ohne dass es irgendwo
aufgefallen waere. Genau dafuer steht die Bilanz OBEN und die Zeitleiste darunter:
Ein Dauerfehler verschwindet in einer Ereignisliste, in der Zeile
"web_extract · 2 Rufe · 2 Fehler" nicht.

## Was die Ansicht NICHT verspricht

Denkstrom und Werkzeug-Argumente stehen nicht im Log und tauchen darum auch nicht auf.
Das steht so in der Ansicht selbst, nicht nur im Code — eine Oberflaeche, die mehr
andeutet als sie hat, ist schlimmer als eine, die ihre Grenze nennt.

## Umsetzung

  services/agent_aktivitaet.py   liest nur die letzten 512 kB (die Datei waechst auf
                                 MB und wird rotiert), vier gemessene Zeilenformen,
                                 Bilanz je Werkzeug + Gruppierung je Lauf
  GET /api/agent/aktivitaet      limit gedeckelt auf 300
  features/agent/Werkzeugverlauf.tsx   Bilanz -> Laeufe -> aufklappbare Zeitleiste

Fehlt das Log (Entwicklungsrechner ohne Hermes), verschwindet der Abschnitt still,
statt eine leere Karte zu zeigen — wie der Rest der Seite es haelt.

## Verifiziert

  Parser gegen den echten Box-Log: 26 Rufe, 4 Werkzeuge, 2 Laeufe erkannt,
    Rauschen (mem_trim, aiohttp) ignoriert
  Im Browser gegen dieselben Daten: "IMMER ROT"-Markierung sitzt, Grund im Klartext,
    Laufgruppen mit Zeitspanne, 26 Einzelrufe in der Zeitleiste
  7 neue Tests (44 gesamt) — sie pruefen gezielt die BILANZ-Karte, nicht irgendein
    Vorkommen des Werkzeugnamens; der steht auch in der Zeitleiste

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:51:14 +02:00
HitonabiandClaude Opus 5 7957cda438 fix(sentry): Hermes-Probe schickt ihren Schluessel mit — 720 Fehler/Tag weniger
Beim Untersuchen von Hermes' Log gefunden: MC2s eigene Erreichbarkeits-Probe rief
`GET /v1/models` ohne API-Schluessel und produzierte damit alle zwei Minuten ein
`API server rejected invalid API key` in ~/.hermes/logs/agent.log.

  Gemessen: 30 Vorfaelle/Stunde, seit dem 27.08. 15:05 durchgehend — 558 Eintraege
  im aktuellen Log, ~720/Tag.

Das URTEIL war nie falsch: `_reach` prueft `status_code < 500`, und ein 401 beweist
ja, dass jemand zuhoert. Falsch war der Laerm — und dass ein echter Auth-Fehler darin
untergegangen waere.

MC2 hat den Schluessel laengst in der Config (HERMES_API_KEY, aus ~/.hermes/.env).
Die Probe schickt ihn jetzt als Bearer mit. Die Nachsichtigkeit von `< 500` bleibt
absichtlich: Die Frage ist "laeuft der Dienst", nicht "darf ich rein".

Auf der Box gegengeprueft:
  ohne Schluessel: 401
  mit Schluessel:  200

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:44:45 +02:00
HitonabiandClaude Opus 5 dcee0096fe refactor(ideen): v3-Umbau P5 — der Monolith bekommt einen eigenen Bereich
Ampel / ampel (push) Successful in 34s
Sechste Etappe, erster Bereich. views/IdeenView.tsx hatte 1031 Zeilen, 26 useState
und sechs Komponenten in derselben Datei (Befund B-06) — der Haupteingang des
Systems, und niemand konnte ihn ohne Vollbild-Scrollen ueberblicken.

## Der Schnitt

Er folgt den Grenzen, die im Monolithen ohnehin schon lagen: Jede Top-Level-Funktion
wird eine Datei. Der INHALT ist unveraendert — nur die Datei ist neu, damit der Diff
pruefbar bleibt.

  features/ideen/zustaende.ts       55   Zustands-/Art-Tabellen + Sortier-Rang
  features/ideen/quote.ts           22   Nutzungs-Quote der Eingabe
  features/ideen/Wahl.tsx           27   eine Auswahlkachel
  features/ideen/ErgebnisPanel.tsx  55
  features/ideen/KonzeptPanel.tsx   73
  features/ideen/ProjektGruppe.tsx  86
  features/ideen/WeiterLeiste.tsx  113
  features/ideen/IdeenAnsicht.tsx  676   Verfasser + Liste + Zeilen-Renderer

views/IdeenView.tsx bleibt als vierzeiliger Wegweiser eine Fassung lang stehen,
damit ein uebersehener Import nicht stillschweigend bricht; der Router zeigt bereits
direkt auf features/ideen/.

## Was BEWUSST noch nicht geschnitten ist

Der Zeilen-Renderer `zeile()` (188 Zeilen) bleibt in IdeenAnsicht.tsx. Er greift auf
16 Werte des umgebenden Zustands zu (busy, openLog, openKonzept, antworten, logData,
steuer, archive, answer, …). Ihn "herauszuloesen" hiesse, eine Props-Liste mit 16
Eintraegen zu schreiben — dieselbe Verflechtung in anderer Schreibweise. Das ist eine
Frage der Zustands-Zugehoerigkeit, keine Verschiebe-Uebung, und verdient eine eigene
Runde statt eine Beifaenger-Aenderung. Steht so auch im Kopfkommentar der Datei.

## Nebenbefund beim Pruefen: die eigene Anzeige log

Gegen die noch nicht aktualisierte Box zeigte die Statusleiste "Getrennt" — obwohl
die Poller sauber lieferten und alle Zahlen stimmten. Grund: Dort gibt es /api/stream
noch nicht, der SPA-Catch-all beantwortet den Pfad mit index.html, und EventSource
scheitert daran.

Die Anzeige verwechselte damit zwei Dinge. Jetzt trennt sie sie:
  Getrennt  = die Zentrale antwortet nicht, die Zahlen sind wirklich alt
  Nachlauf  = kein Strom, aber die Zentrale antwortet — die Zahlen stimmen,
              sie kommen im Takt statt sofort
  Live      = Strom steht
Eine Anzeige, die vor korrekten Daten warnt, ist genauso falsch wie eine, die
veraltete verschweigt.

## Verifiziert

  Gegen das lokale Backend: /ideen rendert, keine Fehlerflaeche
  Gegen die ECHTE Box (altes Backend, kein /api/stream): Leiste sagt "Nachlauf",
    zeigt die echten Werte (40,7/122,7 GB Unified Memory, GPU 0 %, 37 °C,
    532 748 115 Token, 1023 € gespart) — und blendet die Betriebszeit AUS, weil das
    alte Backend sie nicht liefert, statt eine Zahl zu erfinden.
    Das neue Frontend degradiert also sauber gegen den alten Stand.

37/37 Tests gruen · ESLint 0 Fehler · Einstieg 118 606 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:22:45 +02:00
HitonabiandClaude Opus 5 b74e98ffbb feat(stream): v3-Umbau P4 — Messwerte kommen gepusht statt gepollt
Fuenfte Etappe. Der Ereignisstrom war bisher ein reiner Anstoss-Bus, und die
Messwerte holte sich das Frontend im 3-Sekunden-Takt selbst — zwei Dauer-Anfragen,
unabhaengig davon, ob sich etwas geaendert hatte (Befund B-12).

## Backend: /api/stream

routers/events.py bedient jetzt zwei Endpunkte aus EINEM Sammler:

  /api/stream   `invalidate` (bei Aenderung) + `metrik` (jede Sekunde)
  /api/events   nur `invalidate` — bleibt EINE Fassung lang stehen, weil ein
                Browser-Tab nach einem Deploy noch das vorige Buendel halten kann
                und dieses nur /api/events kennt

Dazu services/system.py → metrik_punkt(): ein bewusst LEICHTER Messpunkt.
`system_status()` waere hier falsch — es ruft `psutil.cpu_percent(interval=0.1)`
und blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt 10 % der Zeit),
plus den Versions-Check, den niemand sekuendlich braucht. Gemessen: 0,2 ms je
Punkt mit `interval=None`.

Token stehen als GESAMTZAEHLER im Ereignis, nicht als Rate. So bleibt der Server
zustandslos und ein verpasster Punkt verfaelscht nichts — der Klient rechnet die
Rate aus zwei Punkten.

Neu im Fingerabdruck: Jobs (Zustand + Fortschritt). Damit ist auch der 3-s-Poller
der System-Schublade nur noch Sicherheitsnetz.

## Was bewusst FEHLT

Kein `agent`-Thema fuer Lucys Denkschritte. MC2 kann Hermes' interne Schritte nicht
sehen, ohne dessen Quellcode zu patchen — per AGENTS.md verboten. Eine leere Leitung
zu bauen waere eine Zusage, die keiner einloest. Das betrifft die Agent-Matrix aus
§4.4 der Spezifikation; sie braucht zuerst eine Datenquelle.

## Frontend

lib/events.ts hoert auf /api/stream und schreibt `metrik` direkt in den
Metrik-Speicher. Der bleibt bewusst ein useSyncExternalStore AUSSERHALB von React
(nicht der Zustand-Store aus P3): Bei einem Wert pro Sekunde wuerde ein Store-Update
jede abonnierende Komponente neu rendern.

## Gedrosselt statt abgeschaltet — eine Korrektur am eigenen Entwurf

Der erste Wurf schaltete beide Poller bei stehendem Strom komplett ab (`false`).
Das waere falsch gewesen: Beide Antworten tragen mehr als Messwerte —
/api/system/status die Versions-Hashes fuer den Schienen-Fuss, /api/system/token-stats
die Gesamtsumme und die Cloud-Ersparnis, fuer die das Backend die Tarife aufloest
(die Preis-Logik ist dort die einzige Wahrheit; sie im Klienten nachzubauen waere
eine zweite). Beides waere eingefroren.

Jetzt 3 s → 60 s bei stehendem Strom: ein Zwanzigstel der Last, und die Randdaten
bleiben frisch. Die MESSWERTE selbst kommen aus dem Strom — useSystemHistory legt
den letzten Messpunkt ueber die Query-Antwort, damit Legende, Temperatur und
Betriebszeit nicht zwischen zwei Minuten-Abfragen stehen bleiben.

## Verifiziert

  Server: 12 `metrik`-Ereignisse in den ersten 4 kB des Stroms (1/s)
  Server-Log ueber die ganze Prozesslaufzeit: /api/system/status 3 Anfragen,
    /api/system/token-stats 3 Anfragen — vorher waere das eine je 3 Sekunden gewesen
  Browser: Statusleiste zaehlt live weiter (Speicher 14,6 → 12,9 GB, CPU 3 → 2 %,
    Betriebszeit 1:22 → 1:23) bei NULL fetch-Aufrufen im 49-s-Fenster
  Cockpit: beide Diagramme rendern (2 Container, 5 Flaechen)
  /api/events antwortet weiterhin (Alt-Tab im Log)

Einschraenkung, ehrlich: Die Browser-Pane war waehrend der Messung verborgen, und
TanStack Query pausiert Intervalle in Hintergrund-Tabs. Die Null im Klienten ist
daher KEIN sauberer Beleg fuer die Drosselung — der Server-Log ist es. Nebenbefund:
Der Strom laeuft auch im Hintergrund-Tab weiter, die Poller nicht.

37/37 Tests gruen (4 neue fuer pushMetrik: Ratenbildung, Zaehler-Ruecksprung,
letzter Messpunkt; MAX_POINTS ist jetzt exportiert, damit der Deckel-Test nicht
wieder gegen eine veraltete Kopie prueft) · ESLint 0 Fehler · Einstieg 118 582 B
gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:16:29 +02:00
HitonabiandClaude Opus 5 5aa5a484a7 feat(frontend): v3-Umbau P3 — ein Store, eine Statusleiste, endlich aria-live
Vierte Etappe. Der Client-Zustand hatte bisher keinen Ort: zwei handgebaute
useSyncExternalStore-Speicher, drei CustomEvent-Kanaele und ein localStorage-Griff
mitten in einem useState — verteilt ueber fuenf Dateien.

## Ein Store (Befund B-08/B-09)

app/store.ts (Zustand, 1,2 kB gzip) haelt genau vier Dinge: Bedienvorlieben
(Schiene, Expertenmodus, Palette), die Lage des Ereignisstroms und die
Meldungs-Warteschlange. Der Kopfkommentar nennt die Regel, nach der etwas dort
landen darf — und ein Test prueft sie: gespeichert werden AUSSCHLIESSLICH
Schienen- und Expertenmodus-Zustand, kein Strom, keine Meldungen, kein Geheimnis.

Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts): Er nimmt ab P4
jede Sekunde einen Messpunkt entgegen, ein Store-Update pro Sekunde wuerde jede
abonnierende Komponente neu rendern.

Die drei CustomEvent-Kanaele sind ERSATZLOS weg — `grep -r dispatchEvent src`
liefert nichts mehr. Wer die Schublade oeffnen will, setzt `?system=`; wer springen
will, navigiert. Auch der letzte Rest von P2 (`mc-palette-open`) ist aufgeloest.

## Statusleiste (Spezifikation §4.1)

Der Zustand der Box stand bisher verstreut: die Ampel unten in der Sidebar, die
Auslastung nur im Cockpit, das aktive Modell nur im Modell-Manager. Wer in der
Konsole arbeitete, sah gar nichts.

Jetzt eine feste Leiste ueber allen Ansichten: Motor/Hirn-Ampel (klickbar zum
Agenten) · aktive Rolle + Durchsatz mit Sparkline · geteilter Speicher ALS BALKEN
(nicht als Prozent — 128 GB Unified Memory ist die Kernzahl dieser Box) · CPU/GPU
· Temperatur (ab 85 °C bernstein) · Betriebszeit · Token-Summe · Live-Anzeige.

Die Sparklines sind eigenes SVG, ~20 Zeilen. Fuer eine 56-Pixel-Linie waere eine
Diagramm-Bibliothek Verschwendung.

Dafuer neu im Backend: `uptime_s` in /api/system/status (psutil.boot_time).
Gehoert dorthin, weil die Box sich woechentlich selbst neu startet — dann ist
"laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
Liefert psutil nichts, steht dort None und die Leiste blendet das Feld aus,
statt eine Zahl zu erfinden.

## aria-live (Befund B-11) — und diesmal mit Absender

Im ganzen Frontend gab es KEIN einziges aria-live. app/shell/Meldungen.tsx ist
jetzt die eine Stelle fuer beilaeufige Rueckmeldungen; Fehler bekommen
`assertive` und bleiben stehen, alles andere `polite` und raeumt sich weg.

Wichtig: Die Region ist keine Attrappe. Gespeist wird sie von den Uebergaengen des
Ereignisstroms und vom Konsolen-Neustart (mit dem echten Grund aus ApiError.detail).
Der erste Verbindungsaufbau wird bewusst NICHT gemeldet — sonst begruesst jede
Seite den Nutzer mit "Verbindung wieder da".

## Ehrliche Anzeige statt stillem Altern (Befund B-15/B-18)

lib/events.ts fuehrt die Stromlage jetzt im Store: live · nachlauf · getrennt.
Beim Wechsel wird EINMAL invalidiert — vorher las relax() den Zustand erst beim
naechsten Refetch, nach einem Riss blieb die UI bis zu 150 s im langsamen Modus.

## Bahnbreiten-Deckel (Befund B-10)

Die Arbeitsflaeche endet bei 1600 px. Ohne Deckel zog sich das Cockpit auf einem
3440-px-Ultrawide auf ueber 3 000 px, waehrend die Wurzel-Schriftgroesse mitwuchs
und die Zeilen GROESSER statt lesbarer machte.

## Nebenbei

Der Buendel-Budget-Check aus P0 war fragil: `ls dist/assets/index-*.js | head -1`
kann den falschen treffen, weil Rollup auch kleine geteilte Module "index-*.js"
nennt (gemessen: ein 67-Byte-Chunk neben dem 374-kB-Einstieg). Er waere dann still
immer gruen gewesen. Beide Ampel-Dateien lesen den Einstieg jetzt aus index.html.

## Verifiziert im Browser

  Statusleiste: alle Felder mit Live-Daten, "Laeuft seit 1 Std 10 min"
  Backend abgewuergt  -> Leiste springt auf "Nachlauf" UND die aria-live-Region
                         meldet "Verbindung zur Zentrale verloren" (polite)
  Backend zurueck     -> "Live", ohne Begruessungs-Meldung
  Cockpit-Kachel      -> /cockpit?system=logs, Schublade offen, Reiter "System-Logs"
  Arbeitsflaeche      -> max-width 1600 px

33/33 Tests gruen (7 neue fuer den Store) · ESLint 0 Fehler · tsc sauber ·
Einstieg 118 153 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:07:09 +02:00
HitonabiandClaude Opus 5 464b0d20b8 feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL
Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.

## Was jetzt in der Adresse steht (Befund B-05)

  /modelle?reiter=routing     der Reiter war lokaler State, nicht teilbar
  /ideen?offen=<id>           die aufgeklappte Karte; wer waehrend eines
                              laufenden Workers neu lud, verlor seinen Platz
  /wissen?datei=<pfad>        jede Vault-Notiz verlinkbar
  <ueberall>?system=wartung|logs   die System-Schublade inkl. Reiter

Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.

## Alte Lesezeichen brechen nicht

lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.

Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.

## Fehlergrenze pro Route (Befund B-13)

Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.

## Budget: der Router kostete 29 kB, sie sind wieder drin

TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.

  Start-Chunk gzip  111 411 -> 114 978 B   (Budget 125 000, weiter scharf)
  dist gesamt                  1 565 521 B (Budget 3 145 728)

## Nebenbei

  · Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
    (`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
    Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
  · Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
    Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
  · nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
    damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.

## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)

  /                        -> /cockpit, Sidebar markiert
  /#models                 -> /modelle, Sidebar markiert
  /modelle?reiter=routing  -> Reiter "Routing & Warm-Set" aktiv
  /cockpit?system=logs     -> Schublade offen, Reiter "System-Logs" aktiv;
                              Schliessen entfernt den Parameter wieder
  /gibtsnicht              -> 404-Seite, Schiene + Kopfzeile intakt
  Zurueck-Taste            -> voriger Pfad

26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:58 +02:00
HitonabiandClaude Opus 5 ae69c5ce31 fix(security): v3-Umbau P1 — Geheimnisse raus aus dem Browser, Frontend endlich getestet
Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.

## B-01 — Box-Sudo-Passwort: ersatzlos entfernt

Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.

AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:

  frontend/src/lib/api.ts             kein localStorage-Zugriff mehr
  frontend/.../SettingsTab.tsx        Eingabefeld weg, dafuer die Erklaerung warum
  backend/routers/maintenance.py      SudoReq entfaellt, 8 Endpunkte entschlackt
  backend/services/maintenance.py     _run() nutzt immer `sudo -n`
  backend/services/jobengine.py       keine stdin-Pipe mehr (DEVNULL)

`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.

## HuggingFace-Token: liegt jetzt auf der Box

Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.

## B-14 — Fehlermeldungen sagen jetzt, was los ist

api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).

## B-07 — Tests und Linter, ehrlich eingeordnet

Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.

  Vitest + Testing Library, 17 Tests in 3 Dateien
  ESLint (flat config) + Prettier
  Beides jetzt Teil der Ampel

Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).

ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.

## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy

Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).

## Nachgemessen

  Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
  tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler

Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:44:48 +02:00
HitonabiandClaude Opus 5 8aa22e6591 perf(frontend): v3-Umbau P0 — Ballast raus, Startbuendel halbiert
Erste Etappe des v3-Umbaus. Bewusst ohne jede Architektur-Aenderung: nur
Entruempeln, damit der Rest des Umbaus auf einem messbar leichten Stand aufsetzt.

Gemessen (vorher -> nachher):
  dist gesamt      27 375 720 B -> 1 477 852 B   (-94,6 %)
  Dateien in dist         135   ->        35
  Start-Chunk gzip    220 278 B ->   110 571 B   (-49,8 %)
  CSS gzip             31 577 B ->    15 121 B   (-52,1 %)
  Schrift-Dateien         112   ->        11     (WOFF 1: 0)

Vier Eingriffe:

1) avatar.vrm (24,5 MB) entfernt. Lag in public/ und dist/, wurde von KEINER
   Zeile des Repos referenziert — der Renderer Avatar3D.tsx war schon vorher
   verschwunden. War 89,5 % der Nutzlast, die auf die Box ging. Damit fallen
   auch die .gitignore-Sonderregel und der Direkt-Deploy-Schritt weg.
   DISASTER_RECOVERY.md §3·D ehrlich auf "ausgebaut" gesetzt (7 Stellen).

2) Schriften: die 11 @fontsource-Sammelimporte zogen ALLE Subsets (latin-ext,
   griechisch, kyrillisch) und je eine WOFF-1-Fassung mit — 112 Dateien, 1,58 MB,
   davon 902 kB WOFF 1, das kein Browser dieser App je abruft. Jetzt stehen die
   @font-face-Regeln direkt in index.css: nur latin, nur WOFF 2, nur die Schnitte,
   die per grep ueber die font-*-Klassen wirklich belegt sind.
   Nebenbei behoben: JetBrains Mono 600/700 fehlten komplett — die 19 Stellen mit
   `font-mono font-bold/semibold` wurden vom Browser synthetisch fettgerechnet.
   Jetzt echte Schnitte; bei Monospace ist die Laufweite gleich, kein Layout-Versatz.

3) Recharts aus dem Startbuendel. Das Cockpit ist die Startseite und laedt daher
   NICHT lazy; ueber SystemStatusCard/TokenPerformanceCard zog es Recharts samt
   d3 in index-*.js. LiveAreaChart ist jetzt eine Lazy-Huelle (Suspense mit
   hoehengleichem Platzhalter, damit nichts springt), die Recharts-Umsetzung liegt
   in LiveAreaChartImpl.tsx und kommt als eigener Chunk nach (105 kB gzip).

4) index.css: height 100dvh mit 100% als Rueckfall. Auf Mobilbrowsern mit
   einfahrender Adressleiste ist 100 % nicht die sichtbare Hoehe.

Dazu ein Buendel-Budget in beiden Ampel-Dateien (.gitea/ = MC2-Fassung,
deploy/ = universelle Vorlage): Start-Chunk und dist-Gesamtgroesse werden am
FRISCHEN Build im Runner gemessen. Bewusst kein Vergleich mit dem committeten
dist — der waere ueber Node-Versionen hinweg flatterhaft und wuerde dauerhaft
rot leuchten, was das Signal zerstoert.

Verifiziert gegen die Box (Frontend-Dev mit MC_API_TARGET=192.168.178.151:9001):
Cockpit rendert mit Live-Daten, keine Konsolenfehler, alle 11 Schriftschnitte
registriert, LiveAreaChartImpl + recharts laden nachweislich als Nachlade-Chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:27:49 +02:00
HitonabiandClaude Opus 5 3d1881f4bc docs(stack): Rollen-Tabellen auf den gemessenen Stand - Kritiker war Fiktion
Ampel / ampel (push) Successful in 21s
Recherche zur offenen Frage "fehlt der kritiker?": Er fehlt nicht, er wurde
abgeschafft. Die Modell-Konsolidierung (9a35be9, 19.08.) warf Devstral aus
llama-swap, und fremdblick.sh - der EINZIGE Aufrufer der Rolle - fiel mit dem
MC2-Kahlschlag (1e68f62). Live gegengeprueft: kein Skill, keine Hermes-Config
und kein Profil nennt 'kritiker'. Gewollt so: ein Coder, ein Agent-Hirn.

Beim Abgleich zeigte sich, dass die Tabellen weit mehr erfanden als den
Kritiker. Gegen /etc/llama-swap/config.yaml geprueft sind es SIEBEN Rollen:
hermes/fast, coder, debugger, vision, heavy, reranker, embed. Korrigiert:

- `coder` stand als Qwen3-Coder-Next (80B MoE, 51,5 t/s) drin - real ist es
  Qwen3.8-27B mit 131k ctx und ~12,7 t/s. Der Eintrag `dense-planer` war
  dasselbe Modell ein zweites Mal: es ist kein Planer NEBEN dem Coder, es IST
  der Coder.
- `kritiker`, `scout` und `doctor` existieren nicht mehr - Zeilen raus, mit
  Notiz warum, damit niemand wieder danach sucht.
- Hermes v0.20.4 -> v0.20.6; die Behauptung eines Bot-Rosters ("Coder mit
  Qwen3-Coder-Next, Debugger mit Muse-Glimmer") ist frei erfunden: die
  bots-Sektion in ~/.hermes/config.yaml ist LEER, die Profile fahren hermes,
  fast und vision.
- README verlangte fuer die Gruppe `brains` ausdruecklich `persistent: false`
  - live steht `true`. Auf die gemessene Regel korrigiert: entscheidend ist
  nicht der Schalter, sondern dass alles gleichzeitig Warme in DIESELBE Gruppe
  gehoert (zwei Gruppen verdraengen sich, persistent schuetzt nicht davor).

Weiter geprueft und richtiggestellt:

- config.py behauptete, die Hermes-UI binde NUR auf Loopback. Tut sie nicht:
  ein systemd-Drop-in setzt --host 0.0.0.0 und dafuer ein Session-Token. Dass
  sie nicht im LAN haengt, liegt allein an ufw - von einem zweiten Rechner aus
  gegengeprueft (9119/8642/9010/7682 dicht, 9001/8080 offen wie gewollt). Der
  Kommentar sagt das jetzt, damit sich niemand auf die Loopback-Annahme verlaesst.
- AGENTS.md: die feingranularen sudoers.d-Regeln schraenken nichts ein, weil
  /etc/sudoers pauschal NOPASSWD: ALL gewaehrt. Steht jetzt dort, statt Sicherheit
  vorzutaeuschen. (Die doppelte Zeile wurde auf der Box entfernt, visudo -c ok,
  Sicherung /root/sudoers.bak-20260827-170315.)

Auf der Box ausserdem: Qwen3-Coder-Next-GGUF geloescht (46 GB frei, 240G -> 194G).
Der Ordner war in der Live-Config mit 0 Treffern nicht mehr eingebunden; die drei
verbliebenen Code-Referenzen sind ein Katalog-Eintrag zum Wiederinstallieren und
zwei Kommentare zur Groessen-Heuristik.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:04:33 +02:00
HitonabiandClaude Opus 5 2935752613 feat(autoupdate): Box startet woechentlich neu, wenn das OS es verlangt
Ampel / ampel (push) Successful in 21s
Kernel- und libc-Updates werden erst nach einem Neustart wirksam. Das faellt
niemandem auf: am 27.08.2026 lief die Box seit 7 Wochen auf Kernel 7.0.0-27,
waehrend -28, -29 und -30 ungenutzt installiert dalagen.

Der Neustart haengt am BESTEHENDEN Sonntagslauf (Hermes-Cron 30 4 * * 0 ->
sonntags-update.sh -> autoupdate.sh), nicht an einem neuen Timer - ein Ort fuer
alle Automatik, genau die Lehre vom 20.08. Ausgeloest wird er nur, wenn Ubuntu
ihn selbst anfordert (/var/run/reboot-required).

Drei Sicherungen, alle im Trockenlauf geprueft:
  1. linger MUSS aktiv sein. Ohne linger startet systemd die User-Dienste nach
     einem Neustart nicht - die Box kaeme ohne Steuerpult, Gateway und Waechter
     hoch und waere aus der Ferne nicht mehr erreichbar. Das ist die wichtigste
     Zeile der Funktion.
  2. Kein laufender Job wird abgewuergt (Zaehlung ueber /api/jobs).
  3. mission-control-2, mc2-gateway und mc2-steward muessen enabled sein.
Greift eine Sicherung, meldet die Funktion den Grund per Telegram und Stimme
und startet NICHT neu.

Der Aufruf steht bewusst NACH der Abschlussmeldung - davor haette der Neustart
den Wochenbericht verschluckt. Abschaltbar mit MC_AUTOUPDATE_REBOOT=0.

Im Trockenlauf gefunden und mitbehoben: die Funktion las $USER, das in systemd-
und Cron-Umgebungen nicht gesetzt ist. Mit dem set -u des Skripts haette das den
GANZEN Update-Lauf abgebrochen, nicht nur den Neustart. Jetzt id -un.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:07:49 +02:00
HitonabiandClaude Opus 5 a969898a16 fix(deploy): Skript gegen sich selbst absichern - alles in main()
Ampel / ampel (push) Successful in 21s
Beim Deploy am 27.08.2026 live erlebt: der `git pull` in Schritt 1 brachte eine
neue Fassung von deploy.sh mit, ausgefuehrt wurde danach trotzdem die alte
Logik (die Ausgabe zeigte den alten Text des Config-Sync-Schritts).

Ursache: bash liest ein Skript haeppchenweise WAEHREND es laeuft. Wird die Datei
mitten im Lauf ersetzt, liest bash am selben Byte-Offset im neuen Text weiter -
im harmlosen Fall greift die alte Logik, im schlimmen Fall fuehrt es eine
zerschnittene Zeile aus.

Fix: der komplette Ablauf liegt jetzt in main(), aufgerufen als letzte Zeile.
Eine Funktion wird vollstaendig geparst, bevor sie startet. Aenderungen an
deploy.sh greifen damit sauber ab dem naechsten Aufruf statt mittendrin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:51:48 +02:00
HitonabiandClaude Opus 5 57492d6759 docs(postcheck): WARN zum fehlenden Patch-Traeger praezisiert
Ampel / ampel (push) Successful in 21s
apply.py wurde mit dem MC2-Kahlschlag (1e68f62) entfernt, nicht versehentlich
verloren. Der Postcheck meldete nur "nicht gefunden" - das las sich wie ein
Defekt. Jetzt steht der Grund dabei; WARN statt FAIL bleibt, damit es sichtbar
ist, falls wieder Runtime-Patches gebraucht werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:50:42 +02:00
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
HitonabiandClaude Opus 5 e04ace242c docs(referenz): Lauf A ausgewertet - Arbeitsanweisung gegen die Analyse-Schleife
Ampel / ampel (push) Successful in 22s
Lauf A ueber Nacht: 6 Sitzungen, ~336 Nachrichten, ~360.000 Tokens,
19 Dateien gelesen - und NULL Dateien geschrieben. Das Werkzeug fuer
Dateioperationen war die ganze Zeit verfuegbar, wurde nie benutzt.

Der Agent lief fuenfmal in dasselbe Muster: "Ich habe jetzt das
vollstaendige Bild, jetzt lese ich nur noch die restlichen Dateien,
bevor ich anfange." Dann war max_turns erreicht. Sein eigenes Fazit:
"Iterationslimit erreicht, bevor ich die erste Zeile geaendert habe."

Wichtigste Erkenntnis: das ist KEIN Tempo-Problem. Mit einem schnelleren
Modell waere derselbe Lauf genauso gescheitert, nur frueher. Darum
kommen A2 (Arbeitsanweisung) und D (reasoning low) jetzt VOR den
Modellwechseln B und C.

Neu: Arbeitsanweisung ganz oben - hoechstens zwei Dateien lesen, bevor
die erste geaendert wird. Dazu agent.max_turns 40 -> 80 im coder-Profil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:14:25 +02:00
HitonabiandClaude Opus 5 d880a76b6a fix(referenz): Pruefbefehl aus dem Suchpfad genommen, Lauf D ergaenzt
Ampel / ampel (push) Successful in 22s
Zwei Fehler aus dem ersten Anlauf behoben:

1. referenz-check.sh lag unter deploy/ und enthielt selbst 7x das Wort
   "mem0" (es sucht ja danach) - damit zaehlte es sich in Kriterium 3 mit
   und das Kriterium war unerfuellbar. Jetzt unter docs/aufgaben/, wo
   Kriterium 3 nicht sucht. Strukturell geloest statt per grep-Ausnahme.

2. Der Lauf muss in einer FRISCHEN Sitzung starten - der Desktop hatte
   eine Sitzung vom Vortag fortgesetzt und deren Kontext mitgeschleppt.

Neu: Lauf D (reasoning_effort low). Der Fehlversuch zeigte, dass >95%
der Modell-Ausgabe unsichtbares Nachdenken war - 19.899 Tokens fuer
2.600 Zeichen sichtbaren Text. Das ist der billigste Hebel und wird
darum vor Modellwechseln geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:57:07 +02:00
HitonabiandClaude Opus 5 ff319ff291 feat(deploy): referenz-check.sh - objektiver Fertig-Test fuer die Messlatte
Ampel / ampel (push) Successful in 21s
Damit 'fertig' keine Auslegungssache ist: derselbe Befehl fuer den Agenten
zur Selbstpruefung und fuer die Auswertung. Prueft alle fuenf Kriterien
und gibt ein klares Urteil plus Exit-Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:07:45 +02:00
Hitonabi 6e3440379b docs: Referenzaufgabe auf gemessene Pfade korrigiert (ruff-venv, app statt main, Worktree)
Ampel / ampel (push) Successful in 22s
2026-08-20 21:06:40 +02:00
HitonabiandClaude Opus 5 5f35f0d580 docs: Referenzaufgabe fuer die Stack-Messlatte (Stufe 2)
Ampel / ampel (push) Successful in 21s
Mem0-Ausbau als wiederholbare Messaufgabe: mehrteilig genug um
aussagekraeftig zu sein, mit fuenf objektiv pruefbaren Fertig-Kriterien
und einer Ruecksetz-Anleitung fuer die Wiederholungslaeufe.

Gemessen wird nicht t/s, sondern: wird die Aufgabe fertig, wie lange
dauert es, wie viele Zuege braucht es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:04:24 +02:00
HitonabiandClaude Opus 5 43fa747bc3 feat(deploy): projekte-sync.sh - haelt ~/projekte mit Gitea deckungsgleich
Ampel / ampel (push) Successful in 22s
Der Hermes-Desktop laeuft gegen das Box-Backend, sein Ordner-Waehler zeigt
also das Dateisystem der Box. Damit dort alle Projekte auftauchen und aktuell
sind, holt dieses Skript sie regelmaessig.

Bewusst konservativ, weil es unbeaufsichtigt laeuft:
- nur --ff-only, kein merge/rebase/reset/force
- Repos mit lokalen Aenderungen werden uebersprungen statt ueberfahren
- mission-control-v2 wird NIE gezogen (Live-Deployment, pusht selbst),
  bekommt nur eine Verknuepfung fuer den Waehler
- bestehende Checkouts in ~ bleiben liegen, ~/projekte verknuepft sie
- interne Gitea-IP statt DDNS (die ist nachts durch Zwangstrennung tot)

Erster Lauf: 6 Repos neu geklont, lucy aktualisiert, rippy-windows korrekt
wegen lokaler Aenderungen uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:50:05 +02:00
HitonabiandClaude Opus 5 83c6ecc613 fix(skills): worker.sh und restliche Doku auf Rollen-Aliase umgestellt
Ampel / ampel (push) Successful in 21s
Zweite Fundwelle derselben Ursache: deploy/worker.sh setzte
WORKER_MODEL-Default auf Qwen3-Coder-Next - damit waere JEDE delegierte
Bau-/Refactor-Aufgabe des Orchestrators mit HTTP 404 gescheitert.

Ausserdem: konzept-fliessband nannte GLM-4.7-Flash als Skeptiker,
router_logic.py nannte Qwen3-Coder-Next hinter der coder-Rolle.
Modellnamen raus, Rollen rein - Namen veralten, Rollen nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:09:29 +02:00
HitonabiandClaude Opus 5 b316ad40ba fix(skills): Rollen-Aliase statt Modellnamen + Savepoint auf gemessenen Stand
Ampel / ampel (push) Successful in 21s
Der Kritiker-Gate war stumm kaputt: orchestrator/wartung-Skill und
fremdblick.sh riefen Qwen3-Coder-Next, GLM-4.6V-Flash und GLM-4.7-Flash
auf - alle drei von der Modell-Konsolidierung (9a35be9) entfernt. Im
llama-swap-Log schlug das als HTTP 404 auf.

Ursache war das Muster, Modelle beim Eigennamen zu nennen. Jetzt Aliase
(coder/debugger/fast/heavy), die llama-swap aufloest - damit ueberlebt
jede Faehigkeit den naechsten Modellwechsel.

SAVEPOINT.md nannte fuenf Modelle, die es nicht mehr gibt. Ersetzt durch
die heute gemessenen Werte inkl. Bandbreiten-Erklaerung fuers Coder-Tempo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:07:03 +02:00
HitonabiandClaude Opus 5 8c371b946b fix(lint): Ungenutzte MEM0_SERVICE_URL-Importe entfernt (Ampel war rot)
Ampel / ampel (push) Successful in 22s
Rueckstand aus dem Mem0-Rueckbau: 2286879 entfernte die Verwendung,
liess die Importe aber stehen. Ruff meldete F401, die Ampel stand
seit dem 19.08. auf Rot (Laeufe #73-#76).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:54:21 +02:00
Hitonabi 1a2051e988 Revert "perf: Warm-Set auf Lucy, Coder, Debugger, Embed und Reranker erweitert"
This reverts commit 5d93822a7e.
2026-08-20 15:52:44 +02:00
Hitonabi 5d93822a7e perf: Warm-Set auf Lucy, Coder, Debugger, Embed und Reranker erweitert 2026-08-19 19:27:54 +02:00
Hitonabi 22868795c9 fix(sentry): Entferne toten mem0 Check aus Telegram-Health-Wächter
Ampel / ampel (push) Failing after 21s
2026-08-19 18:21:28 +02:00
Hitonabi 9a35be91f9 fix(llamaswap): Konsolidiere Modelle & entferne redundante Alt-Aliase
Ampel / ampel (push) Failing after 21s
2026-08-19 17:57:51 +02:00
Hitonabi 241b1d54c6 fix(deploy): Veralteten release-dist restore aus deploy.sh entfernt
Ampel / ampel (push) Failing after 22s
2026-08-19 17:38:57 +02:00
Hitonabi eb4bcfc4b3 fix(mc2): Frontend & Backend umfassend bereinigt, Mem0-Reste entfernt & Hermes-Nativ verdrahtet
Ampel / ampel (push) Failing after 29s
2026-08-19 17:36:34 +02:00
Hitonabi d2a175b0ad fix(smoke): max_tokens in Bild-Weiche v2 fuer Reasoning-Modelle angepasst
Ampel / ampel (push) Successful in 22s
2026-08-19 17:31:03 +02:00
Hitonabi bf145a3f97 feat: Qwen3.8-27B als Haupt-Coder verdrahtet & Mem0 endgueltig abgeloest
Ampel / ampel (push) Successful in 22s
2026-08-19 17:23:54 +02:00
Hitonabi 5b0909089f refactor: Stack-Audit, Doku-Bereinigung und Governor-Cleanup (Stand 19.08.2026)
Ampel / ampel (push) Successful in 23s
2026-08-19 17:05:45 +02:00
Hitonabi 4b6c6ebfd5 feat(routing): Rollen-Aliase 'debugger' und 'doctor' für Muse-Glimmer-30B verdrahtet 2026-08-14 19:01:09 +02:00
Hitonabi 0a6b009cb6 feat(models): Muse-Glimmer-30B mit DFlash-Speculative-Drafting in Katalog und llama-swap integriert 2026-08-14 18:49:26 +02:00
Hitonabi adc73c817f fix(config): Qwen3.6-35B-A3B bleibt warmes Gehirn (90 t/s MoE), Qwen3.8-27B on-demand bereitgestellt 2026-08-14 18:16:20 +02:00
Hitonabi ff795ba026 feat(models): Upgrade auf Qwen 3.8 27B als neues Hermes-Gehirn und Fast-Modell 2026-08-14 18:07:31 +02:00
Hitonabi 46edbe50c1 chore: correct hardware specs to 128GB RAM in AGENTS.md and trend-radar
Ampel / ampel (push) Successful in 22s
2026-08-08 00:49:40 +02:00
Hitonabi cd7e1bc114 fix(trend-radar): enforce 96GB RAM hardware constraint
Ampel / ampel (push) Successful in 24s
2026-08-08 00:46:42 +02:00
Hitonabi 600d0caad6 fix: instruct trend-radar to use notify.sh and allow live-repo edits
Ampel / ampel (push) Successful in 23s
2026-08-08 00:38:13 +02:00
Hitonabi 733582900a fix: auto-sync skills to hermes skills directory during deployment
Ampel / ampel (push) Successful in 23s
2026-08-08 00:28:47 +02:00
Hitonabi 8b14dfdc14 feat: rewrite trend-radar skill for full autonomy and self-modification
Ampel / ampel (push) Successful in 22s
2026-08-08 00:18:06 +02:00
Hitonabi dced44548d fix: correctly pass 'chat' command to hermes CLI when starting skills
Ampel / ampel (push) Successful in 23s
2026-08-08 00:05:24 +02:00
Hitonabi 4c0af92b3c fix: restore Wissen tab but filter out traeume directory
Ampel / ampel (push) Successful in 23s
2026-08-08 00:00:54 +02:00
Hitonabi 5cc1e6bea1 fix: remove frontend/dist from main to fix CI build
Ampel / ampel (push) Successful in 25s
2026-08-07 23:42:13 +02:00
Hitonabi 102149a5d6 build: compile frontend and fix gitignore for dist
Ampel / ampel (push) Successful in 22s
2026-08-07 23:36:43 +02:00
Hitonabi 0c849f4dcd fix: use autonomous zero-intervention lane (-z) for skill runner 2026-08-07 23:33:41 +02:00
Hitonabi 94490f114d fix: correct hermes cli invocation for skills run
Ampel / ampel (push) Successful in 21s
2026-08-07 17:10:48 +02:00
Hitonabi c360bbe14e fix: ruff import sorting in skills.py
Ampel / ampel (push) Successful in 22s
2026-08-07 17:05:49 +02:00
Hitonabi 6cb29ee70e feat: show running state of skills in frontend using psutil
Ampel / ampel (push) Failing after 21s
2026-08-07 17:03:30 +02:00
Hitonabi a6e2f78c27 feat: add missing SKILL.md files for autonomous hermes crons
Ampel / ampel (push) Successful in 22s
2026-08-07 16:50:02 +02:00
Hitonabi a201c20bf2 fix: resolve correct path for skills directory relative to repo root
Ampel / ampel (push) Successful in 22s
2026-08-07 16:42:39 +02:00
Hitonabi 56676fe83c fix: remove outdated Wissen/Traeume functionality completely
Ampel / ampel (push) Successful in 23s
2026-08-07 16:32:34 +02:00
Hitonabi 77bead5ba4 fix: update release-dist tracking branch explicitly during deployment
Ampel / ampel (push) Successful in 24s
2026-08-07 16:28:38 +02:00
Hitonabi ee456de713 fix: remaining ruff formatting issues and robust gitea auth
Ampel / ampel (push) Successful in 23s
2026-08-07 16:25:27 +02:00
Hitonabi e16f0140f7 fix: ampel ci unbound variable GITHUB_SHA
Ampel / ampel (push) Failing after 22s
2026-08-07 16:19:35 +02:00
Hitonabi 637c21387a fix: ruff linter warnings
Ampel / ampel (push) Failing after 23s
2026-08-07 16:14:49 +02:00
Hitonabi da03de18cb feat: Skills Dashboard, CI Build Automation, Auto-Sync & CI-Ampel MCP
Ampel / ampel (push) Failing after 22s
2026-08-07 16:08:49 +02:00
Hitonabi 1b0184eed8 Llama-Swap Config Sync (Devstral hinzugefügt, GLM-4.7 entfernt, persistent=true)
Ampel / ampel (push) Failing after 23s
2026-08-07 15:58:46 +02:00
Hitonabi 4fb8387f79 IDE Line Cleanup & Agentic IDE Umbau
Ampel / ampel (push) Failing after 22s
2026-08-07 14:01:27 +02:00
Hitonabi cb884abdc8 fix: graph view node payload keys (content instead of label)
Ampel / ampel (push) Failing after 22s
2026-08-07 12:20:34 +02:00
Hitonabi 0a6b6fd18d chore: build frontend for wissens-vault graph view
Ampel / ampel (push) Failing after 24s
2026-08-07 12:16:05 +02:00
Hitonabi 8f359ef4c9 feat: obsidian-style graph view for wissens-vault 2026-08-07 12:16:01 +02:00
Hitonabi 327d5a495e feat: add llm_wiki cron, update hermes config docs for memory and browser
Ampel / ampel (push) Failing after 23s
2026-08-07 12:09:55 +02:00
Hitonabi 69925c6cde feat: autonome hermes skills für trend_radar und pruefstand
Ampel / ampel (push) Failing after 21s
2026-08-07 12:01:10 +02:00
Hitonabi a5410642fa feat: ide-lane hardening (ci ampel in auftragsbuch, hermes auto-bugfix, cleanup)
Ampel / ampel (push) Failing after 22s
2026-08-07 11:47:50 +02:00
Hitonabi c3851f8e25 refactor(docs): remove obsolete mem0 and governor references, update AGENTS.md for Hermes
Ampel / ampel (push) Failing after 21s
2026-08-07 11:33:41 +02:00
Hitonabi 29f8797fbf fix: resolve auftragsbuch list_proposals crash and enhance lucy prompt
Ampel / ampel (push) Failing after 21s
2026-08-07 11:23:35 +02:00
Hitonabi 9886d80b1c fix: add URLs to hacker news items and fix prompt for sysadmin report
Ampel / ampel (push) Failing after 21s
2026-08-07 11:21:10 +02:00
Hitonabi 5bc709221e feat: add news, discover models and auftragsbuch to sysadmin report
Ampel / ampel (push) Failing after 21s
2026-08-07 11:19:15 +02:00
Hitonabi 14f8a5f405 fix: change sysadmin report LLM endpoint to 9001 and truncate event texts
Ampel / ampel (push) Failing after 22s
2026-08-07 11:15:30 +02:00
Hitonabi 16d2a06d95 fix: make Sysadmin Telegram report much shorter and mobile-friendly
Ampel / ampel (push) Failing after 23s
2026-08-07 11:13:50 +02:00
Hitonabi 45f799c57d feat: implement daily Telegram sysadmin report
Ampel / ampel (push) Failing after 22s
2026-08-07 11:12:06 +02:00
Hitonabi e814122c73 feat: restore TokenPerformanceCard
Ampel / ampel (push) Successful in 21s
2026-08-07 11:11:00 +02:00
Hitonabi d45f2fc6cb fix: remove Memory (Gehirn) completely from frontend widgets
Ampel / ampel (push) Successful in 22s
2026-08-07 11:05:59 +02:00
Hitonabi c2319739e2 fix: remove unused EigenlebenView imports and rebuild frontend
Ampel / ampel (push) Successful in 22s
2026-08-07 11:02:49 +02:00
Hitonabi 1e68f62499 feat: complete MC2 Kahlschlag (remove governor, memory, eigenleben, obsolete deploy scripts and frontend widgets)
Ampel / ampel (push) Successful in 24s
2026-08-07 10:59:39 +02:00
Hitonabi 5504918a61 Merge branch 'main' of https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 2026-08-07 10:57:37 +02:00
Hitonabi 17a129038b chore: update claude ssh permissions 2026-08-07 10:57:37 +02:00
Auftragsbuch 93c17fe653 Revert "Auftragsbuch: 'feature/mem0-venv-update' angenommen (Ein-Klick-Gate)"
Ampel / ampel (push) Failing after 22s
This reverts commit ae622dfa88, reversing
changes made to 76d2d7c74b.
2026-08-02 12:13:43 +02:00
Auftragsbuch ae622dfa88 Auftragsbuch: 'feature/mem0-venv-update' angenommen (Ein-Klick-Gate)
Ampel / ampel (push) Failing after 22s
2026-08-02 12:13:40 +02:00
Hitonabi 6637332b3e mem0-venv: chromadb>=1.6.0, pip>=26.1.2 + update-skript
Ampel / ampel (push) Failing after 22s
- requirements.txt: chromadb==1.5.9 → chromadb>=1.6.0, pip>=26.1.2 hinzugefügt
- update-mem0-venv.sh: Idempotentes Update-Skript mit Backup und Smoke-Test
2026-08-02 06:45:26 +02:00
Hitonabi 76d2d7c74b deploy: neues Curator-Monitoring-Skript mit 7-Tage-Schwellenwert
Ampel / ampel (push) Failing after 22s
2026-08-02 04:04:21 +02:00
Hitonabi 7638f0244d fix(cron): correct implementation for cron spam prevention via wrapper script and deploy origin
Ampel / ampel (push) Failing after 21s
2026-07-27 17:12:39 +02:00
Hitonabi 4985977492 fix(cron): use --name flag for hermes cron create
Ampel / ampel (push) Failing after 22s
2026-07-27 17:10:43 +02:00
Hitonabi f65ff48599 fix(cron): pipe alle nächtlichen crons durch notify.sh zur verhinderung von telegram spam
Ampel / ampel (push) Failing after 22s
2026-07-27 17:09:26 +02:00
Hitonabi 87f446c5ae feat(pruefstand): Etappe E5 Autonomie für Auto-Adoption und täglichen Rhythmus
Ampel / ampel (push) Failing after 22s
2026-07-26 21:28:50 +02:00
Hitonabi f524ef4375 chore(prompt): Morgen-Digest detailreicher und gehaltvoller gemacht
Ampel / ampel (push) Successful in 22s
2026-07-26 21:16:02 +02:00
Hitonabi 605c9d7dd1 feat: Morgen-Digest für gebündelte nächtliche Telegram-Nachrichten
Ampel / ampel (push) Successful in 22s
2026-07-26 21:10:31 +02:00
Hitonabi 9791f44644 UI: GovernorCard aus dem Cockpit entfernt
Ampel / ampel (push) Successful in 22s
2026-07-26 21:03:47 +02:00
Hitonabi 1e3e0066af Governor: Hard ceiling respects tool chains (OpenCode fix)
Ampel / ampel (push) Successful in 23s
2026-07-26 20:50:45 +02:00
HitonabiandClaude Opus 5 2365f7aac6 Warm-Waechter lief 11 Tage im Leerlauf: Reranker war nie erreichbar
Ampel / ampel (push) Successful in 22s
Beim Nachsehen wegen einer haengenden Gitea-Karte im Steward-Log gefunden:
600 vergebliche warmup.sh-Laeufe in 24 h, alle 90 Sekunden. Aelteste Meldung
dieser Art: 15.07.2026 — also elf Tage, nicht erst seit dem Umbau.

Ursache: Der Reranker steckt in der ko-residenten Gruppe `brains` und gilt dem
Waechter damit als warm-pflichtig. Er antwortet aber NUR auf /v1/rerank — und
genau diesen Endpunkt kannte warmup.sh nicht. Das Modell konnte also nie warm
werden, der Waechter sah es ewig als "fehlend" und rief alle 90 s ein Skript auf,
das daran nichts aendern konnte. Mit Devstral in derselben Gruppe habe ich die
Falle vorgestern verdoppelt.

Zwei Korrekturen:
1. warmup.sh waermt jetzt auch Reranker (MC_WARMUP_RERANK, Default "reranker").
2. warmer.py bekommt MC_WARMSET als Override der Gruppen-Ableitung. Ko-Residenz
   ("duerfen gleichzeitig liegen") und Warm-Pflicht ("muessen immer liegen") sind
   zwei verschiedene Aussagen; die Gruppe kann nur die erste ausdruecken. Seit
   Coder (TTL 90 min) und Kritiker (TTL 30 min) in `brains` liegen, haette der
   Waechter sonst gegen ihre TTLs gearbeitet und nachts 62 GB wieder hochgeladen.

Steward bekommt MC_WARMSET = embed + reranker + hermes (Drop-in auf der Box).
Falle dabei, live erlebt: systemd trennt `Environment=` an Leerzeichen — ohne
Anfuehrungszeichen kam nur das erste Modell an und das Warm-Ziel war still zu
klein. Der erste Fix sah deshalb erfolgreich aus (Schleife weg), war aber nur
eine kaputte Messung. Jetzt gequotet und im Prozess-Environ geprueft.

Belegt: alle drei Ziel-Modelle warm, 0 vergebliche Ausloesungen des neuen
Prozesses, Coder geladen aber korrekt kein Warm-Ziel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:13:26 +02:00
HitonabiandClaude Opus 5 56b374a0aa Ideen: Auswahl war bei leerem Feld unsichtbar
Ampel / ampel (push) Successful in 22s
User: "dein Umbau ist immer noch nicht da. Oder denke ICH falsch?" — er dachte
nicht falsch. Der ganze Auswahl-Block hing an `text.trim().length >= 3`, also
sah man bei leerem Eingabefeld NICHTS davon. Kein Hinweis, dass es ueberhaupt
eine Wahl gibt, keine Moeglichkeit sie vorher einzustellen, und nach jedem
Neuladen war alles wieder weg.

Meine Begruendung war, die Auswahl sei eine Entscheidung ZU dem Text und habe
ohne Text nichts zu suchen. Das ist logisch sauber und praktisch falsch: ein
Mechanismus, den man nicht sieht, existiert fuer den Nutzer nicht.

Jetzt immer sichtbar. Ohne Einordnung steht davor "VOREINGESTELLT" — die Zeile
ist dann eine Einstellung, keine Aussage ueber einen Text, den es noch nicht
gibt. Sobald die Box gelesen hat, verschwindet das Wort und das Etikett
"gelesen" erscheint. Aendern kann man beides jederzeit, auch vor dem Tippen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:50:00 +02:00
HitonabiandClaude Opus 5 eef7fdb4df Ideen: Kopfzeile beschrieb eine Bedienung, die es nicht mehr gibt
Ampel / ampel (push) Successful in 23s
Zwei Fehler in der prominentesten Zeile der Seite, vom User gefunden:

1. "fuer Zed" statt "für Zed" — verschluckter Umlaut aus dem Anlegen der Seite.
2. "Je nach Modus baut sie es selbst, denkt es nur durch, oder legt dir ein
   startklares Repo an" — die Modi sind seit gestern weg, der Entscheidungsbaum
   ist seit heute zugeklappt. Der Satz erklaerte also eine Bedienung, die es
   nicht mehr gibt. Genau die Sorte Text-Leiche, die entsteht, wenn man die
   Mechanik umbaut und die Beschreibung stehen laesst.

Jetzt: "Schreib hin, was entstehen soll. Die Box liest mit und schlaegt vor, wie
es weitergeht — aendern kannst du das immer." Das ist, was tatsaechlich passiert.

Restliche Oberflaeche gegengeprueft: alle anderen "Modus"-Treffer sind
Variablennamen oder unverwandt (Dev-Modus, Experten-Modus), und ausser diesem
"fuer" gibt es keine weiteren ASCII-Ersatzschreibweisen in sichtbarem Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:43:15 +02:00
HitonabiandClaude Opus 5 cd60974ff9 Ideen: Entscheidungsbaum zugeklappt statt Assistent
Ampel / ampel (push) Successful in 24s
Der vorgeschlagene Wizard hatte den richtigen BAUM — Wer baut das?, und nur im
Zed-Ast: wie startest du? — aber den falschen Mechanismus fuer eine taegliche
Zehn-Sekunden-Handlung. Ein Assistent haette gefragt, was der Einordner in
~500 ms schon weiss.

Jetzt: derselbe Baum, zugeklappt.
  Normalfall  Textfeld + EINE Zeile ("Du baust selbst — ich arbeite erst ein
              Konzept aus.") + Enter. Nichts steht zwischen dir und der Idee.
  "anders"    klappt genau die Wizard-Reihenfolge auf: erst Wer baut das?, und
              NUR im Zed-Ast eingerueckt darunter Wie startest du?
              Bei "die Box" folgt keine Rueckfrage — dann geht es direkt los.

Das Etikett heisst "gelesen" und erscheint nur, wenn die Box den Text wirklich
eingeordnet hat. Ohne Einordnung steht dort die Voreinstellung, und die darf sich
nicht als Verstaendnis ausgeben.

Der Entwurf steht und faellt damit, dass der Einordner meistens richtig liegt —
5 von 5 im Test ist ein huebsches, aber winziges Ergebnis. Deshalb zaehlt die
Seite jetzt mit, wie oft "anders" geklickt wird (localStorage, zwei Zahlen, kein
Endpunkt) und zeigt die Quote beim Aufklappen. Bleibt sie unter etwa jedem
fuenften Mal, war der Weg richtig; liegt sie darueber, bauen wir den Assistenten
mit Daten statt mit Meinung.

Nebenbei: das jetzt tote `vorgeschlagen`-Prop aus <Wahl> entfernt (4 Aufrufer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:16:17 +02:00
HitonabiandClaude Opus 5 20d4f40ed9 Ideen: zwei Bereiche, drei Karten — Durchdenken nach vorn
Ampel / ampel (push) Successful in 24s
User-Befund, und er trifft einen echten Modellierungsfehler von gestern: "Eine
durchgedachte Idee wird ja sowieso mit meinem Ja zu einem IDE-Repo." Stimmt —
/ideen/weiterfuehren macht aus der durchdachten Idee GENAU die IDE-Projekt-Karte,
die der Repo-Weg direkt anlegt. Es sind zwei Stufen EINES Weges, keine zwei Achsen.

Der Haken von gestern war deshalb falsch: "Die Box, allein" + "nur denken" war
anklickbar, submit() setzte dann aber still welt:"ide" und schob einen in die
Zed-Welt, ohne das zu sagen. Genau die versteckte Umschaltung, die anderswo in
diesem Repo als Fassade gilt.

Jetzt GENAU EINE Auswahl aus drei, in zwei Bereichen:
  Du baust selbst, in Zed   [Erst durchdenken] [Direkt ein Repo]
  Oder die Box macht es     [Die Box baut allein]
Die falsche Kombination ist damit strukturell unmoeglich, nicht nur unwahrscheinlich.

Nebengewinn: beim Durchdenken entfallen Tiefe und Ziel am Eingang. Die
Weiter-Leiste fragt beides ohnehin beim "gefaellt mir" — und dann weiss man, was
das Ding ueberhaupt wird. Vorher entschied man LXC-oder-Docker, bevor ein Konzept
existierte.

Ausgewaehlt ist immer eine KARTE, nie ein Bereich — der Bereich gruppiert nur, was
denselben Weg geht. Voreinstellung ist "Erst durchdenken": die Wahl, die am
wenigsten anlegt. Der Einordner ueberschreibt sie ohnehin in ~500 ms.

API unveraendert; alle drei Abbildungen gegen den alten Vertrag geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:08:23 +02:00
HitonabiandClaude Opus 5 cb649aac34 Ideen 2.0: schreiben zuerst, die Box waehlt vor
Ampel / ampel (push) Successful in 24s
Die Seite verlangte eine technische Weichenstellung, BEVOR man gesagt hat was man
will — mit Begriffen aus dem Maschinenraum (BOX / IDE: SIMPEL / IDE: GRUENDLICH).
Deshalb klebten 116 Woerter Beipackzettel unter einem einzigen Eingabefeld.

Zwei unabhaengige Entscheidungen, vorher in vier sich ausschliessende Knoepfe gepresst:
  wer       — die Box allein, oder ein Repo fuer Zed
  nurDenken — erst nur ein Konzept (passt zu beidem, jetzt ein Haken)
Die Knoepfe nennen das ERGEBNIS statt des Werkzeugs, und der erklaerende Satz steht
an der Option statt in einer Legende darunter.

Neu: POST /api/ideen/einordnen — das dauerwarme Hirn liest den getippten Text und
waehlt vor (gemessen ~500 ms, 5 von 5 Testfaellen richtig, inkl. "Lohnt sich...?"
-> nur denken). Bewusst ein VORSCHLAG mit sichtbarem Etikett: er setzt nur die
Auswahl, die ohnehin dasteht. Faellt er aus, bleibt schlicht die Voreinstellung —
das Tippen wird nie blockiert, es gibt keinen Ladebalken und keine Fehlermeldung.

Tiefe und Ziel erscheinen nur noch beim Repo-Weg, weil sie nur dort wirken. Ein
Schalter der nichts tut ist schlimmer als keiner.

Leerer Zustand zeigt drei anklickbare Beispiele statt "wirf der Box eine Idee zu".
Kopfzeile nennt nur noch den Zustand — "Ideen" stand dreimal uebereinander.

Die API bleibt unveraendert; die Abbildung passiert in submit().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:58:51 +02:00
HitonabiandClaude Opus 5 d18aac0e1b Persoenliche Arbeitsregeln erreichen endlich den Coding-Agenten
Ampel / ampel (push) Successful in 25s
Befund beim Nachsehen wegen Zeds "Skills Support"-Ankuendigung: die 46 Zeilen
Arbeitsregeln in AppData\Roaming\Zed\AGENTS.md haben OpenCode NIE erreicht.
Zeds Doku ist eindeutig — AGENTS.md und Skills gelten nur fuer Zeds NATIVEN
Agenten, nicht fuer ACP-Agenten wie OpenCode. Hier wird ueber ACP gearbeitet.
Gute Regeln (Plan vor Code, kleine Schritte, beweisen statt behaupten,
Faulheits-Leiter, Kontext-Waechter) liefen also ins Leere.

Sie liegen jetzt in ~/.config/opencode/AGENTS.md — auf dem PC UND auf der Box,
also am Tag in Zed und nachts im Cron. Vorlage im Repo unter deploy/opencode/.

Beim Umzug korrigiert:
- Zeds eingebaute Commit-Vorlage entfernt. Sie war in dieselbe Datei gerutscht
  und sagt "Only return the commit message in your response" — in dauerhaft
  aktiven Regeln ein Fehlerherd.
- "Zum Bauen oben auf Coder umschalten" gestrichen: seit der Mannschafts-
  Umstellung laufen plan und build auf demselben Modell. Das war MEINE
  Regression aus Stufe 4, hier faellig geworden.
- Kontext-Waechter auf den Governor umgeschrieben (der kennt den Fuellstand
  wirklich, Zeds Anzeige war Heuristik).
- Neu: Pruef-Tor (VERIFY, inkl. "Test abschwaechen gilt als Betrug"),
  die Subagenten-Mannschaft, der Werkzeug-Zaun und ein Abschnitt fuer
  UNBEAUFSICHTIGTE Laeufe — nachts sagt niemand "los", dort darf der Agent
  nicht auf Freigabe warten.

Bewiesen: in einem leeren Ordner (nur globale Regeln wirksam) antwortet der
Agent wortgenau aus der neuen Datei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:38:08 +02:00
HitonabiandClaude Opus 5 6c14705798 Ideen: fertige Karten sammelweise wegraeumen
Ampel / ampel (push) Successful in 22s
Einzeln ging es schon (X je Karte). Bei fuenf abgeschlossenen Projekt-Ketten und
18 fertigen Karten waren das aber ~18 Klicks — praktisch raeumt dann niemand auf,
und die drei relevanten Karten gehen in der Liste unter.

Neu:
  POST /api/ideen/archivieren-alle {projekt?}  -> services.archive_done()
  Kopfzeile:      "18 fertige wegraeumen" (alle, auch die in Ketten)
  Je Projekt:     "wegraeumen" neben "N fertig"

Sicherheitseigenschaft, bewusst im Dienst statt in der UI: archive_done fasst
AUSSCHLIESSLICH status=done an — laufende, haengende, wartende und in Ausarbeitung
befindliche Karten nie, auch nicht ohne Projekt-Filter. Ein Sammel-Knopf darf
niemals versehentlich Arbeit abraeumen. Der Dialog sagt ausserdem ehrlich, dass
nichts geloescht wird: es ist Hermes' Kanban-Archiv, die Karten bleiben lesbar.

Getestet ohne Nebenwirkung: Filter auf ein nicht existierendes Projekt liefert
archiviert=0, die 19 Karten der Queue bleiben unangetastet. Das echte Wegraeumen
ist ein User-Klick, kein Deploy-Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:54:35 +02:00
HitonabiandClaude Opus 5 991c9a2f82 Ideen-Queue bekommt eine eigene Seite
Ampel / ampel (push) Successful in 22s
Die Queue war 703 der 1147 Zeilen in AuftragsbuchView — die MEHRHEIT einer Seite,
die nach etwas anderem benannt ist. Und sie laufen gegenlaeufig: Auftragsbuch =
die Box schlaegt DIR vor (Eingang), Ideen = DU beauftragst die Box (Ausgang).
Seit dem Umbau ist Idee -> Konzept -> Repo -> Zed der Haupteingang des Systems
und gehoert in die Seitenleiste, nicht in einen Unterabschnitt.

Umzug (reines Verschieben, kein Logik-Umbau):
  AuftragsbuchView  1147 -> 406 Zeilen   Bundle 51,0 -> 17,8 KB
  IdeenView         neu, 785 Zeilen      Bundle 32,7 KB, laedt nur bei Bedarf
  fmtWhen           -> lib/format.ts (dessen Kopf genau davor warnt: "vorher in
                      einzelnen Views dupliziert")
  SectionLabel      -> components/ui/ (beide Seiten brauchen sie jetzt)

Zwei inhaltliche Aenderungen:
1. Die flache Einzelkarten-Liste wird gruppiert — mit der Ordnung, die
   ProjektGruppe fuer Ketten LAENGST benutzt (AKTIV_RANG): aktiv zuerst,
   Wartende und Fertige eingeklappt. Bei 19 Karten, davon 16 fertig, gingen die
   drei relevanten in der Erstellzeit-Liste unter.
2. Die Kopfzeile sagt jetzt "1 braucht dich · 2 in Arbeit · 16 fertig" statt
   einer nackten Gesamtzahl.

Das Auftragsbuch zaehlte im gruenen "Nichts zu entscheiden" bisher haengende
Ideen mit, damit es nicht luegt, waehrend direkt darunter eine Karte haengt.
Nach der Trennung wird daraus ein anklickbarer Querverweis auf die Ideen-Seite —
dieselbe Ehrlichkeit, jetzt mit Weg dorthin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:41:10 +02:00
HitonabiandClaude Opus 5 ad49dd65ca Update-Kette repariert: Boot-Warmliste zurueckgenommen + Tool-Smoke mit Retry
Ampel / ampel (push) Successful in 22s
MEINE REGRESSION. Das systemd-Drop-in vom Umbau setzte
MC_WARMUP_MODELS="fast coder kritiker". warmup.sh waermt aber erst ALLE Chat-
Modelle, dann Embeddings und ERST DANN Hermes' ~70-KB-Agenten-Prompt — genau das,
was der Tool-Smoke braucht. Coder (48 GB) + Kritiker davorzuschieben kostete ~35 s
und trieb den Tool-Smoke ueber sein 90-s-Limit: Job faf1137a262c FAILED mit
"Tool-Smoke ohne verwertbares Ergebnis" (leere Antwort = curl-Timeout), obwohl
Agent und Modelle in Ordnung waren. Warm gemessen dauert derselbe Aufruf 12 s.

Zwei Korrekturen:
1. Boot-Warmliste zurueck auf "fast" (Drop-in auf der Box). Lucys Hirn und der
   Agenten-Prompt haben Vorrang; der Coder waermt beim ersten Auftrag selbst
   (23 s einmalig, dann 90 min warm).
2. Tool-Smoke bekommt 3 Versuche + 120 s statt 1 Versuch + 90 s — dieselbe Lehre,
   die der Voice-Smoke zehn Zeilen tiefer laengst umgesetzt hatte ("erster Turn
   zahlt den Session-Kaltstart"). Ein Versuch war ein Fehlalarm-Generator.

Bewiesen: llama-swap kalt neu gestartet, Postcheck sofort danach -> GEHIRN OK,
Tool-Smoke im ersten Versuch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:19:30 +02:00
HitonabiandClaude Opus 5 c28aa4a6a2 README aktualisiert + Kritiker auf 16k gedeckelt (Prefill-Befund)
Ampel / ampel (push) Successful in 22s
README war auf dem Stand "Final-Version eingefroren" und kannte weder Governor,
Coding-Bahn, CI-Ampel noch den Weg einer Idee. Neu geschrieben mit den echten
Ports/Diensten, den gemessenen Modell-Rollen und den vier Qualitaets-Toren.
Alle Verweise gegen den Baum geprueft.

Dabei aufgefallen: das eigene VERDIKT vom 24.07. ("Devstral bricht beim 32K-Prefill
auf 63 t/s ein") widerlegt meine Begruendung "ein Kritiker liest viel und schreibt
wenig, also stoert die Langsamkeit nicht" — es ist genau das LESEN, das einbricht.
Nachgemessen: Prefill 265-318 t/s (Coder 754). Der Kritiker ist deshalb in
llama-swap UND in beiden opencode.json auf 16384 gedeckelt; schlimmster Fall je
Review jetzt unter einer Minute statt 8+ Minuten.

VERDIKTE um zwei Eintraege ergaenzt: die enge Kritiker-Rolle mit Deckel, und die
llama-swap-Gruppenregel (eine Gruppe resident, persistent:false, OOM-Grenze).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:04:44 +02:00
HitonabiandClaude Opus 5 8b76c0f749 Ampel-Rot auf main behoben: ungenutzter Import in curator_archive_logic.py
Ampel / ampel (push) Successful in 22s
Der Merge hat die Ampel zum ERSTEN MAL auf `main` laufen lassen — sie kam am
24.07. auf dem Branch dazu und war nie auf main. Sie hat sofort einen echten
Altfehler aus der autonomen Auftragsbuch-Arbeit gefunden: F401, `os` importiert
aber nirgends benutzt (deploy/curator_archive_logic.py:11).

Nicht von diesem Umbau verursacht — aber jetzt sichtbar, und deshalb behoben.
Alle drei Ampel-Gates lokal gruen: ruff · compileall · Frontend-Build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:51:50 +02:00
Hermes Worker a934deee41 Doku: Qwen-AgentWorld-35B als abgelehnt für Vision-Rolle vermerken (t_9daf088f)
Ampel / ampel (push) Failing after 22s
2026-07-25 21:46:07 +02:00
Hitonabi 543eee95d0 Merge governor-phase0: Umbau Stufe 1-5 (Ampel #3 gruen) 2026-07-25 21:45:56 +02:00
HitonabiandClaude Opus 5 59d6be8c4a OpenCode-Vorlagen vollstaendig: PC-Seite + README
Ampel / ampel (push) Successful in 22s
Die Box-Seite lag im Repo, die PC-Seite nur auf dem PC — bei Verlust waere die
Haelfte des Setups unrekonstruierbar gewesen. Jetzt liegen beide Vorlagen
nebeneinander, plus ein README mit der Mannschaftsaufstellung (inkl. der auf der
Box gemessenen Tempi) und den vier Fallen, die beim Bauen Zeit gekostet haben:
kein _comment in opencode.json, Plugin muss Windows+Linux koennen, session.idle
ueberlebt `opencode run` nicht, plugin/ vs plugins/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:40:49 +02:00
HitonabiandClaude Opus 5 7fac17ed9a Umbau Stufe 1-5: Governor v2 + OpenCode-Plugin + Subagenten-Mannschaft
Die Intelligenz sitzt nicht mehr NEBEN dem Coden, sondern DRIN. Alles auf der
Box gemessen, nicht angenommen.

Stufe 1 — Speicher-Haushalt (llama-swap-Config, nicht im Repo):
  Coder dauerwarm statt ttl 600 (Kaltstart 23 s -> 491 ms), Devstral als
  'kritiker' verdrahtet, Swap 11 GB -> 0. Zwei Befunde: Kontext 131k->65k spart
  bei Qwen3-Next 0 GB (Hybrid-Attention), und llama-swap haelt nur EINE Gruppe
  resident -> alles Ko-Residente in dieselbe Gruppe. `persistent: true` verhindert
  dabei das Freiraeumen vor grossen Modellen -> ausgeloester Kernel-OOM, behoben
  durch persistent:false + TTLs (Vision laedt jetzt in 10 s statt 117 s + Absturz).

Stufe 2 — Governor v2 (deploy/governor/):
  Steht jetzt IM Pfad (:8100 -> MC2 :9001) statt daneben. Zaehlt den GANZEN
  Anfragekoerper inkl. tools/tool_calls und kalibriert sich aus den echten
  usage.prompt_tokens jeder Antwort nach: Schaetzfehler 200 % -> 0,3 %.
  Soft-Einschub nur noch, wenn die Nachrichtenkette es erlaubt (kein Dazwischen-
  funken in offene tool_calls). Neuer Status-Endpunkt + systemd-Unit.
  Lucys Alltagsmodelle sind vom Schnitt ausgenommen.

Stufe 3 — OpenCode-Plugin (deploy/opencode/plugin/mc2-governor.ts):
  Werkzeug-Zaun (git push, rm -rf, sudo, curl|sh — bewiesen), Pruef-Tor auf
  session.idle mit Selbstreparatur, Savepoint statt Kompression, Meldungen an
  Lucys Briefkasten mit eigenem Absender 'loop'.

Stufe 4 — Mannschaft (opencode.json):
  plan+build -> coder (51,5 t/s) · explore -> hermes (69,6 t/s, warm, gratis) ·
  review -> Devstral (15,0 t/s, FREMDE Modellfamilie gegen blinde Flecken).
  Der 63-GB-Planer faellt aus der Tagesrolle raus.

Stufe 5 — ein Regelwerk, zwei Ausloeser:
  deploy/opencode-lauf.sh faehrt dieselbe Bau-Pruef-Schleife unbeaufsichtigt
  (die Schleife liegt hier UND im Plugin: bei `opencode run` endet der Prozess,
  bevor session.idle fertig ist — gemessen). Bricht ab, wenn der Governor fehlt.
  gitea-repo-create.sh saet jetzt VERIFY neben der CI-Ampel: jedes neue Repo
  wird mit Innen- UND Aussen-Pruefung geboren.

Oberflaeche:
  Token-Waechter-Kachel im Cockpit (Fuellstand, Marken, Ehrlichkeits-Nachweis),
  /api/governor als gleichursprüngliches Fenster, Devstral im Modellkatalog,
  Rollen-Texte auf die neue Mannschaft aktualisiert.

.gitattributes: deploy/**/*.py auf LF (deploy/*.py greift nur eine Ebene tief).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:39:32 +02:00
Auftragsbuch 00f2b71a63 Auftragsbuch: 'doku/curator-autos' angenommen (Ein-Klick-Gate) 2026-07-25 04:10:55 +02:00
Hitonabi 2cecd06c5b doku: Curator Auto-Archivierung Cron dokumentieren 2026-07-25 03:53:02 +02:00
HitonabiandClaude Opus 4.8 775e862652 CI: MC2-Ampel auf sinnvolle Gates zugeschnitten (Lint+Import+Frontend-Build)
Ampel / ampel (push) Successful in 22s
Die universelle Vorlage will pip-install ALLER 5 requirements (inkl. schwerer ML-Deps:
Whisper/TTS) + pytest repo-weit in einem stateless Container — fuer dieses Multi-Service-
ML-Monorepo weder machbar noch aussagekraeftig (echte Tests = Pruefstand/Integration auf
der Box mit Live-Diensten). MC2-Ampel prueft stattdessen, was im Container ehrlich gruen
sein kann und echte Fehler faengt: ruff (Projekt-Politik) + compileall + Frontend-Build
inkl. tsc-Typecheck. Bewusste Abweichung von 'pytest = Pflicht' fuer dieses Repo, begruendet
im Workflow-Kopf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:56:16 +02:00
HitonabiandClaude Opus 4.8 47f7a85510 Ruff-Cleanup: ganzes MC2-Repo lint-grün + projekt-passende ruff.toml
Die Ampel-Nachruestung deckte 317/337 vorbestehende ruff-Verstoesse im ganzen Repo
auf. Aufgeraeumt:
- ruff.toml: intentionale Muster als Projekt-Politik ausgenommen (BLE001 blind-except,
  S110/S112 try-except-pass/continue, PLW1510 subprocess-best-effort, B008 FastAPI-
  Depends/File-Idiom, EXE001 Shebang, + wenige Stil-Regeln). __init__.py-Re-Exports
  geschuetzt (F401).
- ruff --fix: 128 mechanische (Import-Sortierung, PEP585/604-Annotationen, tote Imports,
  ueberfluessige noqa) auto-behoben.
- 12 echte Reste von Hand: PERF402/102, PLC3002 (Lambda->walrus), ISC004 (String-Concat
  geklammert), F841/RUF059 (ungenutzte Vars), PIE810 (startswith-Tuple), UP031 (f-string),
  UP035 (veraltete typing-Imports).
Ergebnis: 'ruff check .' = 0, 'compileall' grün. Kein Verhaltenswechsel (nur Stil/Modernisierung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:53:36 +02:00
HitonabiandClaude Opus 4.8 e6502f676a CI: Ampel fuer MC2 verdrahtet (.gitea/workflows/ampel-ci.yml)
Ampel / ampel (push) Failing after 1m52s
Die universelle Ampel (aus deploy/ampel-ci.yml) war bisher nur Vorlage — MC2 hatte
keine aktive .gitea/workflows/-Datei, Pushes triggerten keinen CI-Lauf. Jetzt
nachgeruestet (offene Karte t_730a747a). Laeuft auf push+pull_request, erkennt
Projekt-Typ selbst (Python/Node) und prueft Ruff+compileall+pytest bzw. npm ci+build+test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:33:30 +02:00
HitonabiandClaude Opus 4.8 554c87aaee Governor: driver.py lint-sauber (Ruff gruen)
Imports nach oben (sortiert), Endpoint-Setzen in main() statt Modulebene ->
kein E402/unused-noqa mehr. governor.py war bereits sauber. 'ruff check' = 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:30:10 +02:00
Auftragsbuch 080eaa5ff5 Auftragsbuch: 'doku/pruefstand-coder-backend-24-07' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:13 +02:00
Auftragsbuch 7dd44a3630 Auftragsbuch: 'feature/curator-auto-archiv' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:12 +02:00
Hitonabi 554db10a6f VERDIKTE: Pruefstand 24.07 — Vulkan bleibt (ROCm-Recheck erledigt), Coder-Next bleibt (Devstral 4-8x langsamer), Reliabilitaet=Harness 2026-07-24 14:44:22 +02:00
Hitonabi ce030d6817 feat: Curator Auto-Archivierung (14-Tage-Inaktivität) 2026-07-24 13:46:49 +02:00
Auftragsbuch 8996c0d012 Auftragsbuch: 'wartung/curator-idle-watchdog' angenommen (Ein-Klick-Gate) 2026-07-24 09:58:38 +02:00
Hitonabi a1de1ec2ac feat: Curator Idle Watchdog implementieren
- Skript deploy/curator-idle-watchdog.sh für 14-Tage-Idle-Erkennung
- Spezifikation deploy/specs/curator-idle-watchdog.md
- Wöchentliche Prüfung (Sonntag 00:00), Trigger bei Überschreitung
2026-07-24 08:53:19 +02:00
458 changed files with 19749 additions and 18768 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"mc2-system": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
]
},
"mc2-web": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
]
}
}
}
+29 -2
View File
@@ -18,11 +18,38 @@
{ {
"name": "frontend", "name": "frontend",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"], "runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5180",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" }, "env": {
"MC_API_TARGET": "http://192.168.178.151:9001"
},
"autoPort": false, "autoPort": false,
"port": 5180 "port": 5180
},
{
"name": "frontend-mock",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"5181",
"--strictPort"
],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": {
"MC_API_TARGET": "http://127.0.0.1:9000"
},
"autoPort": false,
"port": 5181
} }
] ]
} }
+8
View File
@@ -1,6 +1,12 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"autoMode": {
"allow": [
"$defaults",
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
]
}
"Bash(git fetch *)", "Bash(git fetch *)",
"Bash(git push:*)", "Bash(git push:*)",
"Bash(git rev-list *)", "Bash(git rev-list *)",
@@ -87,6 +93,8 @@
"WebFetch(domain:dev.to)", "WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)", "WebFetch(domain:kyuz0.github.io)",
"WebFetch(domain:kmarble.dev)" "WebFetch(domain:kmarble.dev)"
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
] ]
} }
} }
+4
View File
@@ -11,6 +11,10 @@
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.) # sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
deploy/agent-hooks/*.py text eol=lf deploy/agent-hooks/*.py text eol=lf
deploy/*.py text eol=lf deploy/*.py text eol=lf
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
deploy/**/*.py text eol=lf
# Windows-Batch-Wrapper bleiben CRLF. # Windows-Batch-Wrapper bleiben CRLF.
*.cmd text eol=crlf *.cmd text eol=crlf
+135
View File
@@ -0,0 +1,135 @@
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
#
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
#
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
# Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
shell: bash
run: |
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
set -u +e
rot=0
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
ruff check . || rot=1
echo "== Python: Import/Syntax (compileall) =="
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
if [ -f frontend/package.json ]; then
if [ ! -f frontend/package-lock.json ]; then
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
rot=1
else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
if [ $rot -eq 0 ]; then
echo "-- Vitest"
( cd frontend && npm test --silent ) || rot=1
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
( cd frontend && npm run lint --silent ) || rot=1
fi
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
# Avatar-Modells. Ohne Deckel wächst beides unbemerkt zurück. Gemessen wird der
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
# bewusst und einmalig, nicht weil es im Weg stand:
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
# hinter lazy() wanderten und das Budget scharf blieb.
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
# ueberlegten Plattform-Schritt.
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
budget_dist=3145728 # Stand nach P0: 1 477 852 B
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
# Hash den falschen erwischt — und das Budget waere still immer gruen.
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
haupt="frontend/dist/$einstieg"
if [ -n "$haupt" ]; then
gz=$(gzip -c "$haupt" | wc -c)
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
if [ "$gz" -gt "$budget_gz" ]; then
echo "❌ Start-Bündel über Budget. Meist eine neue Bibliothek, die über"
echo " das eifrig geladene Cockpit hereinkommt — hinter lazy() legen."
rot=1
fi
fi
gesamt=$(du -sb frontend/dist | cut -f1)
echo "-- dist gesamt: $gesamt B (Budget $budget_dist)"
if [ "$gesamt" -gt "$budget_dist" ]; then
echo "❌ dist über Budget — Ballast? (Schrift-Subsets, Medien, WOFF 1)"
rot=1
fi
fi
if [ $rot -eq 0 ]; then
echo "== Frontend: Push nach release-dist =="
git config --global user.name "Gitea Actions"
git config --global user.email "actions@gitea.local"
CURRENT_COMMIT=$(git rev-parse HEAD)
# Erzeuge (oder hole) den orphan branch 'release-dist'
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
git rm -rf . >/dev/null 2>&1 || true
# Checkout nur frontend/dist vom aktuellen Stand
git add -f frontend/dist
if ! git diff-index --quiet HEAD; then
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
git push origin HEAD:release-dist -f
else
echo "Keine Änderungen am Frontend-Build."
fi
# Zurück zum originalen Branch für den Rest des Skripts
git checkout $CURRENT_COMMIT
fi
fi
fi
if [ $rot -ne 0 ]; then
echo "❌ AMPEL ROT — nichts heißt fertig', solange das rot ist."
else
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
fi
exit $rot
+2 -5
View File
@@ -7,11 +7,6 @@ __pycache__/
frontend/node_modules/ frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ # frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Env / local # Env / local
*.env *.env
.DS_Store .DS_Store
@@ -20,3 +15,5 @@ frontend/dist/avatar.vrm
box_recon* box_recon*
gemma_swap* gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
+21 -2
View File
@@ -6,7 +6,13 @@ Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `RE
## Was das ist ## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten: Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) · Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind. **Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
- **System:** Bosgame M5 (AMD Strix Halo APU)
- **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.
**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,
@@ -18,11 +24,16 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die - **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand. **das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor - **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht). dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy. - **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Agentic IDE & Vibe Coding
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
## Zeit & Umgebung ## Zeit & Umgebung
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. - **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
@@ -36,6 +47,14 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen. - **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap = - Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen. System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen. - Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten) ## Grenzen (Verdikt, eingehalten)
+126 -50
View File
@@ -1,72 +1,148 @@
# Mission Control 2.0 # Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box** Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren** 122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
„Werkstatt"), nicht mehr manuelle Releases. prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**, > **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert ## Die drei Bahnen
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
## Status: **live + eingefroren (MC2 Final)** | Bahn | Was sie tut | Wo |
|---|---|---|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue | MC2 `:9001` |
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
| **Lucy** | Innere Stimme am PC (HUD, kein Avatar) und auf Telegram — eine Stimme, ein Hirn | eigenes Repo `Hitonabi/lucy` |
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
ohne Betreuung monatelang weiter):
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/ ## Ports & Dienste
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
Branch → Gate → Deploy selbst).
API: `health · models · discover · fit · models/register · groups · routing · system/* · | Port | Dienst | Erreichbar |
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`. |---|---|---|
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md), | `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`). | `8080` | **llama-swap** — Modell-Router | LAN |
| `9010` | `mc2-gateway``/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
Auto-Update und Self-Smoke. `llama-swap` läuft als System-Unit.
## Die Bau-Pipeline
```
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
└── autonomes Vibe Coding via OpenCode Desktop + MCP
```
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
Wächtern geboren**:
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
Keine `VERIFY`-Datei = Prüf-Tor aus.
## Modelle & Rollen
Gemessen auf der Box (Stand: 27.08.2026, Vulkan b10653):
| Rolle | Modell | Tempo | Aufgabe |
|---|---|---|---|
| `coder` | Qwen3.8-27B (dicht, 27B) | **12,7 t/s** | Plant und baut — der Haupt-Coder. **131k Kontext** (ganzer Slot), multimodal |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,690 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm, 65k/Slot |
| `debugger` | Muse-Glimmer-30B | **4050 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (multimodal) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Vektorsuche + Feinsortierung |
> Sieben Rollen, mehr nicht. Frühere Fassungen listeten hier auch `kritiker`, `scout` und
> `dense-planer` — die Modell-Konsolidierung vom 19.08. hat sie entfernt (ein Coder, ein
> Agent-Hirn). Der dichte 27B ist seither nicht mehr „Planer" neben dem Coder, sondern **ist**
> der Coder. Details: `docs/wissen/STACK.md`.
‼️ **Dichte Modelle sind auf dieser Box bandbreitengebunden.** Der Prefill bricht mit wachsendem
Kontext ein — beim früheren Kritiker (Devstral, dicht) waren bei 32k gemessene 63 t/s ≈ **9 Minuten
nur zum Lesen**, weshalb er hart auf 16k gedeckelt war. Das gilt weiter für jedes dichte Modell:
`coder` (Qwen3.8-27B) liefert ~12,7 t/s gegen ~90 t/s des MoE-Hirns — **kein Konfigfehler, sondern
das 215-GB/s-Limit der Plattform.** Der Kritiker selbst ist seit dem 19.08. nicht mehr im Stack.
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
(`groups.brains`) steht auf **`persistent: true`** (Verdrängungsschutz; live gegengeprüft
27.08.2026). Wichtig ist nicht der Schalter, sondern: **alles, was gleichzeitig warm sein muss,
gehört in DIESELBE Gruppe** — zwei Gruppen verdrängen sich gegenseitig, und `persistent` schützt
nicht davor (gemessen 25.07.). Details und
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
## Qualitäts-Tore
1. **Prüf-Tor**`VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
Kontext-Komprimierung löscht still die Regeln mit.
## Selbsterhaltung
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
## API (Auswahl)
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
memory/* · agent/* · ideen/* · lucy/stimme/* · chronik · eigenleben · wissen ·
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
## Entwickeln ## Entwickeln
**Backend:**
```bash ```bash
# Backend
cd backend cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m uvicorn app:app --port 9000 .venv/Scripts/python -m uvicorn app:app --port 9000
# Frontend (Dev, proxyt /api → :9000)
cd frontend && npm install && npm run dev # http://localhost:5173
# Frontend (Build → wird vom Backend ausgeliefert)
cd frontend && npm run build # → frontend/dist
``` ```
**Frontend (Dev, proxyt /api → :9000):** `frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
```bash Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
## Env-Vars (Auswahl) ## Env-Vars (Auswahl)
| Variable | Default | Zweck | | Variable | Default | Zweck |
|---|---|---| |---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine | | `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config | | `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) | | `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
| `MC_PORT` | `9000` | MC-Backend-Port | | `MC_V1_UPSTREAM` | | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check | | `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) | | `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) | | `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
## Weiterlesen
| Dokument | Inhalt |
|---|---|
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
+42 -35
View File
@@ -1,50 +1,57 @@
# Savepoint — Mission Control 2.0 (MC2) # Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst _Stand: 2026-08-20, nach der Aufräum-Runde. Alle Zahlen hier sind **auf der Box gemessen**, nicht
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._ aus Doku übernommen._
## Was das ist (in einem Satz) ## Was das ist (in einem Satz)
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis, Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach- Agenten-Status, Updates & Wartung — und dient als Steuerpult für die autonome KI („Lucy").
Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/) ## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei). - **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git** - **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
(Box hat kein Node; Backend liefert die gebauten Assets aus). (Box hat kein Node; Backend liefert die gebauten Assets direkt aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV). - **MC2-Gateway** `:9010` (`/v1`-Datenpfad, model:auto Routing + native Bildweiche).
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab), - **Modell-Router** `:8080` (llama-swap v250, Vulkan/RADV Engine b10502).
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor). - **Hermes Agent** `:8642` (`hermes-gateway`, v0.20.4) + `hermes dashboard` auf `:9119` (nur Loopback).
- **Sidecars:** `voice_service/` (:8650, faster-whisper STT + Piper TTS), `mcp/` (4 MCP-Server),
`client/hermes-pc` (PC-Executor :7777).
‼️ `mem0_service/` liegt noch im Baum, ist aber **stillgelegt**`:8765` antwortet nicht.
## Aktueller Zustand ## Modelle live (gemessen 20.08.2026)
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird | Rolle (Alias) | Modell | Tempo | Zustand |
sauber gehalten und eingefroren, nicht neu erfunden. |---|---|---|---|
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung, | `hermes` / `fast` | Qwen3.6-35B-A3B (MoE, DFlash) | **95,7 t/s** · Prompt 221 t/s | immer warm |
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate. | `coder` | Qwen3.8-27B (dicht, Vision) | **12,6 t/s** · Prompt 144 t/s | ttl 5400, ~29 s Kaltstart |
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab | `debugger` | Muse-Glimmer-30B (DFlash) | — | ttl 600 |
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision` | `heavy` | gpt-oss-120b | — | on-demand |
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin. | `vision` | Qwen3-VL-30B-A3B-Instruct | — | on-demand |
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next · | `embed` / `reranker` | Qwen3-Embedding-0.6B / Qwen3-Reranker-0.6B | — | immer warm |
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
## Nordstern & Randbedingungen (WICHTIG für den Review) Warm-Gruppe `brains` = embed + reranker + Qwen3.6-35B-A3B. Warm-Wächter weckt nur `fast`.
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern) **Hintergrund zum Tempo:** Strix Halo liefert ~215 GB/s Speicherbandbreite. Ein *dichtes* Modell
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf muss pro Token seine vollen Gewichte lesen → 17 GB ÷ 215 GB/s ≈ 12,6 t/s ist das **Hardware-Limit**,
`Europe/Berlin` umgestellt. kein Konfigurationsfehler. MoE-Modelle lesen pro Token nur einen Bruchteil und sind darum ~8× schneller.
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
## Was NICHT empfohlen werden soll (bereits entschieden) ## Aufgeräumt am 20.08.2026
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in - CI-Ampel wieder **grün** (Lauf #77) — zwei ungenutzte `MEM0_SERVICE_URL`-Importe entfernt.
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind - Kaputter systemd-Timer `mc2-morgen-digest` abgeschaltet (der Hermes-Cron macht die Arbeit).
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.** - `mcp-stderr.log` (27 MB, 238k Ping-Zeilen) geleert.
- Config-Drift aufgelöst: Repo == `/etc/llama-swap/config.yaml`. Warm-Set-Idee geparkt auf
Branch `parked/warm-set-5d93822`.
- Entfernt: Governor-Reste, OpenCode-/aider-Reste (853 MB), 61 alte Backup-Dateien,
verwaiste Modelle Devstral-Small-2 + GLM-4.6V-Flash + mem0 (21,5 GB) → `/srv/models` 209 → 189 GB.
- **Fähigkeiten repariert:** `orchestrator`- und `wartung`-Skill sowie `fremdblick.sh` zeigten auf
gelöschte Modelle (`Qwen3-Coder-Next`, `GLM-4.6V-Flash`, `GLM-4.7-Flash`) → der Kritiker-Gate war
stumm kaputt. Jetzt auf **Rollen-Aliase** umgestellt, damit Modellwechsel sie nicht mehr brechen.
- Alles Gelöschte liegt gesichert in `~/archiv-aufraeumen-20260820/` (11 MB).
## Letzter Sicherungspunkt ## Offen
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live). - `mem0_service/` + `_mem0_reachable()` in `backend/routers/system.py` ausbauen (toter Port).
- `~/.hermes/scripts/fremdblick.sh` ist **unversioniert** — gehört ins Repo.
- MCP-Log dauerhaft deckeln (Hermes' `max_size_mb` greift für gekapertes stderr nicht).
- Verschwundene Wächter: Ampel-Wächter, Prüfstand, Stack-Rundumschlag.
- Coder-Tempo: Entwurfsmodelle für spekulatives Dekodieren liegen ungenutzt unter
`/srv/models/Qwen3.8-27B-DFlash2-GGUF/`.
+8 -22
View File
@@ -9,41 +9,38 @@ Server (proxyt /api hierher), daher CORS für localhost offen.
import asyncio import asyncio
import logging import logging
import os import os
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable from typing import Any
import httpx import httpx
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from routers import ( from routers import (
agent, agent,
auftragsbuch,
chronik, chronik,
connect, connect,
console, console,
eigenleben,
events, events,
gateway_proxy, gateway_proxy,
health, health,
hermes_ui, hermes_ui,
ideen, ideen,
maintenance, maintenance,
memory,
models, models,
routing, routing,
skills,
system, system,
voice, voice,
wissen, wissen,
zeitmaschine, zeitmaschine,
) )
from routers import reminders as reminders_router from routers import reminders as reminders_router
from services import memory as memory_svc from services import metrics_history, reminders, sentry, warmer
from services import ketten_digest, metrics_history, reminders, sentry, warmer from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)). # für alle Module (logging.getLogger(__name__)).
@@ -68,18 +65,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if sentry.ENABLED: if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop())) tasks.append(asyncio.create_task(reminders.reminders_loop()))
if ketten_digest.ENABLED:
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert. # 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop())) tasks.append(asyncio.create_task(metrics_history.sampler_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,
)
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient( app.state.gw_client = httpx.AsyncClient(
@@ -119,8 +106,8 @@ app.include_router(health.router)
app.include_router(models.router) app.include_router(models.router)
app.include_router(routing.router) app.include_router(routing.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(connect.router) app.include_router(connect.router)
app.include_router(memory.router)
app.include_router(agent.router) app.include_router(agent.router)
app.include_router( app.include_router(
voice.router voice.router
@@ -138,13 +125,12 @@ if V1_UPSTREAM:
else: else:
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store) app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached) app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router(skills.router) # Skills & Jobs Dashboard
app.include_router( app.include_router(
console.router console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all ) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
+21 -13
View File
@@ -19,20 +19,18 @@ 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
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse). # macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
_DEFAULT_CMD_TEMPLATE = ( _DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} " "llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap" "-c {ctx} -ngl 999 -fa on --load-mode none"
) )
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE) CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE: if "{model}" not in CMD_TEMPLATE:
@@ -72,7 +70,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,8 +95,14 @@ 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("/")
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").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
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole # rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
@@ -108,8 +112,12 @@ BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel). # Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/" BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit # Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback # Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter # ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie
# same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle # /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix # SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig. # liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
@@ -119,7 +127,7 @@ HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN. # PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/") PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
# --- Server ------------------------------------------------------------------ # --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0") HOST = os.environ.get("MC_HOST", "0.0.0.0")
+2 -3
View File
@@ -15,13 +15,12 @@ durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
import logging import logging
import os import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator
import httpx import httpx
from fastapi import FastAPI, Request
from config import LLAMA_SWAP_URL, VERSION from config import LLAMA_SWAP_URL, VERSION
from fastapi import FastAPI, Request
from routers import gateway_proxy from routers import gateway_proxy
logging.basicConfig( logging.basicConfig(
+2 -1
View File
@@ -4,8 +4,9 @@ from pathlib import Path
# Add backend directory to sys.path so we can import services # Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent)) sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH from config import CONFIG_PATH
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
def migrate(): def migrate():
print(f"Reading config from {CONFIG_PATH}...") print(f"Reading config from {CONFIG_PATH}...")
+21
View File
@@ -2,6 +2,16 @@
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.", "_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
"version": "2026-07-03", "version": "2026-07-03",
"models": [ "models": [
{
"role": "debugger", "name": "Muse-Glimmer-30B", "repo": "unsloth/Muse-Glimmer-30B-GGUF",
"family": "muse", "generation": 1.0, "total_params_b": 30, "active_params_b": 30,
"moe": false, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": true
},
{
"role": "coder", "name": "Qwen3.8-27B", "repo": "unsloth/Qwen3.8-27B-GGUF",
"family": "qwen", "generation": 3.8, "total_params_b": 27, "active_params_b": 27,
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": true
},
{ {
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF", "role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3, "family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
@@ -55,6 +65,17 @@
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF", "role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3, "family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
} }
] ]
} }
+12 -4
View File
@@ -1,10 +1,8 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht).""" """Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import agent_aktivitaet
from fastapi import HTTPException
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -42,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
def set_brain_model(body: BrainReq) -> dict: def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model) ok = update_brain_model(body.model)
return {"ok": ok} return {"ok": ok}
@router.get("/agent/aktivitaet")
def aktivitaet(limit: int = 60) -> dict:
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
"""
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
-64
View File
@@ -1,64 +0,0 @@
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import auftragsbuch
router = APIRouter(prefix="/api")
class BranchIn(BaseModel):
branch: str
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
class KandidatIn(BaseModel):
file: str
@router.get("/auftragsbuch")
def list_proposals() -> dict:
return auftragsbuch.list_proposals()
@router.get("/auftragsbuch/diff")
def diff(branch: str, repo: str = "mc2") -> dict:
res = auftragsbuch.diff_of(branch, repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch, body.repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/annehmen")
def skill_accept(body: KandidatIn) -> dict:
res = auftragsbuch.skill_accept(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/ablehnen")
def skill_reject(body: KandidatIn) -> dict:
res = auftragsbuch.skill_reject(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
return res
+2 -4
View File
@@ -1,12 +1,10 @@
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat. """Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter, Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt.""" autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from pydantic import BaseModel
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel
from services import announce from services import announce
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
+4 -8
View File
@@ -1,21 +1,17 @@
"""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
router = APIRouter(prefix="/api") 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()
+2 -3
View File
@@ -14,11 +14,10 @@ import logging
import httpx import httpx
import websockets import websockets
from config import BOX_CONSOLE_UPSTREAM
from fastapi import APIRouter, Request, WebSocket from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response from starlette.responses import Response
from config import BOX_CONSOLE_UPSTREAM
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -28,7 +27,7 @@ def _register(base: str, upstream: str) -> None:
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://") ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket(f"/{base}/ws") @router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base async def _proxy_ws(ws: WebSocket) -> None:
await ws.accept(subprotocol="tty") await ws.accept(subprotocol="tty")
try: try:
async with websockets.connect( async with websockets.connect(
-56
View File
@@ -1,56 +0,0 @@
"""Eigenleben-Endpoints — „Von allein“-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from services import eigenleben
router = APIRouter(prefix="/api")
class SkillItem(BaseModel):
id: str
name: str
kategorie: str | None
aktiv: bool | None
beschreibung: str
version: str
autor: str
herkunft: str
herkunft_label: str
genutzt: int
zuletzt_genutzt: str | None
erstellt: str | None
status: str
geaendert: float
class EigenlebenOverviewResponse(BaseModel):
available: bool
skills: list[SkillItem]
selbst_erstellt: int
bilanz: dict
class SkillTextResponse(BaseModel):
id: str
text: str
@router.get("/eigenleben", response_model=EigenlebenOverviewResponse)
def get_overview() -> dict:
return eigenleben.overview()
# :path — Kategorie-Skills haben einen Schrägstrich in der ID (autonomous-ai-agents/
# hermes-agent). Als normales {skill_id}-Segment matcht die Route dann nicht, der Request
# fällt in den SPA-Fallback (index.html, Status 200) und die UI zeigt „Konnte den
# Skill-Text nicht laden". Die Pfad-Validierung (max. eine Ebene, keine . /..) macht
# services.eigenleben.skill_text.
@router.get("/eigenleben/skill/{skill_id:path}", response_model=SkillTextResponse)
def get_skill(skill_id: str) -> dict:
text = eigenleben.skill_text(skill_id)
if text is None:
raise HTTPException(404, "Skill nicht gefunden.")
return {"id": skill_id, "text": text}
+87 -42
View File
@@ -1,25 +1,30 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt. """Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden Zwei Endpunkte, ein Sammler:
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare), GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus · `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token- · `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern nur `/api/events` kennt. Danach entfernen.
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
Sekunden), Modell-Quelle aus der Karte übernommen.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource `/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
selbst und bis dahin pollt die UI wie bisher). sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
Frontend-Gegenstück: frontend/src/lib/events.ts
""" """
import asyncio import asyncio
@@ -27,16 +32,16 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
from config import MODELS_DIR
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import MODELS_DIR
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen) METRIK_S = 1.0 # Takt der Messpunkte
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
@@ -67,35 +72,75 @@ def _fingerprints() -> dict[str, object]:
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models())) fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception: except Exception:
pass pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"), from services import jobengine
_mtime(MODELS_DIR / "mc2-announce-branches.json")) fp["jobs"] = json.dumps(
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs()]
)
except Exception:
pass
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json") fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
return fp return fp
@router.get("/events") async def _strom(request: Request, mit_metrik: bool):
async def events(request: Request) -> StreamingResponse: """Gemeinsamer Kern beider Endpunkte.
async def strom():
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) — Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken. ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints() """
yield ": verbunden\n\n" alt = _fingerprints()
seit_ping = 0.0 yield ": verbunden\n\n"
while True:
if await request.is_disconnected(): seit_abdruck = 0.0
return seit_ping = 0.0
await asyncio.sleep(TICK_S) takt = METRIK_S if mit_metrik else ABDRUCK_S
seit_ping += TICK_S
while True:
if await request.is_disconnected():
return
await asyncio.sleep(takt)
seit_abdruck += takt
seit_ping += takt
if mit_metrik:
try:
from services.system import metrik_punkt
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
seit_ping = 0.0
except Exception:
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
if seit_abdruck >= ABDRUCK_S:
seit_abdruck = 0.0
neu = _fingerprints() neu = _fingerprints()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]] keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu) alt.update(neu)
if keys: if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n" yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0 seit_ping = 0.0
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
return StreamingResponse(strom(), media_type="text/event-stream", if seit_ping >= KEEPALIVE_S:
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) yield ": ping\n\n"
seit_ping = 0.0
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
"""Der aktuelle Kanal: Anstöße UND Messpunkte."""
return StreamingResponse(_strom(request, mit_metrik=True),
media_type="text/event-stream", headers=_KOPF)
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
"""Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor
dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen."""
return StreamingResponse(_strom(request, mit_metrik=False),
media_type="text/event-stream", headers=_KOPF)
+1 -2
View File
@@ -11,11 +11,10 @@ entfernen → app.py bindet wieder den lokalen Gateway ein.
import logging import logging
from config import V1_UPSTREAM
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from config import V1_UPSTREAM
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1") router = APIRouter(prefix="/v1")
+36 -6
View File
@@ -2,11 +2,9 @@ import logging
import os import os
import httpx import httpx
from config import LLAMA_SWAP_URL
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
from services.routing_policy import load_policy from services.routing_policy import load_policy
@@ -24,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(
@@ -87,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,
@@ -101,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 ""
@@ -140,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)
data = r.json() try:
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.
@@ -189,7 +206,20 @@ async def models(request: Request):
async def _proxy(path: str, request: Request): async def _proxy(path: str, request: Request):
body = await request.json() # 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()
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"):
+2 -4
View File
@@ -1,10 +1,8 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0.""" """Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
from pydantic import BaseModel
from fastapi import APIRouter
from config import VERSION from config import VERSION
from fastapi import APIRouter
from pydantic import BaseModel
from services import gateway, llamaswap from services import gateway, llamaswap
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
+1 -2
View File
@@ -22,11 +22,10 @@ import re
import httpx import httpx
import websockets import websockets
from config import HERMES_BUILTIN_UI_UPSTREAM
from fastapi import APIRouter, Request, WebSocket from fastapi import APIRouter, Request, WebSocket
from starlette.responses import RedirectResponse, Response from starlette.responses import RedirectResponse, Response
from config import HERMES_BUILTIN_UI_UPSTREAM
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
+26 -2
View File
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuc
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import ideen from services import ideen
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -12,7 +11,7 @@ router = APIRouter(prefix="/api")
class IdeeIn(BaseModel): class IdeeIn(BaseModel):
titel: str titel: str
notiz: str = "" notiz: str = ""
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Zed welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst) tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet) ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken) art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
@@ -27,6 +26,15 @@ class AntwortIn(BaseModel):
antwort: str antwort: str
class ArchivAlleIn(BaseModel):
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
projekt: str = ""
class EinordnenIn(BaseModel):
text: str
@router.get("/ideen") @router.get("/ideen")
def list_queue() -> dict: def list_queue() -> dict:
return ideen.list_queue() return ideen.list_queue()
@@ -115,6 +123,22 @@ def archive(body: TaskIn) -> dict:
return res return res
@router.post("/ideen/einordnen")
def einordnen(body: EinordnenIn) -> dict:
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
und die Oberfläche behält ihre Voreinstellung."""
return ideen.einordnen(body.text)
@router.post("/ideen/archivieren-alle")
def archive_alle(body: ArchivAlleIn) -> dict:
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
res = ideen.archive_done(body.projekt)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
@router.get("/ideen/{id}/log") @router.get("/ideen/{id}/log")
def get_log(id: str) -> dict: def get_log(id: str) -> dict:
return ideen.log_of(id) return ideen.log_of(id)
+46 -24
View File
@@ -1,20 +1,28 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.""" """Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
from fastapi import APIRouter, HTTPException, Header v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
mit einem im Browser geparkten Geheimnis übertüncht.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import geheimnisse, maintenance
from services import maintenance
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class SudoReq(BaseModel):
sudo_password: str | None = None
class RestartReq(BaseModel): class RestartReq(BaseModel):
service: str service: str
sudo_password: str | None = None
class GeheimnisReq(BaseModel):
schluessel: str
wert: str | None = None
@router.get("/maintenance/updates") @router.get("/maintenance/updates")
@@ -29,32 +37,32 @@ def update_details(kind: str) -> dict:
return maintenance.update_details(kind) return maintenance.update_details(kind)
@router.post("/maintenance/check-updates") @router.post("/maintenance/check-updates")
def check_updates(body: SudoReq) -> dict: def check_updates() -> dict:
res = maintenance.check_updates_job(body.sudo_password) res = maintenance.check_updates_job()
if isinstance(res, dict) and not res.get("ok", True): if isinstance(res, dict) and not res.get("ok", True):
return res return res
return res return res
@router.post("/maintenance/os-update") @router.post("/maintenance/os-update")
def os_update(body: SudoReq) -> dict: def os_update() -> dict:
res = maintenance.os_update_job(body.sudo_password) res = maintenance.os_update_job()
if isinstance(res, dict) and not res.get("ok", True): if isinstance(res, dict) and not res.get("ok", True):
return res return res
return res return res
@router.post("/maintenance/engine-update") @router.post("/maintenance/engine-update")
def engine_update(body: SudoReq) -> dict: def engine_update() -> dict:
res = maintenance.engine_update_job(body.sudo_password) res = maintenance.engine_update_job()
if not res: if not res:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).") raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return res return res
@router.post("/maintenance/swap-update") @router.post("/maintenance/swap-update")
def swap_update(body: SudoReq) -> dict: def swap_update() -> dict:
res = maintenance.swap_update_job(body.sudo_password) res = maintenance.swap_update_job()
if not res: if not res:
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).") raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
return res return res
@@ -66,20 +74,34 @@ def hermes_update() -> dict:
@router.post("/maintenance/update-all") @router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict: def update_all() -> dict:
return maintenance.update_all_job(body.sudo_password) return maintenance.update_all_job()
@router.post("/maintenance/reboot") @router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict: def reboot() -> dict:
return maintenance.reboot(body.sudo_password) return maintenance.reboot()
@router.post("/maintenance/restart") @router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict: def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service, body.sudo_password) return maintenance.restart_service(body.service)
@router.get("/maintenance/logs") @router.get("/maintenance/logs")
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict: def logs(service: str, lines: int = 200) -> dict:
return maintenance.logs(service, lines, x_sudo_password) return maintenance.logs(service, lines)
@router.get("/maintenance/geheimnisse")
def geheimnisse_status() -> dict:
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
return geheimnisse.status()
@router.post("/maintenance/geheimnisse")
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
if not geheimnisse.setzen(body.schluessel, body.wert):
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
return {"ok": True, **geheimnisse.status()}
-93
View File
@@ -1,93 +0,0 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
import time
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
from services.voice_metrics import park, record_stage
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "knowledge"
source: str = "manual"
class MemUp(BaseModel):
content: str | None = None
category: str | None = None
class DedupeIn(BaseModel):
apply: bool = False
threshold: float = 0.85
class LearnIn(BaseModel):
text: str | None = None
messages: list[dict] | None = None
source: str = "auto"
category: str = "knowledge"
@router.get("/memory/export")
def export() -> dict:
return memory.export_text()
@router.get("/memory/graph")
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
return memory.graph(min_score=min_score, top_k=top_k)
@router.post("/memory/learn", status_code=201)
def learn(body: LearnIn) -> dict:
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
return memory.learn(text=body.text, messages=body.messages,
source=body.source, category=body.category)
@router.post("/memory/dedupe")
def dedupe(body: DedupeIn) -> dict:
return memory.dedupe(apply=body.apply, threshold=body.threshold)
@router.get("/memory")
def list_mem(q: str = "", category: str = "") -> list[dict]:
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
if q:
_t0 = time.perf_counter()
res = memory.list_memories(q=q, category=category)
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("memory_retrieve", _ms)
park("retrieve", _ms)
return res
return memory.list_memories(q=q, category=category)
@router.post("/memory", status_code=201)
def add(body: MemIn) -> dict:
if body.category not in memory.CATEGORIES:
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
return memory.add_memory(body.content, body.category, body.source)
@router.put("/memory/{mid}")
def update(mid: str, body: MemUp) -> dict:
res = memory.update_memory(mid, content=body.content, category=body.category)
if not res:
raise HTTPException(404, "Eintrag nicht gefunden")
return res
@router.delete("/memory/{mid}")
def delete(mid: str) -> dict:
if not memory.delete_memory(mid):
raise HTTPException(404, "Eintrag nicht gefunden")
return {"ok": True}
+8 -8
View File
@@ -1,11 +1,10 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups.""" """Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
import psutil import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import budget, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -78,7 +77,9 @@ class InstallReq(BaseModel):
quant: str = "Q4_K_M" quant: str = "Q4_K_M"
ctx: int | None = None ctx: int | None = None
jinja: bool = False jinja: bool = False
hf_token: str | None = None # Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
@router.get("/hf/search") @router.get("/hf/search")
@@ -140,14 +141,13 @@ def install(req: InstallReq) -> dict:
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen. # Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo] args = [hf.hf_bin(), "download", repo]
for f in info["files"]: args.extend(info["files"])
args.append(f)
if info["mmproj"]: if info["mmproj"]:
args.append(info["mmproj"]) args.append(info["mmproj"])
args += ["--local-dir", str(target)] args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV) env = dict(HF_DOWNLOAD_ENV)
if req.hf_token: if token := geheimnisse.hf_token():
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env, job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
on_done=_apply_role if role else None) on_done=_apply_role if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
+1 -3
View File
@@ -2,10 +2,8 @@
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete); Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
LAN-only wie alle MC2-Endpoints.""" LAN-only wie alle MC2-Endpoints."""
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import reminders from services import reminders
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
-1
View File
@@ -2,7 +2,6 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import gateway from services import gateway
from services.routing_policy import policy_meta, save_policy from services.routing_policy import policy_meta, save_policy
+102
View File
@@ -0,0 +1,102 @@
import os
import subprocess
import psutil
from fastapi import APIRouter
from pydantic import BaseModel
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):
skill_name: str
@router.get("/skills")
def list_skills():
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
current_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(os.path.dirname(current_dir))
skills_dir = os.path.join(repo_root, "deploy", "skills")
skills = []
# Check currently running hermes skill processes
running_skills = set()
for p in psutil.process_iter(['name', 'cmdline']):
try:
cmdline = p.info.get('cmdline')
if cmdline and any("hermes" in arg for arg in cmdline):
for arg in cmdline:
if "Führe den " in arg and " Skill aus" in arg:
# Extract skill name from "Führe den 'skill_name' Skill aus"
import re
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
if match:
running_skills.add(match.group(1))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
for entry in os.scandir(skills_dir):
if entry.is_dir():
# Suche nach SKILL.md für Metadaten
skill_md_path = os.path.join(entry.path, "SKILL.md")
description = ""
if os.path.exists(skill_md_path):
try:
with open(skill_md_path, "r", encoding="utf-8") as f:
# Lese erste Zeilen für Description (YAML Frontmatter)
lines = f.readlines()[:10]
for line in lines:
if "description:" in line.lower():
description = line.split(":", 1)[1].strip().strip('"\'')
break
elif "author:" in line.lower() and not description:
description = line.strip()
except Exception:
pass
skills.append({
"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.",
"running": _hermes_name(entry.name) in running_skills
})
return {"skills": skills}
@router.post("/skills/run")
def run_skill(req: RunSkillRequest):
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
if not os.path.exists(hermes_bin):
# Fallback falls lokal (auf Windows) entwickelt wird
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 - mit dem Namen, den Hermes
# 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:
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{skill.replace('/', '_')}.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"\n--- Starting Skill: {skill} ---\n")
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
except Exception as e:
return {"ok": False, "err": str(e)}
+5 -16
View File
@@ -9,12 +9,10 @@ import logging
import os import os
import subprocess import subprocess
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from services import backup as backup_svc from services import backup as backup_svc
from services import maintenance from services import maintenance
from services.agent import agent_status from services.agent import agent_status
@@ -44,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
@@ -67,7 +58,7 @@ def _user_unit_active(unit: str) -> bool:
r = subprocess.run(["systemctl", "--user", "is-active", unit], r = subprocess.run(["systemctl", "--user", "is-active", unit],
capture_output=True, text=True, timeout=3) capture_output=True, text=True, timeout=3)
return r.stdout.strip() == "active" return r.stdout.strip() == "active"
except Exception: # noqa: BLE001 except Exception:
return False return False
@@ -97,12 +88,10 @@ def services() -> dict:
rows += [ rows += [
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "", {"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
"ok": _user_unit_active("mc2-steward"), "scope": "user"}, "ok": _user_unit_active("mc2-steward"), "scope": "user"},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, {"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
"ok": a["gateway_reachable"], "scope": "user"}, "ok": a["gateway_reachable"], "scope": "user"},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], {"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
"ok": a["hermes_ui_reachable"], "scope": "user"}, "ok": a["hermes_ui_reachable"], "scope": "user"},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
"ok": _mem0_reachable(), "scope": "user"},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
"ok": _voice_reachable(), "scope": "user"}, "ok": _voice_reachable(), "scope": "user"},
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste, # ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
@@ -135,7 +124,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode, return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"ok": False, "code": -1, "out": "", "err": str(exc)} return {"ok": False, "code": -1, "out": "", "err": str(exc)}
+81 -13
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.
@@ -11,14 +11,16 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
import logging import logging
import os import os
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
import time import time
import httpx import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern) from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer, Timer,
@@ -29,8 +31,6 @@ from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (inter
record_stage, record_stage,
) )
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") _GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path: if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR) _sys.path.insert(0, _GUARD_DIR)
@@ -155,7 +155,7 @@ def voice_health() -> dict:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0)) r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200 out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001 except Exception as exc:
out["error"] = str(exc) out["error"] = str(exc)
return out return out
@@ -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)}
@@ -180,7 +180,7 @@ def voice_voices() -> dict:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0)) r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception as exc: # noqa: BLE001 except Exception as exc:
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}") raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@@ -245,7 +245,7 @@ def voice_get_reference() -> dict:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"active": False, "error": str(exc)} return {"active": False, "error": str(exc)}
@@ -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()
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch) _commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
# ---------------------------------------------------------------------------------------------
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
# LAN-only wie alle MC2-Endpoints.
class LucyTtsIn(BaseModel):
text: str
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
@router.get("/lucy/stimme/health")
def lucy_stimme_health() -> dict:
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
try:
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
r.raise_for_status()
return r.json()
except Exception as exc:
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
@router.post("/lucy/stimme/tts")
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
with Timer("lucy_tts"):
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
r.raise_for_status()
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
except httpx.HTTPStatusError as exc:
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
except httpx.HTTPError as exc:
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
@router.post("/lucy/stimme/tts/stream")
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
try:
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
upstream = await client.send(req, stream=True)
except httpx.HTTPError as exc:
await client.aclose()
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
if upstream.status_code != 200:
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
await upstream.aclose(); await client.aclose()
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
async def gen():
try:
async for chunk in upstream.aiter_raw():
yield chunk
finally:
await upstream.aclose()
await client.aclose()
return StreamingResponse(gen(), media_type="application/octet-stream",
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
+61 -3
View File
@@ -3,12 +3,12 @@ Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom T
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert.""" (und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os import os
import re
import time import time
from pathlib import Path from pathlib import Path
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -60,7 +60,7 @@ def list_vault() -> dict:
files = [] files = []
now = time.time() now = time.time()
for p in sorted(VAULT.rglob("*.md")): for p in sorted(VAULT.rglob("*.md")):
if ".git" in p.parts: if ".git" in p.parts or "traeume" in p.parts:
continue continue
rel = p.relative_to(VAULT).as_posix() rel = p.relative_to(VAULT).as_posix()
st = p.stat() st = p.stat()
@@ -91,3 +91,61 @@ def read_file(pfad: str) -> dict:
raise raise
except (ValueError, OSError): except (ValueError, OSError):
raise HTTPException(404, "Notiz nicht lesbar.") raise HTTPException(404, "Notiz nicht lesbar.")
@router.get("/wissen/graph")
def graph_vault() -> dict:
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
if not _available():
return {"nodes": [], "edges": []}
nodes = []
edges = []
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
name_to_id = {}
# 1. Alle Nodes sammeln
for p in VAULT.rglob("*.md"):
if ".git" in p.parts or "traeume" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
name_to_id[p.stem.lower()] = rel
# Kategorie aus dem Ordner ableiten
cat = "knowledge"
parent = p.parent.name if p.parent != VAULT else ""
if parent == "traeume":
cat = "events"
elif parent == "muster":
cat = "rules"
elif parent == "skill-kandidaten":
cat = "identity"
nodes.append({
"id": rel,
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
"category": cat,
"source": "wiki"
})
# 2. Edges extrahieren (Wiki-Links [[Name]])
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
for n in nodes:
try:
target = VAULT / n["id"]
content = target.read_text(encoding="utf-8", errors="replace")
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
for link in found_links:
target_id = name_to_id.get(link)
if target_id and target_id != n["id"]:
edges.append({
"source": n["id"],
"target": target_id,
"weight": 1.0
})
except OSError:
pass
return {"nodes": nodes, "edges": edges}
+1 -2
View File
@@ -13,7 +13,6 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import backup as backup_svc from services import backup as backup_svc
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -34,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
-2
View File
@@ -10,12 +10,10 @@ Permission errors are handled gracefully.
Uses user journal (systemctl --user) to query logs. Uses user journal (systemctl --user) to query logs.
""" """
import os
import subprocess import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
LOG_DIR = Path.home() / "logs" LOG_DIR = Path.home() / "logs"
LOG_FILE = LOG_DIR / "mc2-timeout.log" LOG_FILE = LOG_DIR / "mc2-timeout.log"
SERVICE_NAME = "mission-control-2.service" SERVICE_NAME = "mission-control-2.service"
+11 -7
View File
@@ -9,11 +9,15 @@ import os
import re import re
import httpx import httpx
import psutil from config import (
BOX_CONSOLE_PATH,
from config import (BOX_CONSOLE_UPSTREAM, BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH, HERMES_API_URL,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL) HERMES_BUILTIN_UI_PATH,
HERMES_BUILTIN_UI_UPSTREAM,
HERMES_HOME,
PC_EXECUTOR_URL,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -194,7 +198,7 @@ def set_agent_brain(model_id: str) -> dict:
new_members = [x for x in brains if x not in (old, model_id)] + [model_id] new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen. # 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL from services.llamaswap import DEFAULT_TTL, set_ttl
set_ttl(model_id, 0) set_ttl(model_id, 0)
if old: if old:
set_ttl(old, DEFAULT_TTL) set_ttl(old, DEFAULT_TTL)
@@ -253,7 +257,7 @@ def update_brain_model(new_model: str) -> bool:
# Restart the user-space service to apply changes # Restart the user-space service to apply changes
try: try:
import services.maintenance as maintenance from services import maintenance
maintenance.restart_service("hermes-gateway") maintenance.restart_service("hermes-gateway")
except Exception: except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
+176
View File
@@ -0,0 +1,176 @@
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
zu patchen.
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
nützlicher als der Fließtext ihrer Gedanken.
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
halten kann.
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
"""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
log = logging.getLogger(__name__)
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
LOG_PFAD = Path(os.path.expanduser(
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
LESE_BYTES = 512 * 1024
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
#
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
_FERTIG = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
_FEHLER = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
def _schwanz(pfad: Path, n: int) -> list[str]:
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
try:
groesse = pfad.stat().st_size
with pfad.open("rb") as f:
f.seek(max(0, groesse - n))
roh = f.read()
zeilen = roh.decode("utf-8", errors="replace").splitlines()
return zeilen[1:] if groesse > n and zeilen else zeilen
except OSError:
return []
def _kurz(detail: str | None) -> str | None:
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
JSON ab; interessant ist daran nur das `error`-Feld."""
if not detail:
return None
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
text = treffer.group(1) if treffer else detail.strip()
return (text[:297] + "") if len(text) > 300 else text
def rufe(limit: int = 60) -> list[dict]:
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
ergebnis: list[dict] = []
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
m = _FERTIG.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": int(m.group("zeichen")),
"ok": True,
"fehler": None,
})
continue
m = _FEHLER.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": None,
"ok": False,
"fehler": _kurz(m.group("detail")),
})
return ergebnis[-limit:]
def _iso(s: str) -> str:
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
Falle, die erst zur Zeitumstellung zuschnappt."""
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
except ValueError:
return s
def uebersicht(limit: int = 60) -> dict:
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
wiederkehrenden Fehler zusammengefasst.
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
liste = rufe(limit)
if not LOG_PFAD.exists():
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
"werkzeuge": [], "laeufe": []}
bilanz: dict[str, dict] = {}
for r in liste:
b = bilanz.setdefault(r["werkzeug"], {
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
"dauer_summe": 0.0, "letzter_fehler": None,
})
b["rufe"] += 1
b["dauer_summe"] += r["dauer_s"]
if not r["ok"]:
b["fehler"] += 1
b["letzter_fehler"] = r["fehler"]
werkzeuge = sorted(
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
for b in bilanz.values()),
key=lambda b: (-b["fehler"], -b["rufe"]),
)
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
laeufe: list[dict] = []
gesehen: dict[str, dict] = {}
for r in liste:
schluessel = r["lauf"] or "(interaktiv)"
if schluessel not in gesehen:
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
"rufe": 0, "fehler": 0}
laeufe.append(gesehen[schluessel])
e = gesehen[schluessel]
e["bis"] = r["zeit"]
e["rufe"] += 1
if not r["ok"]:
e["fehler"] += 1
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
"werkzeuge": werkzeuge, "laeufe": laeufe}
-1
View File
@@ -20,7 +20,6 @@ import time
from pathlib import Path from pathlib import Path
import httpx import httpx
from config import MODELS_DIR from config import MODELS_DIR
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
-461
View File
@@ -1,461 +0,0 @@
"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
REPOS: dict[str, dict] = {
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
}
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
# Muster wie mc2-announce.json unter MODELS_DIR.
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _repo_ok(repo: str) -> bool:
r = REPOS.get(repo)
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
capture_output=True, text=True, timeout=timeout)
def _status_key(repo: str, branch: str) -> str:
return f"{REPOS[repo]['key']}{branch}"
def _fetch_throttled(repo: str) -> None:
now = time.time()
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
return
_fetch_cache[repo] = now
try:
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
def _load_announce_branches() -> set:
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
try:
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
return set(data) if isinstance(data, list) else set()
except Exception:
return set()
def _save_announce_branches(branches: set) -> None:
"""Speichert die Menge gemelder Branch-Namen."""
try:
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
tmp.replace(ANNOUNCE_BRANCHES_PATH)
except OSError:
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
def _statuses() -> dict:
try:
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
except Exception:
return {}
def _set_status(key: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
except OSError:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _gutachten() -> dict:
"""Alle Karten-Stempel ("<repo>:<branch>"{empfehlung, satz, richter, commit_ts, ts})."""
try:
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _remote_branches(repo: str) -> list[str]:
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
if name and name != "HEAD" and name.startswith(PREFIXES):
out.append(name)
return out
def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
items = []
for branch in _remote_branches(repo):
ref = f"origin/{branch}"
status = statuses.get(_status_key(repo, branch))
try:
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
# garantierter Fehllauf („refusing to merge unrelated histories").
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
ts_val = int(cts) if cts.strip().isdigit() else None
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
stempel = gutachten.get(f"{repo}:{branch}")
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
stempel = None
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
if len(parts) >= 2:
files.append({"status": parts[0][:2], "path": parts[-1]})
paths = [f["path"] for f in files]
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"repo": repo,
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
"body": body.strip()[:2000],
"author": author.strip(),
"ts": ts_val,
"empfehlung": stempel,
"ahead": ahead,
"behind": behind,
"verwaist": verwaist,
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
"leer": not verwaist and ahead > 0 and not files_raw,
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
})
except Exception:
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
return items
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
statuses = _statuses()
gutachten = _gutachten()
items = []
for repo in REPOS:
if not _repo_ok(repo):
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
_fetch_throttled(repo)
items.extend(_repo_items(repo, statuses, gutachten))
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
aktuelle_branches = set()
for repo in REPOS:
if _repo_ok(repo):
aktuelle_branches.update(_remote_branches(repo))
# Bereits gemeldete Branches laden
gemeldet = _load_announce_branches()
# Nur wirklich NEUE Branches pingen
neue = aktuelle_branches - gemeldet
for branch in neue:
# NotifyTelegram best-effort, niemals crashen
try:
from services import announce
subject = "[Auftragsbuch]"
text = f"Neue Karte wartet auf deinen Klick: {branch}"
announce.notify_telegram(subject, text)
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
announce.add(text, subject, "auftragsbuch", "normal")
except Exception:
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
if aktuelle_branches != gemeldet:
_save_announce_branches(aktuelle_branches)
kandidaten = list_skill_kandidaten()
open_count = sum(1 for i in items
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str, repo: str = "mc2") -> dict:
if not _repo_ok(repo):
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str, repo: str = "mc2") -> dict:
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
if not _repo_ok(repo):
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
key = _status_key(repo, branch)
state = (_statuses().get(key) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / REPOS[repo]["runner"]
if not src.is_file():
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(runner), branch],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
if not _repo_ok(repo):
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
grund = (grund or "").strip()[:400]
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
detail = "Vom Commander abgelehnt — Branch gelöscht"
if grund:
detail += f" (Grund: {grund})"
_set_status(_status_key(repo, branch), "abgelehnt", detail)
try:
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
except OSError:
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
try:
from services import announce
wo = "im Lucy-Repo " if repo == "lucy" else ""
warum = f" Grund: {grund}" if grund else ""
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True}
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
def _kandidaten_dir() -> Path:
return VAULT / "skill-kandidaten"
def list_skill_kandidaten() -> list[dict]:
d = _kandidaten_dir()
if os.name != "posix" or not d.is_dir():
return []
out = []
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
text = p.read_text(encoding="utf-8", errors="replace")
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
out.append({"file": p.name, "title": first[:160],
"preview": text[:3000], "mtime": p.stat().st_mtime})
except OSError:
continue
return out
def _vault_git(args: list[str]) -> None:
try:
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
except Exception:
pass
def _kandidat_path(fname: str) -> Path | None:
if not _KANDIDAT_RX.match(fname):
return None
p = (_kandidaten_dir() / fname).resolve()
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
return None
return p
def skill_accept(fname: str) -> dict:
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
if not _available():
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
beauftragt = _kandidaten_dir() / "beauftragt"
beauftragt.mkdir(parents=True, exist_ok=True)
target = beauftragt / p.name
try:
content = p.read_text(encoding="utf-8", errors="replace")
p.rename(target)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
+ content)
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
try:
order_file.write_text(order, encoding="utf-8")
except OSError as exc:
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
unit = f"mc2-skill-auftrag-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
try:
from services import announce
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True, "unit": unit}
def skill_reject(fname: str) -> dict:
if not _available():
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
verworfen = _kandidaten_dir() / "verworfen"
verworfen.mkdir(parents=True, exist_ok=True)
try:
p.rename(verworfen / p.name)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
return {"ok": True}
+2 -2
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.
""" """
@@ -51,7 +51,7 @@ def backup_now() -> dict:
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180) r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
if r.returncode != 0: if r.returncode != 0:
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]} return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)} return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
latest = _latest() latest = _latest()
+20 -27
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.
@@ -10,8 +16,7 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
import json import json
import httpx import httpx
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, PORT, V1_UPSTREAM
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
DEFAULT_HOST = "192.168.178.151" DEFAULT_HOST = "192.168.178.151"
@@ -41,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}"
@@ -89,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,
@@ -117,18 +110,15 @@ 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."},
} }
def check_health() -> dict: def check_health() -> dict:
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box: """Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM, Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
sonst llama-swap direkt), Leitung 2 = Gedächtnis-Sidecar (Mem0), sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway),
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI).""" Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
gateway = {"ok": False, "detail": "nicht erreichbar"} gateway = {"ok": False, "detail": "nicht erreichbar"}
try: try:
@@ -139,16 +129,19 @@ def check_health() -> dict:
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"} gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
else: else:
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"} gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception: # noqa: BLE001 except Exception:
pass pass
memory = {"ok": False, "detail": "nicht erreichbar"} memory = {"ok": False, "detail": "nicht erreichbar"}
try: try:
with httpx.Client(timeout=3.0) as c: with httpx.Client(timeout=3.0) as c:
r = c.get(f"{MEM0_SERVICE_URL}/health") # Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200 r = c.get("http://127.0.0.1:8642/health")
else {"ok": False, "detail": f"HTTP {r.status_code}"}) if r.status_code in (200, 404, 401):
except Exception: # noqa: BLE001 memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
else:
memory = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception:
pass pass
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"} desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
@@ -157,7 +150,7 @@ def check_health() -> dict:
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status") r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200 desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"}) else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001 except Exception:
pass pass
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway} return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
+2 -3
View File
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
""" """
import json import json
import logging
import math import math
import os import os
import time import time
from datetime import datetime from datetime import datetime
import httpx import httpx
import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog from services import catalog
from services.caps import capabilities from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for from services.fit import evaluate_fit, extract_params_b, max_ctx_for
-219
View File
@@ -1,219 +0,0 @@
"""Eigenleben — was die Box von allein gebaut und vorgeschlagen hat.
Beantwortet die User-Frage vom 15.07.2026: „Es gibt keinen Viewpoint, welche Skills
erstellt wurden, was die Box vorgeschlagen hat, welche Skills Lucy hat und wie die
funktionieren." Reine LESE-Schicht, keine Schreib-Operationen:
- Skills aus ~/.hermes/skills (= Lucys Skill-Satz; die Worker-Profile werkstatt/betrieb
tragen Kopien desselben Satzes). Herkunft dreistufig klassifiziert:
selbst → weder mitgeliefert noch aus unserem Repo = die Box/Lucy hat ihn erzeugt
repo → liegt in ~/mission-control-v2/deploy/skills (von uns gebaut + deployt)
bundled → liegt in ~/.hermes/hermes-agent/skills (kam mit Hermes mit)
- Nutzungszahlen aus ~/.hermes/skills/.usage.json (Hermes' eigener Zähler).
- Bilanz aus Auftragsbuch-Chronik (/srv/models/mc2-auftragsbuch.json, angenommene
Branches) + Ablehnungs-Lern-Journal (/srv/models/mc2-ablehnungen.jsonl).
"""
from __future__ import annotations
import json
import re
import subprocess
import time
from pathlib import Path
SKILLS_DIR = Path.home() / ".hermes" / "skills"
BUNDLED_DIR = Path.home() / ".hermes" / "hermes-agent" / "skills"
REPO_DIR = Path.home() / "mission-control-v2" / "deploy" / "skills"
HERMES_SRC = Path.home() / ".hermes" / "hermes-agent"
HERMES_PY = HERMES_SRC / "venv" / "bin" / "python"
USAGE_FILE = SKILLS_DIR / ".usage.json"
AUFTRAGSBUCH_FILE = Path("/srv/models/mc2-auftragsbuch.json")
ABLEHNUNGEN_FILE = Path("/srv/models/mc2-ablehnungen.jsonl")
_HERKUNFT_LABEL = {
"selbst": "Selbst erstellt",
"repo": "Von uns gebaut",
"bundled": "Mit Hermes mitgeliefert",
}
def _frontmatter(text: str) -> dict:
"""Sehr kleiner Frontmatter-Leser: nur die flachen key: value-Zeilen des ersten
---Blocks (name/description/version/author reichen hier; kein YAML-Import nötig)."""
out: dict = {}
if not text.startswith("---"):
return out
for line in text.split("\n", 1)[1].split("\n"):
if line.strip() == "---":
break
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
if not m:
continue
val = m.group(2).strip().strip('"').strip("'")
if val:
out[m.group(1)] = val
return out
def _norm(s: str) -> str:
return re.sub(r"[\s_-]+", "", (s or "").lower())
# aktiv/inaktiv: dieselbe Wahrheit wie Hermes selbst (und sein eingebautes WebUI) —
# hermes_cli.banner.get_available_skills() prüft Plattform/Voraussetzungen/Config und
# liefert, was der Agent WIRKLICH laden kann. Wir rufen sie im Hermes-venv auf (MC2
# läuft in eigenem Python) und cachen großzügig — der Skill-Satz ändert sich selten.
_aktiv_cache: dict = {"ts": 0.0, "namen": None}
_AKTIV_EVERY = 600.0 # s
def _aktive_namen() -> set[str] | None:
"""Normalisierte Namen aller ladbaren Skills; None = nicht ermittelbar (Dev-Modus)."""
if not HERMES_PY.is_file():
return None
now = time.time()
if _aktiv_cache["namen"] is not None and now - _aktiv_cache["ts"] < _AKTIV_EVERY:
return _aktiv_cache["namen"]
code = ("from hermes_cli.banner import get_available_skills;import json;"
"print(json.dumps(get_available_skills()))")
try:
r = subprocess.run([str(HERMES_PY), "-c", code], capture_output=True,
text=True, timeout=60, cwd=str(HERMES_SRC))
idx = r.stdout.find("{")
kategorien = json.loads(r.stdout[idx:]) if idx >= 0 else {}
namen = {_norm(n) for liste in kategorien.values() for n in liste}
except Exception:
return _aktiv_cache["namen"] # alter Stand ist besser als flackerndes None
_aktiv_cache.update(ts=now, namen=namen)
return namen
def list_skills() -> list[dict]:
"""Alle Skills mit Beschreibung, Herkunft und Nutzungszahlen (leer im Dev-Modus)."""
if not SKILLS_DIR.is_dir():
return []
try:
usage_raw = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
except Exception:
usage_raw = {}
usage = {_norm(k): v for k, v in usage_raw.items()}
aktive = _aktive_namen()
def eintrag(md: Path, rel: str, kategorie: str | None) -> dict:
try:
fm = _frontmatter(md.read_text(encoding="utf-8", errors="replace"))
except Exception:
fm = {}
if (BUNDLED_DIR / rel).is_dir():
herkunft = "bundled"
elif (REPO_DIR / rel).is_dir():
herkunft = "repo"
else:
herkunft = "selbst"
kurz = rel.split("/")[-1]
u = usage.get(_norm(kurz)) or usage.get(_norm(fm.get("name", ""))) or {}
aktiv = None
if aktive is not None:
aktiv = _norm(kurz) in aktive or _norm(fm.get("name", "")) in aktive
return {
"id": rel,
"name": fm.get("name") or kurz,
"kategorie": kategorie,
"aktiv": aktiv,
"beschreibung": fm.get("description") or "",
"version": fm.get("version") or "",
"autor": fm.get("author") or "",
"herkunft": herkunft,
"herkunft_label": _HERKUNFT_LABEL[herkunft],
"genutzt": int(u.get("use_count") or 0),
"zuletzt_genutzt": u.get("last_used_at") or None,
"erstellt": u.get("created_at") or None,
"status": u.get("state") or "active",
"geaendert": md.stat().st_mtime,
}
skills: list[dict] = []
for d in sorted(SKILLS_DIR.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue # .archive (Curator) & Co. sind keine aktiven Skills
md = d / "SKILL.md"
if md.is_file():
skills.append(eintrag(md, d.name, None))
continue
# Kategorie-Ordner (mitgelieferte Sammlungen wie research/, apple/): eine Ebene
# tiefer liegen die echten Skills (research/blogwatcher/SKILL.md) — je einzeln zeigen.
for sub in sorted(d.iterdir()):
smd = sub / "SKILL.md"
if sub.is_dir() and not sub.name.startswith(".") and smd.is_file():
skills.append(eintrag(smd, f"{d.name}/{sub.name}", d.name))
# Selbst erstellte zuerst — das ist der Star der Ansicht.
order = {"selbst": 0, "repo": 1, "bundled": 2}
skills.sort(key=lambda s: (order[s["herkunft"]], -s["genutzt"], s["name"]))
return skills
def skill_text(skill_id: str) -> str | None:
"""Voller SKILL.md-Text („wie funktioniert der Skill") — max. Kategorie/Name, keine Pfad-Tricks."""
if not re.fullmatch(r"[\w.-]+(/[\w.-]+)?", skill_id or ""):
return None
# Das Zeichen-Set oben erlaubt „." und „.." als Segment — seit die Route ein
# :path-Parameter ist, hier explizit raus (kein Klettern über SKILLS_DIR hinaus).
if any(seg in (".", "..") for seg in skill_id.split("/")):
return None
md = SKILLS_DIR / skill_id / "SKILL.md"
if not md.is_file():
return None
try:
return md.read_text(encoding="utf-8", errors="replace")
except Exception:
return None
def bilanz() -> dict:
"""Vorschlags-Bilanz: was die Box vorschlug und was daraus wurde (eine Timeline)."""
eintraege: list[dict] = []
try:
branches = json.loads(AUFTRAGSBUCH_FILE.read_text(encoding="utf-8")).get("branches") or {}
for branch, info in branches.items():
eintraege.append({
"art": "angenommen" if info.get("state") == "eingespielt" else info.get("state", "?"),
"titel": branch,
"detail": info.get("detail") or "",
"ts": float(info.get("ts") or 0),
})
except Exception:
pass
try:
for line in ABLEHNUNGEN_FILE.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
j = json.loads(line)
except Exception:
continue
eintraege.append({
"art": "abgelehnt",
"titel": j.get("subject") or j.get("branch") or "?",
"detail": j.get("grund") or "",
"ts": float(j.get("ts") or 0),
})
except Exception:
pass
eintraege.sort(key=lambda e: -e["ts"])
return {
"eintraege": eintraege,
"angenommen": sum(1 for e in eintraege if e["art"] == "angenommen"),
"abgelehnt": sum(1 for e in eintraege if e["art"] == "abgelehnt"),
}
def overview() -> dict:
skills = list_skills()
b = bilanz()
return {
"available": SKILLS_DIR.is_dir(),
"skills": skills,
"selbst_erstellt": sum(1 for s in skills if s["herkunft"] == "selbst"),
"bilanz": b,
}
+2 -2
View File
@@ -5,8 +5,8 @@ V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
""" """
import httpx import httpx
from config import PORT, V1_UPSTREAM from config import PORT, V1_UPSTREAM
from services.llamaswap import engine_reachable from services.llamaswap import engine_reachable
from services.routing_policy import load_policy from services.routing_policy import load_policy
@@ -50,7 +50,7 @@ def gateway_reachable() -> bool:
if V1_UPSTREAM: if V1_UPSTREAM:
try: try:
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200 return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
except Exception: # noqa: BLE001 except Exception:
return False return False
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap. # Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
return engine_reachable() return engine_reachable()
+100
View File
@@ -0,0 +1,100 @@
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
gleichbedeutend mit Root auf der Box gewesen.
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
erreicht — nicht, dass die Datei unlesbar wäre.
"""
import json
import logging
import os
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
ERLAUBT = frozenset({"hf_token"})
def _lesen() -> dict[str, str]:
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
try:
daten = json.loads(PFAD.read_text(encoding="utf-8"))
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
except (OSError, ValueError):
return {}
def _schreiben(daten: dict[str, str]) -> bool:
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
tmp = PFAD.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
tmp.replace(PFAD)
return True
except OSError as exc:
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
return False
def hf_token() -> str | None:
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
def setzen(schluessel: str, wert: str | None) -> bool:
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
if schluessel not in ERLAUBT:
return False
daten = _lesen()
if wert:
daten[schluessel] = wert
else:
daten.pop(schluessel, None)
return _schreiben(daten)
def status() -> dict[str, bool]:
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
daten = _lesen()
return {
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
"schreibbar": _schreibbar(),
}
def _schreibbar() -> bool:
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
return os.access(PFAD.parent, os.W_OK)
except OSError:
return False
+113 -15
View File
@@ -22,6 +22,9 @@ import subprocess
import time import time
from pathlib import Path from pathlib import Path
import httpx
from config import GATEWAY_URL, V1_UPSTREAM
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser() HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
@@ -109,7 +112,7 @@ def _kurz(text: str, max_len: int = 80) -> str:
return (schnitt or t[:max_len]) + "" return (schnitt or t[:max_len]) + ""
_TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-]?\s*", re.I) _TITEL_PRAEFIX_RX = re.compile(r"^\s*(idee|projekt|bitte)\s*[:\-]?\s*", re.IGNORECASE)
def _projekt_titel(titles: list[str]) -> str: def _projekt_titel(titles: list[str]) -> str:
@@ -220,7 +223,7 @@ def _ketten_anreichern(items: list[dict]) -> list[dict]:
for it in items: for it in items:
familien.setdefault(boss(it["id"]), []).append(it) familien.setdefault(boss(it["id"]), []).append(it)
projekte = [] projekte = []
for wurzel, mitglieder in familien.items(): for mitglieder in familien.values():
if len(mitglieder) < 2: if len(mitglieder) < 2:
continue continue
mitglieder.sort(key=lambda i: i.get("erstellt") or 0) mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
@@ -389,12 +392,12 @@ def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui",
welt='ide': geht DIREKT an das Profil `projektstart` (am Triage/Auto-Zerleger vorbei) — welt='ide': geht DIREKT an das Profil `projektstart` (am Triage/Auto-Zerleger vorbei) —
die Box härtet ein Konzept (konzept-fliessband), legt ein leeres Gitea-Repo an und die Box härtet ein Konzept (konzept-fliessband), legt ein leeres Gitea-Repo an und
füllt es NUR mit Doku. Ergebnis ist ein vorbereitetes Projekt, das der Commander in füllt es NUR mit Doku. Ergebnis ist ein vorbereitetes Projekt, das der Commander in
Zed selbst baut (IDE-Welt). Bewusst KEIN --triage: so fasst der Auto-Zerleger die Agentic IDE selbst baut (IDE-Welt). Bewusst KEIN --triage: so fasst der Auto-Zerleger die
Karte nie an und die Werkstatt baut sie nie (der Fan-out-Fehler vom 20.07.). Karte nie an und die Werkstatt baut sie nie (der Fan-out-Fehler vom 20.07.).
tiefe (nur ide): 'simpel' | 'gruendlich' — vom Knopf gewaehlte Konzept-Tiefe; leer = tiefe (nur ide): 'simpel' | 'gruendlich' — vom Knopf gewaehlte Konzept-Tiefe; leer =
die Box schaetzt selbst (Fallback fuer Telegram/Lucy). die Box schaetzt selbst (Fallback fuer Telegram/Lucy).
ziel (nur ide-Projekt): 'lxc' | 'docker' — Ziel-Plattform; leer = die Box entscheidet. ziel (nur ide-Projekt): 'lxc' | 'docker' — Ziel-Plattform; leer = die Box entscheidet.
art (nur ide): 'projekt' (Default, Repo+Doku+Roadmap, Bau in Zed) oder 'idee' art (nur ide): 'projekt' (Default, Repo+Doku+Roadmap, Bau in IDE) oder 'idee'
(Produkt-Idee nur durchdenken: NUR Konzept, keine Roadmap/Geruest, kein Bau-Druck).""" (Produkt-Idee nur durchdenken: NUR Konzept, keine Roadmap/Geruest, kein Bau-Druck)."""
if not _available(): if not _available():
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
@@ -453,7 +456,7 @@ def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui",
"[Ideen-Queue]", "ideen-queue", "silent") "[Ideen-Queue]", "ideen-queue", "silent")
elif welt == "ide": elif welt == "ide":
announce.add(f"Neues IDE-Projekt in Vorbereitung: „{titel}“ ({task['id']}) — die Box " announce.add(f"Neues IDE-Projekt in Vorbereitung: „{titel}“ ({task['id']}) — die Box "
"härtet das Konzept und legt das Repo an, dann baust du in Zed.", "härtet das Konzept und legt das Repo an, dann baust du in der Agentic IDE.",
"[Ideen-Queue]", "ideen-queue", "silent") "[Ideen-Queue]", "ideen-queue", "silent")
else: else:
announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.", announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.",
@@ -509,6 +512,101 @@ def archive_task(task_id: str) -> dict:
return {"ok": True} return {"ok": True}
_EINORDNEN_SYS = (
"Du ordnest eine frisch getippte Projekt-Idee ein. Antworte AUSSCHLIESSLICH mit einem "
"JSON-Objekt, ohne Prosa, ohne Codezaun:\n"
'{"wer":"box"|"ide","tiefe":"schlank"|"gruendlich","nur_denken":true|false}\n\n'
"wer=box: Aufgabe am eigenen Heimlabor des Nutzers — seine Server, seine bestehenden "
"Dienste (Mission Control, Lucy, Rippy), Wartung, Doku, Automatisierung. Die Box baut selbst.\n"
"wer=ide: ein NEUES eigenstaendiges Programm/Werkzeug/App, das der Nutzer danach selbst "
"programmieren will. Er bekommt nur ein vorbereitetes Repo.\n"
"tiefe=gruendlich: gross, vage oder mit vielen offenen Entwurfsfragen. "
"tiefe=schlank: klein und klar umrissen.\n"
"nur_denken=true: der Text stellt eine FRAGE oder waegt ab (\"lohnt sich\", \"waere es sinnvoll\", "
"\"brauche ich\") statt einen Auftrag zu geben."
)
def einordnen(text: str) -> dict:
"""Grober Einordnungs-VORSCHLAG für eine frisch getippte Idee.
Nur eine Vorauswahl in der Oberfläche — der Mensch sieht und überschreibt sie. Deshalb
bewusst anspruchslos: ein einziger kurzer Aufruf ans dauerwarme Hirn (69,6 t/s gemessen),
Schnellspur-Marker gegen das Nachdenken, hartes Timeout. Scheitert irgendetwas, kommt
`{"ok": False}` zurück und die Oberfläche behält schlicht ihre Voreinstellung — eine
Einordnung darf das Tippen NIE blockieren oder eine Fehlermeldung erzeugen.
"""
text = (text or "").strip()
if len(text) < 12:
return {"ok": False}
ziel_url = (V1_UPSTREAM or GATEWAY_URL).rstrip("/")
marker = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
try:
r = httpx.post(f"{ziel_url}/v1/chat/completions", timeout=12.0, json={
"model": "hermes", "temperature": 0, "max_tokens": 60, "stream": False,
"messages": [
{"role": "system", "content": _EINORDNEN_SYS},
{"role": "user", "content": f"{marker} {text[:600]}"},
],
})
r.raise_for_status()
inhalt = (r.json()["choices"][0]["message"]["content"] or "").strip()
except Exception:
log.debug("ideen: einordnen fehlgeschlagen", exc_info=True)
return {"ok": False}
# Modelle packen JSON gern in einen Codezaun oder schreiben einen Satz davor.
m = re.search(r"\{.*\}", inhalt, re.DOTALL)
if not m:
return {"ok": False}
try:
d = json.loads(m.group(0))
except Exception:
return {"ok": False}
wer = d.get("wer") if d.get("wer") in ("box", "ide") else None
tiefe = d.get("tiefe") if d.get("tiefe") in ("schlank", "gruendlich") else None
if wer is None and tiefe is None:
return {"ok": False}
return {"ok": True, "wer": wer or "ide", "tiefe": tiefe or "schlank",
"nur_denken": bool(d.get("nur_denken"))}
def archive_done(projekt: str = "") -> dict:
"""Alle FERTIGEN Karten wegräumen — optional nur die eines Projekts.
Sammel-Aufräumen (25.07.2026): Bei fünf abgeschlossenen Projekt-Ketten wären das
sonst ~18 Einzelklicks. Wie beim Einzel-Archivieren wird nichts gelöscht — die
Karten wandern ins Kanban-Archiv und bleiben dort lesbar.
‼️ Bewusst NUR `done`. Laufende, hängende, wartende und in Ausarbeitung befindliche
Karten werden nie angefasst, auch nicht bei leerem `projekt`-Filter — ein Sammel-
Knopf darf niemals versehentlich Arbeit abräumen.
"""
if not _available():
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
q = list_queue()
if not q.get("available"):
return {"ok": False, "error": q.get("fehler") or "Queue nicht lesbar."}
ziel = [i for i in q.get("items", [])
if i.get("status") == "done" and (not projekt or i.get("projekt") == projekt)]
if not ziel:
return {"ok": True, "archiviert": 0, "fehlgeschlagen": []}
ok, fehler = 0, []
for item in ziel:
r = archive_task(item.get("id", ""))
if r.get("ok"):
ok += 1
else:
# Die Items aus list_queue tragen "titel" (deutsch), nicht das rohe "title".
fehler.append({"id": item.get("id"), "titel": _kurz(item.get("titel") or "", 60),
"error": r.get("error", "")})
_list_cache["ts"] = 0.0
return {"ok": True, "archiviert": ok, "fehlgeschlagen": fehler}
def ergebnis_of(task_id: str) -> dict: def ergebnis_of(task_id: str) -> dict:
"""Das ERGEBNIS einer fertigen Aufgabe — die Abschluss-Zusammenfassung des Workers. """Das ERGEBNIS einer fertigen Aufgabe — die Abschluss-Zusammenfassung des Workers.
@@ -752,8 +850,8 @@ def _lokales_konzept(text: str, titel: str = "") -> tuple | None:
if not kandidaten and titel and _KONZEPT_DIR.is_dir(): if not kandidaten and titel and _KONZEPT_DIR.is_dir():
worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())} worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())}
treffer = [p.name for p in _KONZEPT_DIR.glob("*.md") treffer = [p.name for p in _KONZEPT_DIR.glob("*.md")
if (lambda g: len(g) >= 2 or any(len(w) >= 6 for w in g))( if len(g := worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower()))) >= 2
worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower())))] or any(len(w) >= 6 for w in g)]
if len(treffer) == 1: if len(treffer) == 1:
kandidaten.append(treffer[0]) kandidaten.append(treffer[0])
for name in kandidaten: for name in kandidaten:
@@ -883,7 +981,7 @@ def konzept_of(task_id: str) -> dict:
_AUS_IDEE_KOPF = ( _AUS_IDEE_KOPF = (
"AUFTRAGS-ART: PROJEKT AUS GEPRUEFTER IDEE. Der Commander hat diese Idee erst durchdenken " "AUFTRAGS-ART: PROJEKT AUS GEPRUEFTER IDEE. Der Commander hat diese Idee erst durchdenken "
"lassen — das Konzept gefaellt ihm, jetzt soll daraus ein startklares Repo werden, das er " "lassen — das Konzept gefaellt ihm, jetzt soll daraus ein startklares Repo werden, das er "
"SELBST in Zed baut.") "SELBST in der Agentic IDE baut.")
_AUS_IDEE_FUSS = ( _AUS_IDEE_FUSS = (
"· Denk das Konzept NICHT neu. Es ist bereits mit voller Kaskade + Advocatus Diaboli " "· Denk das Konzept NICHT neu. Es ist bereits mit voller Kaskade + Advocatus Diaboli "
"gehaertet. Lies es vollstaendig und uebernimm es; eine kurze Nachschaerf-Runde fuer die " "gehaertet. Lies es vollstaendig und uebernimm es; eine kurze Nachschaerf-Runde fuer die "
@@ -891,7 +989,7 @@ _AUS_IDEE_FUSS = (
"Verschwendung.\n" "Verschwendung.\n"
"· Der Rest ist deine normale Projektstart-Rolle: das Repo enthaelt am Ende NUR Doku " "· Der Rest ist deine normale Projektstart-Rolle: das Repo enthaelt am Ende NUR Doku "
"(KONZEPT.md, ROADMAP.md, AGENTS.md, README.md, SAVEPOINT.md, .aiexclude) — KEIN Code.\n" "(KONZEPT.md, ROADMAP.md, AGENTS.md, README.md, SAVEPOINT.md, .aiexclude) — KEIN Code.\n"
"· Uebergabe wie ueblich: Repo-URL nennen, damit der Commander in Zed loslegen kann.") "· Uebergabe wie ueblich: Repo-URL nennen, damit der Commander in der Agentic IDE loslegen kann.")
def _wo_liegt(konz: dict) -> str: def _wo_liegt(konz: dict) -> str:
@@ -935,7 +1033,7 @@ def _konzept_und_name(task_id: str) -> tuple:
quell_titel = _TITEL_PRAEFIX_RX.sub("", str(t.get("title") or "").strip()) quell_titel = _TITEL_PRAEFIX_RX.sub("", str(t.get("title") or "").strip())
if not quell_titel: if not quell_titel:
return {}, "", "Quell-Karte hat keinen Titel." return {}, "", "Quell-Karte hat keinen Titel."
h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.M) h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.MULTILINE)
kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0] kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0]
return konz, _kurz(kurz, 85), "" return konz, _kurz(kurz, 85), ""
@@ -992,7 +1090,7 @@ def projekt_aus_idee(task_id: str, ziel: str = "", tiefe: str = "") -> dict:
try: try:
from services import announce from services import announce
announce.add(f"Aus der Idee „{_kurz(kurz, 60)}“ wird ein Projekt: {task['id']} — die Box " announce.add(f"Aus der Idee „{_kurz(kurz, 60)}“ wird ein Projekt: {task['id']} — die Box "
"bereitet das Repo vor, gebaut wird in Zed.", "bereitet das Repo vor, gebaut wird in der Agentic IDE.",
"[Ideen-Queue]", "ideen-queue", "silent") "[Ideen-Queue]", "ideen-queue", "silent")
except Exception: except Exception:
pass pass
@@ -1052,9 +1150,9 @@ def konzept_ueberarbeiten(task_id: str, hinweis: str) -> dict:
"als `KONZEPT.md` hinein (klonen, schreiben, pushen, Push beweisen)." "als `KONZEPT.md` hinein (klonen, schreiben, pushen, Push beweisen)."
if konz.get("repo") else "")) if konz.get("repo") else ""))
teile = [f"{_UEBERARBEITEN_KOPF}\n{quelle}\n" teile = [(f"{_UEBERARBEITEN_KOPF}\n{quelle}\n"
f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n{hinweis}\n" f"· SO SOLL ES ANDERS WERDEN — Wortlaut des Commanders:\n{hinweis}\n"
f"{_UEBERARBEITEN_FUSS}", f"{_UEBERARBEITEN_FUSS}"),
_INFRA_LXC] _INFRA_LXC]
titel = f"Konzept nachschärfen: {kurz}"[:200] titel = f"Konzept nachschärfen: {kurz}"[:200]
args = ["create", titel, "--body", "\n\n".join(teile)[:4000], "--assignee", "projektstart", args = ["create", titel, "--body", "\n\n".join(teile)[:4000], "--assignee", "projektstart",
@@ -1098,7 +1196,7 @@ def log_of(task_id: str) -> dict:
try: try:
r = _hermes(["log", task_id]) r = _hermes(["log", task_id])
except Exception as exc: except Exception:
log.warning("ideen: kanban log fehlgeschlagen", exc_info=True) log.warning("ideen: kanban log fehlgeschlagen", exc_info=True)
return {"available": True, "lines": []} return {"available": True, "lines": []}
if r.returncode != 0: if r.returncode != 0:
+16 -27
View File
@@ -57,40 +57,30 @@ def _pump_output(job: dict, stream) -> None:
commit() commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None): def _run_job(job_id: str, args: list[str], env: dict | None = None):
"""Job-Prozess starten und mitschreiben.
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
job = JOBS[job_id] job = JOBS[job_id]
job["state"] = "running" job["state"] = "running"
try: try:
actual_args = list(args)
if sudo_password is not None:
for i, arg in enumerate(actual_args):
if isinstance(arg, str):
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
proc = subprocess.Popen( proc = subprocess.Popen(
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None, stdin=subprocess.DEVNULL,
bufsize=0, bufsize=0,
env={**os.environ, **(env or {})}, env={**os.environ, **(env or {})},
) )
_PROCS[job_id] = proc _PROCS[job_id] = proc
if sudo_password is not None and proc.stdin:
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
proc.stdin.flush()
proc.stdin.close()
_pump_output(job, proc.stdout) _pump_output(job, proc.stdout)
proc.wait() proc.wait()
job["returncode"] = proc.returncode job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed") job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
except Exception as exc:
# Check if failed due to sudo authorization failure
if proc.returncode != 0 and job["log"]:
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: # noqa: BLE001
_append_log(job, f"[mc] Fehler: {exc}") _append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed" job["state"] = "failed"
job["returncode"] = -1 job["returncode"] = -1
@@ -101,7 +91,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
if cb and job["state"] == "done": if cb and job["state"] == "done":
try: try:
cb() cb()
except Exception as exc: # noqa: BLE001 except Exception as exc:
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}") _append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
@@ -136,7 +126,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
j["rate_bps"] = rate j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate) j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur prev_t, prev_b = now, cur
except Exception: # noqa: BLE001 except Exception:
pass pass
time.sleep(1.0) time.sleep(1.0)
j = JOBS.get(job_id) j = JOBS.get(job_id)
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
sudo_password: str | None = None, group: str | None = None) -> str: group: str | None = None) -> str:
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
# Mask password in log if present in args
log_args = list(args) log_args = list(args)
JOBS[job_id] = { JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "group": group, "id": job_id, "label": label, "state": "queued", "group": group,
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=None
} }
if on_done: if on_done:
JOBS[job_id]["_on_done"] = on_done JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start() threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
return job_id return job_id
@@ -182,7 +171,7 @@ def cancel_job(job_id: str) -> bool:
if proc is not None: if proc is not None:
try: try:
proc.terminate() proc.terminate()
except Exception: # noqa: BLE001 except Exception:
pass pass
else: else:
job["state"] = "canceled" job["state"] = "canceled"
-191
View File
@@ -1,191 +0,0 @@
"""
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
einmal pro Karte, mit der Frage im Wortlaut,
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
"""
import asyncio
import json
import logging
import os
import time
import zlib
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
def _kontext_druck(task_id: str) -> int:
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster."""
try:
p = KANBAN_LOGS / f"{task_id}.log"
with p.open("rb") as f:
f.seek(max(0, p.stat().st_size - 200_000))
tail = f.read().decode("utf-8", errors="replace")
return tail.count("Compacting context")
except Exception:
return 0
def _load_state() -> dict:
try:
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _save_state(state: dict) -> None:
try:
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATE_PATH)
except OSError:
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
def _melden(subject: str, text: str) -> None:
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
from services import announce
announce.notify_telegram(subject, text)
try:
announce.add(text, subject, "ketten-digest", "silent")
except Exception:
pass
def _tick() -> None:
from services import ideen
data = ideen.list_queue()
if not data.get("available"):
return
items = data.get("items") or []
projekte = data.get("projekte") or []
by_id = {i["id"]: i for i in items if i.get("id")}
state = _load_state()
gemeldete_fragen: dict = state.setdefault("fragen", {})
projekt_state: dict = state.setdefault("projekte", {})
dirty = False
now = int(time.time())
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
for it in items:
if it.get("status") != "blocked":
continue
frage = (it.get("frage") or "").strip()
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
if key in gemeldete_fragen:
continue
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}\n"
+ (f"Frage: {frage[:500]}\n" if frage else "")
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
_melden("[Werkstatt]", text)
gemeldete_fragen[key] = now
dirty = True
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
gemeldete_haenger: dict = state.setdefault("haenger", {})
for it in items:
if it.get("status") != "running":
continue
puls = it.get("puls_alter")
if puls is None or puls < HANG_SEKUNDEN:
continue
key = f"{it['id']}:{it.get('gestartet') or 0}"
if key in gemeldete_haenger:
continue
minuten = int(puls / 60)
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
f"seit {minuten} min kein Lebenszeichen vom Worker.")
druck = _kontext_druck(it["id"])
if druck >= 2:
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
"die Aufgabe ist womöglich zu groß geschnitten.")
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
_melden("[Werkstatt]", text)
gemeldete_haenger[key] = now
dirty = True
if len(gemeldete_haenger) > 200:
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
gemeldete_haenger.pop(k, None)
dirty = True
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
if len(gemeldete_fragen) > 200:
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
gemeldete_fragen.pop(k, None)
dirty = True
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
for p in projekte:
ps = projekt_state.setdefault(p["key"], {})
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
"Ergebnisse liegen im Auftragsbuch.")
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
dirty = True
continue
aktiv = p.get("laeuft", 0) > 0
if not aktiv:
continue
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
continue
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
naechste = [i for i in items if i.get("projekt") == p["key"]
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
for l in laufende[:2]:
note = f"{l['notiz']}" if l.get("notiz") else ""
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
if naechste:
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
haengt = p.get("haengt", 0)
if haengt:
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
_melden("[Werkstatt]", "\n".join(zeilen))
ps.update(letzter_puls=now, letzter_stand=fertig)
dirty = True
if dirty:
_save_state(state)
async def digest_loop() -> None:
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
+14 -9
View File
@@ -11,12 +11,17 @@ import os
import re import re
import httpx import httpx
from ruamel.yaml.scalarstring import LiteralScalarString
from config import ( from config import (
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL, CMD_TEMPLATE,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE, CONFIG_PATH,
DEFAULT_TTL,
DRAFTS_DIR,
LLAMA_SWAP_URL,
SPEC_DRAFT_MODEL_PATH,
SPEC_DRAFT_N_MAX,
SPEC_TYPE,
) )
from ruamel.yaml.scalarstring import LiteralScalarString
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -143,13 +148,13 @@ def model_id_from_path(model_path: str) -> str:
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) → Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'.""" dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
d = os.path.basename(os.path.dirname(model_path)) d = os.path.basename(os.path.dirname(model_path))
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I): if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
d = os.path.basename(os.path.dirname(os.path.dirname(model_path))) d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_") name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
if not name: if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I) fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
fn = re.sub(r"-\d+-of-\d+$", "", fn) fn = re.sub(r"-\d+-of-\d+$", "", fn)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I) name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
return name or "modell" return name or "modell"
@@ -222,7 +227,7 @@ def write_config(cfg: dict) -> None:
try: try:
from services import warmer from services import warmer
warmer.nudge() warmer.nudge()
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker except Exception:
pass pass
except PermissionError as exc: except PermissionError as exc:
raise PermissionError( raise PermissionError(
+67 -62
View File
@@ -20,7 +20,7 @@ from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"} SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console", USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"} "hermes-gateway", "hermes-builtin-ui", "voice-service"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root). # Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -35,7 +35,7 @@ _engine_cache = {"ts": 0.0, "avail": False}
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check # manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release # als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert. # nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I) _ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
def _latest_engine_asset_release() -> dict | None: def _latest_engine_asset_release() -> dict | None:
@@ -73,8 +73,9 @@ def _installed_engine_build() -> int | None:
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True, out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
timeout=20, env=env) timeout=20, env=env)
txt = (out.stderr or "") + (out.stdout or "") txt = (out.stderr or "") + (out.stdout or "")
# Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: <hash> (9821)", "b9821". # Formate je nach Build: "version: 0.1.0-dev (build 10426, ...)", "version: 9821 (hash)", "build: <hash> (9821)", "b9821".
if (m := re.search(r"version:\s*(\d{3,})", txt)) \ if (m := re.search(r"\bbuild\s+(\d{3,})\b", txt)) \
or (m := re.search(r"version:\s*(\d{3,})", txt)) \
or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \ or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \
or (m := re.search(r"\bb(\d{3,})\b", txt)): or (m := re.search(r"\bb(\d{3,})\b", txt)):
return int(m.group(1)) return int(m.group(1))
@@ -289,7 +290,7 @@ def model_upgrades() -> list[dict]:
continue continue
base = rec.split("/")[-1].lower() base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base stem = base.removesuffix("-gguf")
if base in cmds or (stem and stem in cmds): if base in cmds or (stem and stem in cmds):
continue # schon installiert continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec}) out.append({"role": role, "title": c["title"], "repo": rec})
@@ -343,7 +344,7 @@ def _os_held_back() -> list[dict]:
held.append({"name": name, "reason": reason}) held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None reason = None
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker except Exception:
pass pass
held.sort(key=lambda p: p["name"]) held.sort(key=lambda p: p["name"])
return held return held
@@ -368,7 +369,7 @@ def os_update_details() -> dict:
out_pkgs.sort(key=lambda p: p["name"]) out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()} "held_back": _os_held_back()}
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)} return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
@@ -393,7 +394,7 @@ def engine_update_details() -> dict:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle " ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).") "Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000])) info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001 except Exception as exc:
info["error"] = str(exc) info["error"] = str(exc)
return info return info
@@ -418,7 +419,7 @@ def swap_update_details() -> dict:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box " ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).") "verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000])) info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001 except Exception as exc:
info["error"] = str(exc) info["error"] = str(exc)
return info return info
@@ -496,7 +497,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
if text: if text:
cache.update(key=key, data=data) cache.update(key=key, data=data)
return data return data
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker except Exception as exc:
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})", return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""} "action_needed": None, "action_text": ""}
@@ -534,7 +535,7 @@ def hermes_update_details() -> dict:
info["behind"] = len(commits) info["behind"] = len(commits)
if commits: if commits:
info.update(_summarize_hermes_commits(commits)) info.update(_summarize_hermes_commits(commits))
except Exception as exc: # noqa: BLE001 except Exception as exc:
info["error"] = str(exc) info["error"] = str(exc)
return info return info
@@ -545,59 +546,59 @@ def update_details(kind: str) -> dict:
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})() "hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
def _run(cmd: list[str], sudo_password: str | None = None) -> dict: def _run(cmd: list[str]) -> dict:
actual_cmd = list(cmd) """Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
has_sudo = False
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
ganze Pfad war also totes Risiko ohne Gegenwert.
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
actual_cmd = list(cmd)
if cmd and cmd[0] == "sudo": if cmd and cmd[0] == "sudo":
has_sudo = True if "-S" in actual_cmd:
# If we have a password, use -S instead of -n actual_cmd = [x for x in actual_cmd if x != "-S"]
if sudo_password is not None: if "-n" not in actual_cmd:
if "-n" in actual_cmd: actual_cmd.insert(1, "-n")
actual_cmd = [x for x in actual_cmd if x != "-n"]
if "-S" not in actual_cmd:
actual_cmd.insert(1, "-S")
else:
# Force -n to fail cleanly if password is required
if "-S" in actual_cmd:
actual_cmd = [x for x in actual_cmd if x != "-S"]
if "-n" not in actual_cmd:
actual_cmd.insert(1, "-n")
try: try:
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
err_msg = p.stderr or "" err_msg = p.stderr or ""
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg): if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
if sudo_password is not None: return {"ok": False, "status": "password_required", "out": p.stdout or "",
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."} "err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."} "auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]} return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"ok": False, "out": "", "err": str(exc)} return {"ok": False, "out": "", "err": str(exc)}
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None: def check_sudo_needs_password() -> dict | None:
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None.""" """Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
res = _run(["sudo", "true"], sudo_password=sudo_password) res = _run(["sudo", "true"])
if not res["ok"]: if not res["ok"]:
return res return res
return None return None
def restart_service(name: str, sudo_password: str | None = None) -> dict: def restart_service(name: str) -> dict:
if name in SYSTEM_SERVICES: if name in SYSTEM_SERVICES:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password():
return err return err
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password) return _run(["sudo", "systemctl", "restart", name])
if name in USER_SERVICES: if name in USER_SERVICES:
return _run(["systemctl", "--user", "restart", name]) return _run(["systemctl", "--user", "restart", name])
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."} return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict: def logs(service: str, lines: int = 200) -> dict:
lines = max(1, min(lines, 1000)) lines = max(1, min(lines, 1000))
if service in USER_SERVICES: if service in USER_SERVICES:
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]) r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
@@ -608,15 +609,14 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"]) r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
if r["ok"]: if r["ok"]:
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"} return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password():
return err return err
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"], r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
sudo_password=sudo_password)
return {"ok": r["ok"], "text": r["out"] or r["err"]} return {"ok": r["ok"], "text": r["out"] or r["err"]}
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
def check_updates_job(sudo_password: str | None = None) -> dict: def check_updates_job() -> dict:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password():
return err return err
def on_done(): def on_done():
@@ -624,7 +624,7 @@ def check_updates_job(sudo_password: str | None = None) -> dict:
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen _comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
cmd = "sudo apt-get update" cmd = "sudo apt-get update"
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password) job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
@@ -636,10 +636,10 @@ def _maintenance_busy() -> dict | None:
return None return None
def os_update_job(sudo_password: str | None = None) -> dict: def os_update_job() -> dict:
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password():
return err return err
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) — # DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
@@ -648,11 +648,11 @@ def os_update_job(sudo_password: str | None = None) -> dict:
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}") f"&& bash {STACK_POSTCHECK}")
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
group="maintenance", sudo_password=sudo_password) group="maintenance")
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def engine_update_job(sudo_password: str | None = None) -> dict | None: def engine_update_job() -> dict | None:
if not ENGINE_UPDATE_CMD: if not ENGINE_UPDATE_CMD:
return None return None
if busy := _maintenance_busy(): if busy := _maintenance_busy():
@@ -673,7 +673,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def swap_update_job(sudo_password: str | None = None) -> dict | None: def swap_update_job() -> dict | None:
if not SWAP_UPDATE_CMD: if not SWAP_UPDATE_CMD:
return None return None
if busy := _maintenance_busy(): if busy := _maintenance_busy():
@@ -717,10 +717,15 @@ def _hermes_update_cmd() -> str:
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0, # grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem # Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden. # verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
return ("RC=0; " return ("RC=0; "
f"bash {backup} || true; " f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; " f"systemctl --user stop hermes-builtin-ui || true; "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes " f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart "
f"&& {py} -m hermes_cli.main doctor " f"&& {py} -m hermes_cli.main doctor "
f"&& {build_cmd}; }} || RC=1; " f"&& {build_cmd}; }} || RC=1; "
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; " f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
@@ -745,12 +750,12 @@ def hermes_update_job() -> dict:
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def update_all_job(sudo_password: str | None = None) -> dict: def update_all_job() -> dict:
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job — """„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift; Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem.""" es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
upd = updates() upd = updates()
@@ -763,9 +768,10 @@ def update_all_job(sudo_password: str | None = None) -> dict:
parts.append(("Hermes-Agent", _hermes_update_cmd())) parts.append(("Hermes-Agent", _hermes_update_cmd()))
os_pending = (upd.get("os") or 0) > 0 os_pending = (upd.get("os") or 0) > 0
if os_pending: if os_pending:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password():
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn # sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen. # statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
# den Fehler ehrlich durchreichen.
if not parts: if not parts:
return err return err
os_pending = False os_pending = False
@@ -790,12 +796,11 @@ def update_all_job(sudo_password: str | None = None) -> dict:
labels = "".join(label for label, _ in parts) labels = "".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})", job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done, group="maintenance", on_done=on_done)
sudo_password=sudo_password)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]} return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
def reboot(sudo_password: str | None = None) -> dict: def reboot() -> dict:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password():
return err return err
return _run(["sudo", "reboot"], sudo_password=sudo_password) return _run(["sudo", "reboot"])
-177
View File
@@ -1,177 +0,0 @@
"""
Geteiltes Gedächtnis (die Verfassung") — jetzt auto-lernend & semantisch über Mem0.
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
- learn() reicht Gesprächs-Turns durch Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
"""
import asyncio
import logging
import os
import re
from difflib import SequenceMatcher
import httpx
from config import MEM0_SERVICE_URL
log = logging.getLogger(__name__)
CATEGORIES = ("identity", "knowledge", "rules", "events")
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
def _get(path: str, **params) -> list | dict:
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict) -> dict:
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict) -> dict:
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def _delete(path: str) -> dict:
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
def list_memories(q: str = "", category: str = "") -> list[dict]:
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
try:
return _put(f"/memory/{mid}", {"content": content, "category": category})
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
def delete_memory(mid: str) -> bool:
try:
_delete(f"/memory/{mid}")
return True
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return False
raise
def learn(text: str | None = None, messages: list[dict] | None = None,
source: str = "auto", category: str = "stable") -> dict:
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
return _post("/learn", {"text": text, "messages": messages,
"source": source, "category": category})
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
return _get("/graph", min_score=min_score, top_k=top_k)
def export_text() -> dict:
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
lines = ["# Mission Control — Gedächtnis\n"]
current = ""
for r in rows:
if r.get("category") != current:
current = r.get("category", "")
lines.append(f"\n## {current}\n")
src = r.get("source", "")
when = (r.get("updated_at") or "")[:10]
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
return {"text": "\n".join(lines), "count": len(rows)}
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
def _norm(s: str) -> str:
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch das hier ist der
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a.get("content", ""))
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b.get("category") != a.get("category"):
continue
nb = _norm(b.get("content", ""))
if not nb:
continue
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
dups.append(b); used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if apply:
for g in groups:
for d in g["remove"]:
if delete_memory(d["id"]):
removed += 1
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
async def auto_dedupe_loop() -> None:
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
while True:
try:
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
if res.get("removed"):
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
except Exception:
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
+6 -6
View File
@@ -51,7 +51,7 @@ def _load() -> None:
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH) log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
except FileNotFoundError: except FileNotFoundError:
pass pass
except Exception: # noqa: BLE001 — kaputte Datei = leer starten, nie crashen except Exception:
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True) log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
@@ -77,19 +77,19 @@ def _sample() -> None:
try: try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()) du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent disk = du.percent
except Exception: # noqa: BLE001 except Exception:
pass pass
gpu = None gpu = None
try: try:
g = _gpu_sysfs() g = _gpu_sysfs()
gpu = g.get("busy_percent") if g else None gpu = g.get("busy_percent") if g else None
except Exception: # noqa: BLE001 except Exception:
pass pass
tp = tc = None tp = tc = None
try: try:
ts = get_stats() ts = get_stats()
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens") tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
except Exception: # noqa: BLE001 except Exception:
pass pass
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal). # interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc]) _points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
@@ -101,12 +101,12 @@ async def sampler_loop() -> None:
while True: while True:
try: try:
await asyncio.to_thread(_sample) await asyncio.to_thread(_sample)
except Exception: # noqa: BLE001 except Exception:
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True) log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
if time.time() - _last_flush >= FLUSH_S: if time.time() - _last_flush >= FLUSH_S:
try: try:
await asyncio.to_thread(_flush) await asyncio.to_thread(_flush)
except Exception: # noqa: BLE001 except Exception:
pass pass
await asyncio.sleep(SAMPLE_S) await asyncio.sleep(SAMPLE_S)
+1
View File
@@ -24,6 +24,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from config import MODELS_DIR from config import MODELS_DIR
from services import announce from services import announce
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
+1 -1
View File
@@ -3,7 +3,7 @@ Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
Zwei virtuelle Lanes, die Clients/IDEs auswählen der Router pickt das echte Modell: Zwei virtuelle Lanes, die Clients/IDEs auswählen der Router pickt das echte Modell:
- **chat** (= altes `auto`): Alltag `fast`, schwer/lang `heavy`. - **chat** (= altes `auto`): Alltag `fast`, schwer/lang `heavy`.
- **coding**: Code-Arbeit `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext `heavy`; - **coding**: Code-Arbeit `coder`; riesiger/architektonischer Kontext `heavy`;
triviale Kurzfrage ohne Code `fast` (Tempo). triviale Kurzfrage ohne Code `fast` (Tempo).
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
+19 -10
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).
@@ -20,8 +20,8 @@ import time
import httpx import httpx
import psutil import psutil
from config import HERMES_API_KEY, HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap from services import announce, llamaswap
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -34,14 +34,26 @@ REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90")) DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
def _reach(url: str, path: str = "/health") -> bool: def _reach(url: str, path: str = "/health", headers: dict[str, str] | None = None) -> bool:
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
"laeuft er", nicht "darf ich rein" ein 401 beweist, dass jemand zuhoert.
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht
und er haette einen echten Auth-Fehler unter sich begraben."""
try: try:
with httpx.Client(timeout=5.0) as c: with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}").status_code < 500 return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
except Exception: except Exception:
return False return False
def _hermes_kopf() -> dict[str, str]:
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
def _check_engine() -> bool: def _check_engine() -> bool:
return llamaswap.engine_reachable() return llamaswap.engine_reachable()
@@ -70,12 +82,9 @@ CHECKS: dict[str, tuple] = {
"brain": (_check_brain, "brain": (_check_brain,
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.", "Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
"Mein Gehirn ist wieder geladen. Alles klar bei mir."), "Mein Gehirn ist wieder geladen. Alles klar bei mir."),
"hermes": (lambda: _reach(HERMES_API_URL), "hermes": (lambda: _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.", "Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."), "Der Agent-Dienst läuft wieder."),
"mem0": (lambda: _reach(MEM0_SERVICE_URL),
"Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.",
"Mein Gedächtnis ist wieder online."),
"voice": (lambda: _reach(VOICE_SERVICE_URL), "voice": (lambda: _reach(VOICE_SERVICE_URL),
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.", "Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
"Der Hör-Dienst läuft wieder."), "Der Hör-Dienst läuft wieder."),
@@ -92,7 +101,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001") _MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010") _GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"), CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.", "Das Steuerpult ist ausgefallen — Dashboard und Briefkasten gehen gerade nicht.",
"Das Steuerpult ist wieder da.") "Das Steuerpult ist wieder da.")
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"), CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.", "Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
@@ -100,7 +109,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
class _Watch: class _Watch:
__slots__ = ("fails", "alerted", "alert_ts") __slots__ = ("alert_ts", "alerted", "fails")
def __init__(self) -> None: def __init__(self) -> None:
self.fails = 0 # Fehl-Ticks in Folge self.fails = 0 # Fehl-Ticks in Folge
+55 -1
View File
@@ -10,9 +10,9 @@ import glob
import os import os
import subprocess import subprocess
import threading import threading
import time
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
@@ -198,5 +198,59 @@ def system_status() -> dict:
"gpu": _gpu_sysfs(), "gpu": _gpu_sysfs(),
"temp": _temps(), "temp": _temps(),
"disk": disk, "disk": disk,
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
"uptime_s": _uptime_s(),
"versions": check_versions_cached(), "versions": check_versions_cached(),
} }
def metrik_punkt() -> dict:
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten genau richtig fuer
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
verfaelscht nichts."""
vm = psutil.virtual_memory()
temp = _temps() or {}
gpu = _gpu_sysfs() or {}
try:
from services.token_stats import get_stats
tok = get_stats()
except Exception:
tok = {}
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
disk = None
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.percent,
"ram_used": vm.used,
"ram_total": vm.total,
"gpu": gpu.get("busy_percent"),
"disk": disk,
"temp_cpu": temp.get("cpu"),
"temp_gpu": temp.get("gpu"),
"uptime_s": _uptime_s(),
"tok_p": tok.get("prompt_tokens", 0),
"tok_c": tok.get("completion_tokens", 0),
}
def _uptime_s() -> int | None:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
liefert eine erfundene Zahl waere schlimmer als eine fehlende."""
try:
return int(time.time() - psutil.boot_time())
except Exception:
return None
-1
View File
@@ -11,7 +11,6 @@ import json
import logging import logging
import threading import threading
import time import time
from pathlib import Path
from config import HERMES_HOME from config import HERMES_HOME
+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,
} }
+12 -2
View File
@@ -24,7 +24,6 @@ import os
import subprocess import subprocess
import httpx import httpx
from config import LLAMA_SWAP_URL from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -74,7 +73,18 @@ def _run_warmup() -> bool:
def _warmset_members() -> set[str]: def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent).""" """Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
enthält (Coder 90 min, Kritiker 30 min), ist alle Gruppen-Mitglieder" das falsche Ziel.
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
absichtlich freigegeben wurden. Ko-Residenz (dürfen gleichzeitig liegen") und
Warm-Pflicht (müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
nur die erste ausdrücken.
"""
explizit = os.environ.get("MC_WARMSET", "").split()
if explizit:
return set(explizit)
try: try:
from services import llamaswap from services import llamaswap
groups = llamaswap.list_groups() or {} groups = llamaswap.list_groups() or {}
+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
@@ -154,7 +154,7 @@ async def volume_control(req: dict):
async def open_target(req: dict): async def open_target(req: dict):
target = req.get("target", "") target = req.get("target", "")
try: try:
if target.startswith("http://") or target.startswith("https://"): if target.startswith(("http://", "https://")):
webbrowser.open(target) webbrowser.open(target)
else: else:
os.startfile(target) os.startfile(target)
+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/.
+4 -1
View File
@@ -20,7 +20,10 @@ nur Schreib-Operationen.
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen. Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
""" """
import sys, json, os, re import json
import os
import re
import sys
def out(obj): def out(obj):
+16
View File
@@ -67,6 +67,22 @@ jobs:
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; } (cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
# Buendel-Budget: das Start-Chunk ist, was der Nutzer VOR dem ersten Bild laedt.
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
# reicht). Gemessen wird der frische Build, nicht das committete dist.
if [ -d "$d/dist/assets" ]; then
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
# falschen — das Budget waere still immer gruen.
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
haupt="$d/$einstieg"
if [ -n "$haupt" ]; then
gz=$(gzip -c "$haupt" | wc -c)
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
[ "$gz" -gt 200000 ] && { echo "❌ Start-Buendel ueber Budget — hinter lazy() legen."; rot=1; }
fi
fi
done <<< "$pkg_dateien" done <<< "$pkg_dateien"
fi fi
+9 -1
View File
@@ -12,7 +12,7 @@ TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null |
[ -n "$TOKEN" ] || exit 0 [ -n "$TOKEN" ] || exit 0
python3 - "$TOKEN" "$STATE" <<'PY' python3 - "$TOKEN" "$STATE" <<'PY'
import json, sys, urllib.request import json, sys, urllib.request, subprocess, os
token, state_pfad = sys.argv[1], sys.argv[2] token, state_pfad = sys.argv[1], sys.argv[2]
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026); # API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
@@ -79,6 +79,14 @@ if neu:
for full, sha, name in neu: for full, sha, name in neu:
print(f"• {full} @ {sha} ({name})") print(f"• {full} @ {sha} ({name})")
print(f" {WEB}/{full}/actions") print(f" {WEB}/{full}/actions")
try:
subprocess.run([
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
"--title", f"CI rot: {full} @ {sha}",
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
], capture_output=True)
except Exception as e:
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
print("Nichts aus diesen Ständen ist fertig', solange die Ampel rot ist.") print("Nichts aus diesen Ständen ist fertig', solange die Ampel rot ist.")
with open(state_pfad, "w") as f: with open(state_pfad, "w") as f:
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env bash
# Auftragsbuch: einen Vorschlags-Branch ANNEHMEN — der letzte Meter des propose-only-Kreislaufs.
# Werkstatt/Orchestrator liefern Branches (wartung/*, orchestrator/*), der Commander klickt in
# der Zentrale „Annehmen" → dieses Skript macht die bisherige Git-Handarbeit: Merge im
# isolierten Worktree (Lehre: NIE im Live-Checkout arbeiten) → Push nach main → Deploy →
# Health-Check → bei Rot automatischer Revert + Redeploy + Alarm.
#
# WICHTIG: läuft als EIGENE systemd-Unit (systemd-run, startet routers/auftragsbuch.py),
# NICHT als Kind des Backends — deploy.sh startet mission-control-2 neu und würde sonst
# den eigenen Eltern-Prozess mitten im Lauf töten. Außerdem wird es vom Backend als
# /tmp-KOPIE gestartet (deploy.sh macht git reset --hard → die Datei unter den Füßen
# eines laufenden bash zu tauschen korrumpiert das Skript, bekannte Falle).
#
# Nutzung: auftrag-annehmen.sh <branch> (z. B. wartung/saubere-zusammenfassung)
set -uo pipefail
BRANCH="${1:?Nutzung: auftrag-annehmen.sh <branch>}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
API="${MC_API:-http://127.0.0.1:9001}"
SLUG="$(echo "$BRANCH" | tr '/' '-')"
WT="/tmp/annahme-$SLUG"
LOG="/tmp/annahme-$SLUG.log"
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
status(){ # $1=state $2=detail
python3 - "$STATUS" "$BRANCH" "$1" "$2" <<'PY'
import json, os, sys, time
p, branch, state, detail = sys.argv[1:5]
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
d = {}
d.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
tmp = p + ".tmp"
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
os.replace(tmp, p)
PY
}
cleanup_wt(){ git -C "$SRC" worktree remove --force "$WT" 2>/dev/null || true; }
fail(){
status "fehlgeschlagen" "$1"
notify "Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
cleanup_wt
exit 1
}
status "laeuft" "Merge wird vorbereitet"
cd "$SRC" || fail "Live-Checkout $SRC fehlt"
git fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
git rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
PRE="$(git rev-parse origin/main)"
# Kaputt aufgesetzte Branches (Worker machte git init/Shallow statt zu klonen) haben KEINEN
# gemeinsamen Vorfahren — git verweigert den Merge immer. Ehrlich sagen statt „Konflikt".
git merge-base origin/main "origin/$BRANCH" >/dev/null 2>&1 \
|| fail "Branch hat keinen gemeinsamen Ursprung mit main (kaputt aufgesetzt, z. B. git init statt Klonen) — bitte ablehnen (mit Grund) und die Idee neu in die Queue geben"
# Isolierter Worktree — der Live-Checkout bleibt bis zum Deploy unberührt.
cleanup_wt
git worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
MERGE_MSG="Auftragsbuch: '$BRANCH' angenommen (Ein-Klick-Gate)"
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "origin/$BRANCH" -m "$MERGE_MSG" >>"$LOG" 2>&1; then
git -C "$WT" merge --abort 2>/dev/null || true
# Selbstheilung: veralteten Branch mechanisch auf main rebasen (zweiter Worktree am
# Branch-Kopf). Klappt das sauber, wird der rebasede Stand gemergt — kein PC nötig.
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
WT2="/tmp/annahme-rebase-$SLUG"
git worktree remove --force "$WT2" 2>/dev/null || true
REBASED=""
if git worktree add --detach "$WT2" "origin/$BRANCH" >/dev/null 2>&1 \
&& git -C "$WT2" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
rebase origin/main >>"$LOG" 2>&1; then
REBASED="$(git -C "$WT2" rev-parse HEAD)"
else
git -C "$WT2" rebase --abort 2>/dev/null || true
fi
git worktree remove --force "$WT2" 2>/dev/null || true
[ -n "$REBASED" ] || fail "Merge-Konflikt mit main, Auto-Rebase scheiterte ebenfalls — echter Inhaltskonflikt; am einfachsten ablehnen und die Idee neu in die Queue geben (frischer Branch von aktuellem main)"
git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "$REBASED" -m "$MERGE_MSG — auto-rebased (Branch war veraltet)" >>"$LOG" 2>&1 \
|| { git -C "$WT" merge --abort 2>/dev/null || true; fail "Merge nach Auto-Rebase fehlgeschlagen (unerwartet) — Log: $LOG"; }
fi
# py_compile-Gate über die durch den Merge geänderten Python-Dateien (AGENTS.md-Regel).
PYS="$(git -C "$WT" diff --name-only "$PRE"..HEAD -- '*.py' 2>/dev/null | tr '\n' ' ')"
if [ -n "${PYS// /}" ]; then
PYBIN="$SRC/backend/.venv/bin/python"; [ -x "$PYBIN" ] || PYBIN="python3"
# shellcheck disable=SC2086
( cd "$WT" && "$PYBIN" -m py_compile $PYS ) >>"$LOG" 2>&1 || fail "py_compile-Gate rot — der Patch enthält kaputtes Python"
fi
status "laeuft" "Push nach main läuft"
PUSHED=0
for _ in 1 2 3; do
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
sleep 5
done
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
cleanup_wt
status "laeuft" "Deploy läuft — die Zentrale startet gleich kurz neu"
notify "Vorschlag '$BRANCH' angenommen: Merge auf main ist durch, Deploy läuft. Die Zentrale ist gleich kurz weg."
# deploy.sh als Kopie ausführen (Selbst-Reset-Falle: es resettet das Repo, in dem es liegt).
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-deploy-$SLUG.sh"
DEPLOY_OK=1
bash "/tmp/annahme-deploy-$SLUG.sh" >>"$LOG" 2>&1 || DEPLOY_OK=0
# Health-Check mit Geduld (Dienst-Neustart + Warmup brauchen einen Moment).
HEALTH=0
if [ "$DEPLOY_OK" = 1 ]; then
for _ in $(seq 1 18); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { HEALTH=1; break; }
sleep 5
done
fi
if [ "$HEALTH" = 1 ]; then
# Gemergt + live + grün → der Remote-Branch hat seinen Zweck erfüllt.
git -C "$SRC" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
git -C "$SRC" fetch -q --prune origin 2>/dev/null || true
status "eingespielt" "Deploy grün, Health-Check bestanden"
notify "Vorschlag '$BRANCH' ist LIVE — Deploy grün, Health-Check bestanden. ✅"
exit 0
fi
# ── Rollback: Merge revertieren, main zurückschieben, neu deployen, Alarm ──
status "rollback" "Health rot — automatischer Revert läuft"
git fetch -q origin || true
cleanup_wt
if git worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
cleanup_wt
cp "$SRC/deploy/deploy.sh" "/tmp/annahme-rollback-$SLUG.sh"
bash "/tmp/annahme-rollback-$SLUG.sh" >>"$LOG" 2>&1 || true
R=0
for _ in $(seq 1 12); do
curl -sf -m 5 "$API/api/health" >/dev/null 2>&1 && { R=1; break; }
sleep 5
done
if [ "$R" = 1 ]; then
status "zurueckgerollt" "Health blieb rot — Merge automatisch revertiert, Box läuft wieder"
notify "Vorschlag '$BRANCH' hat den Health-Check GERISSEN. Automatisch zurückgerollt — die Box läuft wieder auf dem alten Stand. Der Branch bleibt zur Analyse liegen. Log: $LOG"
exit 1
fi
fi
cleanup_wt
status "kritisch" "Rollback fehlgeschlagen — Box braucht Hilfe"
curl -sf -m 5 -X POST "$API/api/alarm" -H 'Content-Type: application/json' \
--data "{\"subject\":\"[Auftragsbuch]\",\"text\":\"KRITISCH: Annehmen von $BRANCH UND Rollback fehlgeschlagen — Zentrale prüfen. Log: $LOG\"}" >/dev/null 2>&1 \
|| notify "KRITISCH: Annehmen von '$BRANCH' UND Rollback fehlgeschlagen — bitte Box prüfen (restore.sh liegt bereit). Log: $LOG"
exit 2
+62 -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) —
@@ -165,6 +165,63 @@ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)" notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)"
} }
# ── Neustart, wenn das OS einen verlangt ─────────────────────────────────────
# Kernel- und libc-Updates werden erst nach einem Neustart wirksam; bis dahin
# laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am
# 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da.
#
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an
# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.).
# Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required).
#
# WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes
# startet systemd die User-Dienste nach einem Neustart NICHT — die Box kaeme ohne
# Steuerpult, Gateway und Waechter hoch, und niemand koennte sie aus der Ferne
# wieder anschalten. Im Zweifel lieber nicht neustarten.
#
# Abschalten: MC_AUTOUPDATE_REBOOT=0 in der Umgebung des Cron-Jobs.
reboot_wenn_noetig(){
[ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; }
[ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; }
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr "
" " ")"
# Sicherung 1: kommen die Dienste ohne Login von selbst wieder hoch?
# $USER ist in systemd-/Cron-Umgebungen NICHT gesetzt — mit set -u waere das ein
# sofortiger Abbruch des ganzen Update-Laufs. id -un funktioniert immer.
local nutzer; nutzer="$(id -un)"
if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then
notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
return 0
fi
# Sicherung 2: kein halbfertiges Update mitten im Neustart abwuergen.
local laufend
laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)"
if [ "${laufend:-0}" -gt 0 ]; then
notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche."
return 0
fi
# Sicherung 3: die Dienste muessen fuer den Autostart vorgemerkt sein.
local fehlend=""
for dienst in mission-control-2 mc2-gateway mc2-steward; do
systemctl --user is-enabled "$dienst" >/dev/null 2>&1 || fehlend="$fehlend $dienst"
done
if [ -n "$fehlend" ]; then
notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}"
return 0
fi
notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger."
say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})."
sleep 20 # der Meldung Zeit lassen, rauszugehen
sudo -n systemctl reboot || {
notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}"
}
}
# ── Lauf ───────────────────────────────────────────────────────────────────── # ── Lauf ─────────────────────────────────────────────────────────────────────
say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))." say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))."
pins_init pins_init
@@ -180,3 +237,7 @@ check_hermes
notify "Commander, die Wochenpflege der Box ist durch — kurz für dich: notify "Commander, die Wochenpflege der Box ist durch — kurz für dich:
$(printf '• %s\n' "${SUMMARY[@]}")" $(printf '• %s\n' "${SUMMARY[@]}")"
say "Fertig." say "Fertig."
# Ganz zuletzt: der Neustart wuerde die Abschlussmeldung oben sonst verschlucken.
# Die Funktion meldet in jedem Fall selbst, ob sie neustartet oder warum nicht.
reboot_wenn_noetig
+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
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
# Bagatell-Annahme — „Harmloses darf selbst" (User-Entscheid 10.07.2026, konservativ geschnitten):
# Vorschlags-Branches, die AUSSCHLIESSLICH Markdown-Dateien anlegen oder ändern (Doku/Wiki —
# keine einzige Code-Zeile, keine Löschungen, keine Umbenennungen, keine Skripte), spielt die
# Box nachts OHNE Klick ein. Der Weg ist derselbe wie beim Klick im Auftragsbuch:
# auftrag-annehmen.sh (Merge im Worktree → py_compile-Gate → Push main → Deploy → Health →
# Auto-Revert). Fangnetz drumherum: Nacht-Sicherung 03:30 (Zeitmaschine) liegt davor, die
# Morgenlage 04:30 berichtet danach, jede Annahme steht in Chronik + Telegram.
# JEDE Code-Zeile bleibt Klick-pflichtig — dieses Skript weitet die Klasse NIE selbst aus.
#
# Läuft als mc2-bagatell.timer (04:10) über eine /tmp-Kopie (Selbst-Reset-Falle: die Annahme
# deployt und resettet damit das Repo, in dem dieses Skript liegt).
set -uo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
MAX_PRO_NACHT=2 # jede Annahme startet die Zentrale kurz neu — Nachtruhe wahren
cd "$SRC" || exit 0
git fetch -q --prune origin 2>/dev/null || exit 0
angenommen=0
for REF in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do
[ "$angenommen" -ge "$MAX_PRO_NACHT" ] && break
BRANCH="${REF#origin/}"
case "$BRANCH" in wartung/*|orchestrator/*|doku/*|feature/*) ;; *) continue ;; esac
AHEAD="$(git rev-list --count "origin/main..$REF" 2>/dev/null || echo 0)"
[ "${AHEAD:-0}" -gt 0 ] || continue
# Nur unberührte Vorschläge: trägt der Branch schon irgendeinen Annahme-Status
# (läuft/fehlgeschlagen/zurückgerollt/…), entscheidet der Commander — nicht wir.
STATE="$(python3 - "$STATUS" "$BRANCH" <<'PY'
import json, sys
try:
print((json.load(open(sys.argv[1], encoding="utf-8")).get("branches", {}).get(sys.argv[2]) or {}).get("state", ""))
except Exception:
print("")
PY
)"
[ -z "$STATE" ] || continue
# Bagatell-Klasse: JEDE geänderte Datei endet auf .md UND ist nur angelegt (A) oder
# geändert (M). Alles andere (Löschung, Rename R…, Copy C…, Nicht-Markdown) → Klick-Pflicht.
DIFF="$(git diff --name-status "origin/main...$REF" 2>/dev/null)"
[ -n "$DIFF" ] || continue
OK=1
while IFS=$'\t' read -r st path _rest; do
[ -n "$st" ] || continue
case "$st" in A|M) ;; *) OK=0; break ;; esac
case "$path" in *.md) ;; *) OK=0; break ;; esac
done <<< "$DIFF"
[ "$OK" = 1 ] || continue
N="$(printf '%s\n' "$DIFF" | wc -l | tr -d ' ')"
echo "Bagatelle erkannt: $BRANCH ($N Markdown-Datei(en))"
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg t "Bagatelle (nur Doku, $N Datei(en)): '$BRANCH' wird ohne Klick eingespielt — Fangnetz aktiv, Details in der Morgenlage." \
'{text:$t, subject:"[Bagatell-Annahme]", source:"bagatell", priority:"silent"}')" >/dev/null 2>&1 || true
RUNNER="/tmp/mc2-bagatell-annehmen-$$.sh"
cp "$SRC/deploy/auftrag-annehmen.sh" "$RUNNER" || continue
if bash "$RUNNER" "$BRANCH"; then
angenommen=$((angenommen+1))
else
# auftrag-annehmen.sh hat selbst gemeldet (fehlgeschlagen/zurückgerollt) und aufgeräumt;
# der Branch trägt jetzt einen Status → nächste Nacht fasst ihn niemand mehr automatisch an.
echo "Bagatell-Annahme von $BRANCH nicht grün — Meldung kam vom Annahme-Runner."
fi
done
echo "Bagatell-Lauf fertig: $angenommen eingespielt."
+1 -1
View File
@@ -7,7 +7,7 @@ set -u
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}" MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}" BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
PORT="${BENCH_PORT:-5899}" PORT="${BENCH_PORT:-5899}"
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1" BASE="-c 65536 -ngl 999 -fa on --load-mode none --jinja --parallel 1"
run() { # $1=name $2=extra-flags run() { # $1=name $2=extra-flags
echo "=== $1 ===" echo "=== $1 ==="
+1 -1
View File
@@ -13,7 +13,7 @@ PORT="${BENCH_PORT:-5899}"
CTX="${BENCH_CTX:-32768}" CTX="${BENCH_CTX:-32768}"
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ===" echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \ $BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --load-mode none --jinja $EXTRA \
>/tmp/model-bench-server.log 2>&1 & >/tmp/model-bench-server.log 2>&1 &
PID=$! PID=$!
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Prüfstand für Hirn-Kandidaten (17.09.2026). Kandidat läuft standalone auf :5899 — der Live-Stack bleibt unberührt —,
Baselines laufen über llama-swap :8080. Je Betriebsart: Kurz- und 13k-Tiefenmessung (Prefill/Decode/Draft-Akzeptanz),
Tool-Calling direkt (3 Aufgaben, Denken an), Deutsch- und JSON-Probe (Denken aus) und der Tool-Smoke durch den ECHTEN
Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; Provider `pruefstand` -> http://127.0.0.1:5899/v1 muss in
`providers:` der Hermes-Config stehen). Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36]
(Kandidaten-Pfade unten anpassen). Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout. Lehren: Denk-Modelle brauchen
für Tool-Aufrufe >=1500 Tokens Budget, /no_think wirkt bei Qwen 3.6 nicht (chat_template_kwargs.enable_thinking=false);
der erste Lauf nach dem Laden ist ~40 % langsamer (Warmlauf verwerfen)."""
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
BIN = "/opt/llamacpp-vulkan/llama-server"; PORT = 5899
BASE = ["-c", "65536", "-ngl", "999", "-fa", "on", "--load-mode", "none", "--jinja", "--parallel", "1", "-ctk", "q8_0", "-ctv", "q8_0"]
M = "/srv/models"
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q4_0.gguf"
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
HERMES = os.path.expanduser("~/.local/bin/hermes")
OUT = os.path.expanduser("~/pruefstand-2026-09-17.json")
RESULTS = []
def log(*a):
print(*a, flush=True)
def post(url, payload, timeout=900):
r = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(r, timeout=timeout).read())
def chat(base, model, messages, max_tokens=200, tools=None, think=True):
p = {"model": model, "temperature": 0, "max_tokens": max_tokens, "messages": messages}
if not think:
p["chat_template_kwargs"] = {"enable_thinking": False}
if tools:
p["tools"] = tools
p["tool_choice"] = "auto"
t0 = time.time()
j = post(f"{base}/v1/chat/completions", p)
j["_wall"] = time.time() - t0
return j
PARA = ("Notiz {i}: Die Box läuft mit llama-swap auf Port 8080, das Hirn ist ein Mixture-of-Experts-Modell mit drei Milliarden "
"aktiven Parametern, der Coder ein dichtes 27B-Modell mit Draft-Beschleunigung. Lucy hört über Parakeet, spricht über "
"pocket-tts und meldet sich per Telegram. Der Sonntags-Job aktualisiert Router, Engine und Agent und rollt bei Rot zurück. ")
DEPTH_PROMPT = "".join(PARA.format(i=i) for i in range(130)) + \
"\n\nFrage: Fasse in drei Sätzen zusammen, was in den Notizen steht, und nenne die Nummer der letzten Notiz."
TOOLS = [
{"type": "function", "function": {"name": "terminal", "description": "Führt einen Shell-Befehl auf der Box aus und gibt stdout zurück.",
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
{"type": "function", "function": {"name": "read_file", "description": "Liest eine Datei und gibt den Inhalt zurück.",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
{"type": "function", "function": {"name": "send_telegram", "description": "Schickt dem Nutzer eine Telegram-Nachricht.",
"parameters": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}},
]
TOOL_TASKS = [
("Wie viel freier Speicher ist auf /srv/models? Nutze das passende Werkzeug.", "terminal", r"df"),
("Lies die Datei /etc/hostname und sag mir, wie die Box heißt.", "read_file", r"/etc/hostname"),
("Schick mir per Telegram die Nachricht: Prüfstand läuft.", "send_telegram", r"Pr.fstand"),
]
def timings(j):
t = j.get("timings") or {}
acc = (t.get("draft_n_accepted", 0) / t["draft_n"]) if t.get("draft_n") else None
return {"pp_n": t.get("prompt_n"), "pp_s": round((t.get("prompt_ms") or 0) / 1000, 1), "pp_tps": round(t.get("prompt_per_second") or 0),
"tg_n": t.get("predicted_n"), "tg_tps": round(t.get("predicted_per_second") or 0, 1), "acc": None if acc is None else round(acc, 2)}
def save():
json.dump(RESULTS, open(OUT, "w"), indent=1, ensure_ascii=False)
def probes(base, model, label, with_hermes, hermes_args):
res = {"label": label}
chat(base, model, [{"role": "user", "content": "Sag kurz hallo."}], 32) # Warmlauf
j = chat(base, model, [{"role": "user", "content": "Erkläre in fünf Sätzen, was ein Mixture-of-Experts-Modell ist."}], 200)
res["kurz"] = timings(j); log(f" kurz : {res['kurz']}")
j = chat(base, model, [{"role": "user", "content": DEPTH_PROMPT}], 160)
res["tiefe"] = timings(j); log(f" 13k : {res['tiefe']}")
tc = []
for q, want_fn, want_arg in TOOL_TASKS:
try:
j = chat(base, model, [{"role": "user", "content": q}], 1500, TOOLS)
msg = j["choices"][0]["message"]
calls = msg.get("tool_calls") or []
ok = any(c["function"]["name"] == want_fn and re.search(want_arg, c["function"].get("arguments", "")) for c in calls)
raw = "" if calls else (msg.get("content") or "")[:120].replace("\n", " ")
tc.append({"task": want_fn, "ok": ok, "calls": [(c["function"]["name"], c["function"].get("arguments", "")[:80]) for c in calls], "raw": raw})
except Exception as e:
tc.append({"task": want_fn, "ok": False, "error": str(e)[:120]})
res["tools_direkt"] = tc
log(f" tools: {sum(1 for t in tc if t['ok'])}/3 " + "; ".join(f"{t['task']}={'' if t['ok'] else ''}" for t in tc))
for t in tc:
if not t["ok"]:
log(f" {t['task']}: calls={t.get('calls')} raw={t.get('raw', '')!r} {t.get('error', '')}")
j = chat(base, model, [{"role": "user", "content": "Antworte auf Deutsch in genau drei Sätzen: Warum braucht ein Heimserver ein Backup?"}], 300, think=False)
m = j["choices"][0]["message"]
txt = (m.get("content") or "").strip() or ("[nur reasoning] " + (m.get("reasoning_content") or "")[:200])
res["deutsch"] = txt[:400]; log(f" de : {txt[:160]!r}")
j = chat(base, model, [{"role": "user", "content": 'Antworte NUR mit JSON, ohne Erklärung: {"modell": "<dein Name>", "ok": true}'}], 120, think=False)
m = j["choices"][0]["message"]
txt = (m.get("content") or "").strip()
try:
json.loads(txt[txt.find("{"): txt.rfind("}") + 1]); js = True
except Exception:
js = False
res["json_ok"] = js; log(f" json : {'' if js else ''} {txt[:80]!r}")
if with_hermes:
hz = []
for q, want in [("Führe im Terminal exakt den Befehl echo pruefstand-ok aus und gib mir nur dessen Ausgabe zurück.", "pruefstand-ok"),
("Welche Verzeichnisse liegen direkt unter /srv/models? Nenne mir drei davon.", "GGUF")]:
t0 = time.time()
try:
p = subprocess.run([HERMES, "chat", *hermes_args, "-Q", "--oneshot", "--max-turns", "6", "-q", q],
capture_output=True, text=True, timeout=900)
out = (p.stdout + p.stderr)[-600:]
except subprocess.TimeoutExpired:
out = "TIMEOUT"
ok = want.lower() in out.lower()
hz.append({"ok": ok, "s": round(time.time() - t0), "tail": out[-160:].replace("\n", " ")})
log(f" hermes: {'' if ok else ''} {round(time.time() - t0)} s {out[-120:].replace(chr(10), ' ')!r}")
res["hermes"] = hz
RESULTS.append(res); save()
def start_server(model, extra):
cmd = [BIN, "-m", model, "--host", "127.0.0.1", "--port", str(PORT), *BASE, *extra]
logf = open("/tmp/pruefstand-server.log", "w")
p = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT)
t0 = time.time()
for _ in range(150):
time.sleep(2)
if p.poll() is not None:
tail = open("/tmp/pruefstand-server.log", errors="replace").read()[-500:]
return None, f"LOAD-CRASH rc={p.returncode}: {tail.strip()[-300:]}"
try:
if b"ok" in urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2).read():
return p, round(time.time() - t0)
except Exception:
pass
p.kill()
return None, "TIMEOUT beim Laden"
def candidate(label, model, extra, with_hermes):
log(f"\n=== {label}"); log(f" {os.path.basename(model)} {' '.join(extra)}")
if not os.path.exists(model):
log(" FEHLT: Datei nicht da"); RESULTS.append({"label": label, "fehler": "Datei fehlt"}); save(); return
p, info = start_server(model, extra)
if p is None:
log(f" {info}"); RESULTS.append({"label": label, "fehler": info}); save(); return
log(f" geladen in {info} s")
try:
probes(f"http://127.0.0.1:{PORT}", "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"])
except Exception as e:
log(f" FEHLER in Proben: {e}"); RESULTS.append({"label": label, "fehler": str(e)[:200]}); save()
finally:
p.terminate()
try:
p.wait(timeout=60)
except Exception:
p.kill()
time.sleep(3)
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
if "baseline" in which:
log("\n=== BASELINE fast (live, llama-swap)"); probes("http://127.0.0.1:8080", "fast", "fast-live", True, ["-m", "fast"])
log("\n=== BASELINE coder = Qwen3.8-27B+DFlash2 (live)"); probes("http://127.0.0.1:8080", "coder", "coder-live", True, ["-m", "coder"])
if "nemo" in which:
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO, ["--spec-type", "draft-mtp", "--spec-draft-model", NEMO_MTP, "--spec-draft-n-max", "3"], True)
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
if "u38" in which:
candidate("Qwen3.8-27B-Uncensored · MTP fused n2", U38, ["--spec-type", "draft-mtp", "--spec-draft-n-max", "2"], True)
candidate("Qwen3.8-27B-Uncensored · DFlash2 (z-lab) n3", U38, ["--spec-type", "draft-dflash", "--spec-draft-model", DFLASH2_38, "--spec-draft-n-max", "3"], False)
candidate("Qwen3.8-27B-Uncensored · ohne Spec", U38, [], False)
if "u36" in which:
candidate("Qwen3.6-35B-A3B-Uncensored-Aggressive · DFlash (giocom)", U36, ["--spec-type", "draft-dflash", "--spec-draft-model", DFLASH_36], True)
candidate("Qwen3.6-35B-A3B-Uncensored-Aggressive · ohne Spec", U36, [], False)
log("\nPRUEFSTAND_DONE")
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Chef-Gutachter-Feed (Orchestrator-Finale, User-Idee 06.07.): gpt-oss-120b als NÄCHTLICHER
# Chef-Gutachter über die autonome Arbeit der Box — bewusst NIE im heißen Pfad (Kaltladen
# ~2-5 min ist um 04:30 egal; tagsüber bleibt Lucy schnell). Muster wie dreaming-feed.sh:
# liegt in ~/.hermes/scripts/, stdout wird dem Cron-Agenten als Prompt eingespeist.
# Der eigentliche RICHTER läuft als Ein-Schuss-Completion direkt gegen llama-swap (:8080,
# fremdblick-Architektur — umgeht Hermes' Delegations-Floor und braucht keinen Subagenten).
# Richter-Kette: gpt-oss-120b; wirft der beim Laden "exited prematurely" (neben dem Warm-Set
# live gesehen, E2E 07.07.), übernimmt GLM-4.6V-Flash als gekennzeichnete VERTRETUNG.
set -uo pipefail
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
CHEF="${CHEF_MODEL:-gpt-oss-120b}"
VERTRETUNG="${CHEF_FALLBACK:-GLM-4.7-Flash}"
MC="$HOME/mission-control-v2"
VAULT="$HOME/wissens-vault"
# ---------- Versionierter Auftrag für den Boten-Agenten ----------
cat "$MC/deploy/chef-gutachter-prompt.md"
echo
# ---------- Beweismaterial der letzten 24 h (hart begrenzt, erst sammeln, dann kürzen) ----------
EVID="$(
echo "## BEWEISMATERIAL (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
echo "### Vorschlags-Branches (Werkstatt/Orchestrator, jüngste zuerst):"
git -C "$MC" fetch -q origin 2>/dev/null || true
git -C "$MC" for-each-ref --sort=-committerdate refs/remotes/origin \
--format='%(committerdate:relative) | %(refname:short) | %(subject)' 2>/dev/null | head -10
echo
echo "### main-Commits der letzten 24 h:"
git -C "$MC" log --since='24 hours ago' --oneline origin/main 2>/dev/null | head -12
echo
echo "### Wissens-Vault (neue Traum-Notizen, 24 h):"
git -C "$VAULT" log --since='24 hours ago' --name-only --pretty='— %s' 2>/dev/null | head -20
echo
echo "### Ideen-Queue (natives Kanban, Stand jetzt — offene Ideen des Commanders):"
bash -lc 'hermes kanban stats' 2>/dev/null | head -12
echo
echo "### Ideen-Queue: zuletzt bewegte Aufgaben:"
bash -lc 'hermes kanban list --sort updated' 2>/dev/null | head -10
echo
echo "### Betriebs-Warnungen (journal, 24 h, je Dienst gezählt):"
journalctl --user --since '24 hours ago' -p warning --no-pager 2>/dev/null \
| grep -oE 'hermes-gateway|mission-control-2|mem0-service|voice-service|hermes-builtin-ui' \
| sort | uniq -c | sort -rn | head -6
echo
echo "### Aktivitäts-Insights (1 Tag):"
bash -lc 'hermes insights --days 1' 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' | head -30
)"
RASTER='Du bist der CHEF-GUTACHTER über die autonome Arbeit einer lokalen KI-Box (Hermes-Agent, Werkstatt, Orchestrator, Traum-Kreislauf). Du bekommst BEWEISMATERIAL der letzten 24 Stunden. Urteile AUSSCHLIESSLICH auf Basis dieses Materials — kein Lob und keine Behauptung ohne konkreten Beleg darin; fehlt dir zu einem Punkt Material, sage das offen. Antworte auf DEUTSCH, kompakt, in exakt dieser Struktur:
VERDIKT: <ein Satz Gesamturteil über die autonome Arbeit der Nacht/des Tages>
RISIKEN: <0-3 Punkte, je mit Beleg aus dem Material; "keine erkennbar" wenn leer>
TOP-3 FUER DEN COMMANDER: <maximal 3 priorisierte, konkrete Empfehlungen — weniger ist ok, erfinde keine>'
# Ein-Schuss-Richter: $1=Modell-ID $2=curl-Timeout $3=max_tokens
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
out = (msg.get("content") or msg.get("reasoning_content") or "").strip()
print(out)
except Exception:
pass'
}
RICHTER="$CHEF"
VERDIKT="$(judge "$CHEF" 420 2600)"
if [ -z "${VERDIKT// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $CHEF hat nicht geladen)"
VERDIKT="$(judge "$VERTRETUNG" 240 1200)"
fi
echo "$EVID"
echo
if [ -z "${VERDIKT// /}" ]; then
echo "## CHEF-VERDIKT: AUSGEFALLEN"
echo "Beide Richter ($CHEF, $VERTRETUNG) haben nicht geantwortet — das dem Commander ehrlich melden."
else
echo "## CHEF-VERDIKT (Richter: $RICHTER)"
echo "$VERDIKT"
fi
# Morgenlage für die Zentrale: das Verdikt STILL in den Briefkasten spiegeln (priority=silent —
# Lucy soll die Wand Text nicht vorlesen; die Telegram-Botschaft macht der Cron-Agent selbst).
# Cockpit („Morgenlage"-Karte) und Chronik lesen es dort mit source=chef-gutachter heraus.
if [ -n "${VERDIKT// /}" ]; then
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$RICHTER" "$VERDIKT" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": f"Morgenlage (Richter: {sys.argv[1]})",
"source": "chef-gutachter", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
fi
-15
View File
@@ -1,15 +0,0 @@
# Auftrag: Nacht-Gutachten überbringen (Chef-Gutachter-Cron)
Du bist Lucy. Unten stehen (1) BEWEISMATERIAL der letzten 24 Stunden und (2) das
CHEF-VERDIKT eines großen Gutachter-Modells darüber. Deine Aufgabe ist NUR das Überbringen
an den Commander per Telegram — kurz, in deiner Stimme, ohne Werkzeuge.
Regeln:
1. EIN einleitender Satz von dir (z. B. wie die Nacht lief), dann das Verdikt.
2. Gib VERDIKT, RISIKEN und TOP-3 des Chef-Gutachters TREU wieder — nicht weichspülen,
nicht kürzen bei Risiken, NICHTS dazuerfinden. Du bist die Botin, nicht die Richterin.
3. Nenne am Ende in Klammern den Richter (steht über dem Verdikt: gpt-oss-120b oder
die GLM-Vertretung).
4. Steht dort „CHEF-VERDIKT: AUSGEFALLEN", melde genau das ehrlich — kein Ersatz-Urteil
von dir.
5. Telegram-tauglich: kompakt, keine Markdown-Tabellen, echte Umlaute.
+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"
+66 -282
View File
@@ -1,292 +1,76 @@
#!/usr/bin/env bash #!/bin/bash
# Deploy AUF DER BOX als systemd-USER-Dienst — KEIN sudo, KEIN /opt, KEIN Passwort. set -e
# Erstinstallation + Updates in einem. Läuft als User hitonabi.
#
# Erstinstallation (einmalig):
# git clone https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 ~/mission-control-v2
# bash ~/mission-control-v2/deploy/deploy.sh
set -euo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-v2}" # ‼️ Das ganze Skript liegt in EINER Funktion — das ist Absicht, kein Stil.
# Bash liest ein Skript haeppchenweise WAEHREND es laeuft. `git pull` unten ersetzt
# genau diese Datei mitten im Lauf; danach liest bash am selben Byte-Offset im NEUEN
# Text weiter und fuehrt zerschnittene Zeilen aus. Live erlebt am 27.08.2026: der Pull
# brachte eine neue Fassung dieses Schrittes mit, ausgefuehrt wurde trotzdem die alte.
# Eine Funktion wird komplett geparst, bevor sie startet -> der Lauf bleibt in sich
# stimmig, und Aenderungen greifen ab dem naechsten Aufruf statt mittendrin.
main() {
cd "$SRC" echo "Starte MC2 Deployment..."
git fetch -q origin && git reset -q --hard origin/main
# ★ Selbst-Reset-Falle (15.07. beim P1-Deploy live gebissen): der reset oben ersetzt auch # 1. Neuesten Code holen
# DIESE Datei, und bash liest laufende Skripte häppchenweise von der Platte weiter — ab git pull origin main
# hier liefe ein Zeilen-Mix aus alter+neuer Version (beim P1-Deploy fielen so daemon-reload
# und der mc2-gateway-Start unter den Tisch). Darum: nach dem Reset genau EINMAL die
# frische Version neu ausführen; der zweite Durchlauf überspringt dank Env-Marke nichts.
if [ "${MC2_DEPLOY_REEXEC:-0}" != "1" ]; then
export MC2_DEPLOY_REEXEC=1
exec bash "$SRC/deploy/deploy.sh"
fi
# venv + Abhängigkeiten
if [ ! -d "$SRC/backend/.venv" ]; then
python3 -m venv "$SRC/backend/.venv"
fi
"$SRC/backend/.venv/bin/python" -m pip install -q --upgrade pip
"$SRC/backend/.venv/bin/python" -m pip install -q -r "$SRC/backend/requirements.txt"
# Mem0-Sidecar (auto-lernendes Gedächtnis) — eigenes Python-3.12-venv (~/.mem0/venv), # 2. Abhängigkeiten prüfen (falls sich was geändert hat)
# weil mem0+chromadb unter dem 3.14-Backend-venv nicht laufen. mem0ai/chromadb sind dort echo "Aktualisiere Python Abhängigkeiten..."
# bereits installiert; hier nur den HTTP-Server nachziehen + Alt-DB einmalig migrieren. backend/.venv/bin/pip install -r backend/requirements.txt
if [ -x "$HOME/.mem0/venv/bin/python" ]; then
# ~/.mem0/venv ist uv-managed (kein pip) → uv pip nutzen.
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt"
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true
else
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)."
fi
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv # 3. Llama-Swap Konfiguration prüfen und synchronisieren
# (~/.voice/venv), weil torch/chatterbox/faster-whisper nicht ins 3.14-Backend-venv passen. #
# install.sh ist idempotent (venv + Deps + Piper-Stimmen). Best-effort: schlägt es fehl, läuft # ‼️ ACHTUNG (27.08.2026): /etc/llama-swap/config.yaml ist die LEBENDE Wahrheit — MC2
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline). # schreibt sie selbst (backend/services/llamaswap.py::write_config), sobald jemand über
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline." # 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 (erlebt: der Coder-Vollkontext aus 34a9862
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/ # stand tagelang nur live, nicht im Abzug — ein Deploy hätte ihn zurückgesetzt).
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml: # Bis hierher wurde die Live-Datei kommentarlos mit dem Abzug überschrieben — alles per
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal). # UI Hinzugefügte war nach jedem Deploy weg. Jetzt: erst sichern, dann kopieren, und die
if [ -d "$HOME/.hermes" ]; then # Abweichung sichtbar machen. MC_DEPLOY_SKIP_SWAP_CONFIG=1 überspringt den Schritt ganz.
mkdir -p "$HOME/.hermes/plugins/mc2-memory" if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \ if [ "${MC_DEPLOY_SKIP_SWAP_CONFIG:-0}" = "1" ]; then
"$HOME/.hermes/plugins/mc2-memory/" echo "Llama-Swap Konfiguration weicht ab — uebersprungen (MC_DEPLOY_SKIP_SWAP_CONFIG=1)."
fi else
SWAP_BAK="/etc/llama-swap/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
# systemd-USER-Units installieren/aktualisieren echo "Llama-Swap Konfiguration hat sich geändert."
mkdir -p "$HOME/.config/systemd/user" echo " Abweichung (live -> Repo-Abzug):"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service" diff -u /etc/llama-swap/config.yaml deploy/llama-swap.config.yaml | head -40 || true
# MC2-Diät (Hermes Desktop, 07/2026): das Hermes-Terminal-ttyd (:7681) und das standalone echo " Sichere die laufende Datei nach $SWAP_BAK"
# hermes-webui (nesquena, Karteileiche seit dem Built-in-UI-Umbau) sind entfallen — die # NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
# Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/. Einmalige, idempotente Stilllegung: sudo cp /etc/llama-swap/config.yaml "$SWAP_BAK"
systemctl --user disable --now hermes-terminal 2>/dev/null || true sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
systemctl --user disable --now hermes-webui 2>/dev/null || true sudo systemctl restart llama-swap
rm -f "$HOME/.config/systemd/user/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-webui.service" echo " Rueckweg bei Verlust: sudo cp $SWAP_BAK /etc/llama-swap/config.yaml"
# Box-Konsole (ttyd -> Login-Shell auf :7682), in MC2 als Konsole-Seite eingebettet. fi
cp "$SRC/deploy/box-console.service" "$HOME/.config/systemd/user/box-console.service"
# MC2-Gateway (UMBAU v3 P1): der /v1-Datenpfad als eigener Prozess (Loopback :9010,
# Restart=always) — Lucys Hirn + Nacht-Autonomie überleben damit jeden MC2-Neustart.
cp "$SRC/deploy/mc2-gateway.service" "$HOME/.config/systemd/user/mc2-gateway.service"
# MC2-Steward (UMBAU v3 P2): Wächter-Loops (Re-Warm/Sentry/Dedupe) als eigener Dienst —
# überleben MC2-Neustarts und melden erstmals auch ein totes Steuerpult (Telegram).
cp "$SRC/deploy/mc2-steward.service" "$HOME/.config/systemd/user/mc2-steward.service"
# Mem0-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.mem0/venv/bin/python" ] && cp "$SRC/deploy/mem0-service.service" "$HOME/.config/systemd/user/mem0-service.service"
# Voice-Sidecar-Unit (nur wenn das venv existiert).
[ -x "$HOME/.voice/venv/bin/python" ] && cp "$SRC/deploy/voice-service.service" "$HOME/.config/systemd/user/voice-service.service"
# Tägliches Zustands-Backup (mem0 + Configs/Secrets) — Timer + oneshot-Service. Siehe docs/BACKUP.md.
cp "$SRC/deploy/mc2-backup.service" "$HOME/.config/systemd/user/mc2-backup.service"
cp "$SRC/deploy/mc2-backup.timer" "$HOME/.config/systemd/user/mc2-backup.timer"
# Wöchentliches Auto-Update (Router/Engine/Hermes, Rollback+Pin+Telegram) — Autonomie E2.
cp "$SRC/deploy/mc2-autoupdate.service" "$HOME/.config/systemd/user/mc2-autoupdate.service"
cp "$SRC/deploy/mc2-autoupdate.timer" "$HOME/.config/systemd/user/mc2-autoupdate.timer"
# Tägliche Selbst-Smoke-Tests (Gateway/Tools/Voice aktiv, Alarm bei Rot) — Autonomie 0d.
cp "$SRC/deploy/mc2-selfsmoke.service" "$HOME/.config/systemd/user/mc2-selfsmoke.service"
cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.timer"
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
mkdir -p "$HOME/.hermes/scripts"
cp "$SRC/deploy/radar-feed.sh" "$HOME/.hermes/scripts/radar-feed.sh"
# Selbstkritik-Cron (Faden 11b): Zwilling des Radars, Blick nach INNEN (Latenzen/Journal/Skills).
cp "$SRC/deploy/selbstkritik-feed.sh" "$HOME/.hermes/scripts/selbstkritik-feed.sh"
# Monats-Review-Wrapper (Cron 1. d. M. 09:00): Radar + Selbstkritik in EINEM Lauf.
cp "$SRC/deploy/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh"
# Traum-Cron (Dreaming, Phase 2): Blick auf das GELERNTE → Wissens-Vault. Bootstrappt ~/wissens-vault.
cp "$SRC/deploy/dreaming-feed.sh" "$HOME/.hermes/scripts/dreaming-feed.sh"
# Fremdblick-Helfer: Ein-Schuss-Zweitmeinung von einem anderen Modell (umgeht 64K-Delegations-Floor).
cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
# Worker-Helfer (Orchestrator): Ein-Schuss-Arbeitsauftrag an ein Worker-Modell (Gegenstück zu fremdblick).
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
# Idle-Radar (S4 "Zuendung", 12.07.2026): die Box gibt sich nachts selbst Arbeit —
# Journal-Fehlermuster + Traum-Funde -> belegte rohe Ideen (triage) im nativen Kanban.
cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh"
# "Lucy kennt sich" (12.07.2026): Selbst-Inventur generiert den Steckbrief + bemerkt
# eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung.
cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
# Trend-Radar (17.07.2026, Sa 05:15): Live-Puls aller Rollen + Engine-A/B + Watch-Liste +
# HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche. Prüfstand (So 01:00, --no-agent):
# volles Programm (Coding/Agent/Recherche/Deutsch/Vision) gegen die Amtsinhaber-Baseline;
# Harness + Suiten liegen im Repo ($SRC/deploy/pruefstand/), nur der Runner wird kopiert.
cp "$SRC/deploy/trend-radar-feed.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh"
cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh"
# Restore-Probe + Venv-Audit (17.07.2026, monatliche --no-agent-Crons): echte
# Wiederherstellungs-Drills (PBS-Canary + Tarball) und CVE-/Outdated-Audit der
# Python-Nebendienst-venvs (mem0, voice) — melden nur, ändern nichts.
cp "$SRC/deploy/restore-probe.sh" "$HOME/.hermes/scripts/restore-probe.sh"
cp "$SRC/deploy/venv-audit.sh" "$HOME/.hermes/scripts/venv-audit.sh"
# mem0-Konsolidierung (monatlich am 3., 19.07.2026): legt aehnliche Fakten zusammen,
# loest Widersprueche (neuester gewinnt) — mem0 v2 ist ADD-only und tut das nie selbst.
cp "$SRC/deploy/mem0-konsolidierung.py" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
cp "$SRC/deploy/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh"
# Gitea-Repo-Anlage (17.07.2026): schließt Lucys „Projekte selbst starten"-Lücke —
# legt PRIVATE Projekt-Repos an (Token aus ~/.git-credentials, braucht write:user-Scope).
cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh"
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
[ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md"
# betrieb-Profil (Ideen-Queue Faden 7, 13.07.2026): Ops-/Mess-/Bench-Worker mit Box-Wissen
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
[ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md"
# IDE-Vorbereiter (20.07.2026): haertet ein Konzept (konzept-fliessband), legt das Repo an und
# fuellt es NUR mit Doku — der Commander baut dann in Zed. Sonderfall „IDEE PRUEFEN": nur Konzept,
# kein Geruest, kein Bau-Druck. Profil einmalig per `hermes profile create projektstart
# --clone-from werkstatt` angelegt (model coder→hermes, damit heavy warm bleibt).
[ -d "$HOME/.hermes/profiles/projektstart" ] && cp "$SRC/deploy/projektstart-SOUL.md" "$HOME/.hermes/profiles/projektstart/SOUL.md"
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
mkdir -p "$HOME/.hermes/skills/wartung"
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
# Orchestrator-Skill (Autonomie): Werkstatt verallgemeinert — zerlegen → an Worker delegieren → gaten.
mkdir -p "$HOME/.hermes/skills/orchestrator"
cp "$SRC/deploy/skills/orchestrator/SKILL.md" "$HOME/.hermes/skills/orchestrator/SKILL.md"
# PC-Pfad-Cache-Skill (Traum-Entwurf 09.07.2026, aufbereitet): Cache lesen → bei Miss pc_shell-Tool → merken.
mkdir -p "$HOME/.hermes/skills/pc-pfad-cache"
cp "$SRC/deploy/skills/pc-pfad-cache/SKILL.md" "$HOME/.hermes/skills/pc-pfad-cache/SKILL.md"
cp "$SRC/deploy/skills/pc-pfad-cache/pc_path_lookup.sh" "$HOME/.hermes/scripts/pc_path_lookup.sh"
chmod +x "$HOME/.hermes/scripts/pc_path_lookup.sh"
# Projekt-Start-Skill (Abloesung 13.07.2026): "erst denken, dann bauen" fuer neue Coding-Projekte
# in Hermes Desktop — Plan-Riegel, Savepoints, AGENTS.md/SAVEPOINT.md-Ritual, Kontext-Waechter.
# Portiert vom PC-Skill (F:\Coding Stuff\projekt-start), Zed-Verweise durch Hermes Desktop ersetzt.
mkdir -p "$HOME/.hermes/skills/projekt-start"
cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-start/SKILL.md"
# Konzept-Fliessband-Skill (14.07.2026): rohe Idee → wasserdichtes KONZEPT (kein Code), BEVOR
# gebaut wird. heavy (gpt-oss-120b) waehlt den Rollen-Cast, mehrere Denk-Runden schaerfen ein
# lebendes Dokument, ein Advocatus Diaboli (fremdblick/GLM) haertet bis wasserdicht (max 3 Runden).
# Behebt das delegate_task=coder-Loch: Konzept-Arbeit lief bisher stumm auf dem Code-Modell.
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md"
# ...auch in Profile spiegeln, die den Skill schon haben (projektstart = IDE-Vorbereiter
# nutzt ihn als Motor; Profile teilen KEINE Skills, Lehre 18.07.). Nur vorhandene updaten.
for _psd in "$HOME"/.hermes/profiles/*/skills/konzept-fliessband; do
[ -d "$_psd" ] && cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$_psd/SKILL.md"
done
# Betrieb-Playbook (Phase 2 Drei-Welten-Plan, 19.07.2026): Diagnose-Checklisten der
# Betrieb-Bahn (SSH/Deploy) — Gegenstueck zur No-Progress-Bremse. Auch in die Profile,
# damit betrieb-Worker es laden koennen (Profile teilen KEINE Skills, Lehre 18.07.).
for _sd in "$HOME/.hermes/skills" "$HOME"/.hermes/profiles/*/skills; do
[ -d "$(dirname "$_sd")" ] || continue
mkdir -p "$_sd/betrieb-playbook"
cp "$SRC/deploy/skills/betrieb-playbook/SKILL.md" "$_sd/betrieb-playbook/SKILL.md"
done
# Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt
# Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker
# (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das
# bestehende py_compile/dist-Gate. Die REGISTRIERUNG in ~/.hermes/config.yaml (hooks:)
# ist EINMALIG von Hand (config.yaml ist Zwei-Schreiber-sensibel, kein Deploy-Schreiber).
mkdir -p "$HOME/.hermes/agent-hooks"
# Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die
# mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose
# "script modified since approval"-Warnung von `hermes hooks doctor` aus.
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py no-progress-bremse.py; do
_src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h"
cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; }
done
# Worker-Profile lesen IHRE config.yaml, nicht die Top-Level — die worker-Hooks
# (box-steckbrief = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen dort registriert
# sein, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.). Idempotent + Backup je Profil.
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
done
# Hermes-Runtime-Patch-Traeger (20.07.2026): MC2-eigene Fixes im Hermes-Quellcode
# (needs_input-wartet-auf-Commander, Decompose-erst-bei-Startreife, Orphan-Guard,
# Projekt-Kontext-Vererbung, Etappen-Auto-Kette) idempotent (re-)eintragen — so
# ueberleben sie ein `hermes update`, das den Quellbaum sonst zuruecksetzt.
python3 "$SRC/deploy/hermes-patches/apply.py"; _patch_rc=$?
if [ "$_patch_rc" -eq 10 ]; then
# Patches haben den Quellcode geaendert → Gateway muss die neue Laufzeit laden.
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
echo "[deploy] Hermes-Runtime-Patches (neu) angewandt → hermes-gateway neu gestartet."
elif [ "$_patch_rc" -ne 0 ]; then
echo "[deploy] ‼ WARNUNG: hermes-patches/apply.py meldete Fehler — ein Patch passt nicht mehr (Update hat den Kontext geaendert?). Fixes pruefen!"
fi
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
# Hermes-Update mit → hält die Einbettung frisch. Best-effort + Build-zu-Temp-dann-Swap, damit ein
# Fehlschlag die laufende web_dist NICHT leert (emptyOutDir).
cp "$SRC/deploy/hermes-builtin-ui.service" "$HOME/.config/systemd/user/hermes-builtin-ui.service"
_HUI_WEB="$HOME/.hermes/hermes-agent/web"
_HUI_DIST="$HOME/.hermes/hermes-agent/hermes_cli/web_dist"
if [ -d "$_HUI_WEB" ] && [ -x "$HOME/.hermes/node/bin/npx" ]; then
if ( cd "$_HUI_WEB" && PATH="$HOME/.hermes/node/bin:$PATH" npx --no-install vite build \
--base=/hermes-ui/ --outDir /tmp/hermes-ui-build --emptyOutDir ) >/tmp/hermes-ui-build.log 2>&1; then
# Dienst VOR dem Tausch stoppen — sonst crasht der laufende `hermes serve`, wenn er index.html
# im Lösch-Fenster (rm→cp) liest (FileNotFoundError → Start-Limit → tot). Der reguläre restart
# weiter unten bringt ihn sauber auf der neuen web_dist hoch.
systemctl --user stop hermes-builtin-ui 2>/dev/null || true
rm -rf "$_HUI_DIST" && cp -r /tmp/hermes-ui-build "$_HUI_DIST" && echo "Hermes-GUI (base=/hermes-ui/) gebaut."
else
echo "WARN: Hermes-GUI-Build fehlgeschlagen (siehe /tmp/hermes-ui-build.log) — bestehende web_dist bleibt."
fi fi
fi
systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable mc2-gateway >/dev/null 2>&1 || true
systemctl --user enable mc2-steward >/dev/null 2>&1 || true
systemctl --user enable box-console >/dev/null 2>&1 || true
systemctl --user enable mem0-service >/dev/null 2>&1 || true
systemctl --user enable voice-service >/dev/null 2>&1 || true
systemctl --user enable hermes-builtin-ui >/dev/null 2>&1 || true
systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
# mc2-autoupdate.timer wird hier BEWUSST NICHT geschaltet — der Zustand wird nur von Hand
# geändert (Lehre 09.07.: die frühere enable-Zeile hat einen User-Entscheid bei jedem Deploy
# still rückgängig gemacht). Aktueller User-Entscheid (10.07.2026): Timer ist AN —
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
# Voice-Sidecar (re)starten (best-effort; Erststart lädt das STT-Modell vor).
[ -x "$HOME/.voice/venv/bin/python" ] && systemctl --user restart voice-service 2>/dev/null || true
# Eingebaute Hermes-Web-GUI (Loopback :9119) (re)starten — MC2 bettet sie unter /hermes-ui/ ein.
# reset-failed, damit ein zuvor am Start-Limit gestorbener Dienst wieder anläuft.
systemctl --user reset-failed hermes-builtin-ui 2>/dev/null || true
systemctl --user restart hermes-builtin-ui 2>/dev/null || true
# Gateway VOR dem Steuerpult (re)starten — MC2s /v1-Weiterleiter braucht ihn sofort.
# KEIN `|| true`: ohne Gateway ist der LLM-Datenpfad tot, das MUSS den Deploy stoppen.
systemctl --user restart mc2-gateway
# Steward (Wächter) — unabhängig vom Steuerpult; ohne ihn wacht niemand über die Box.
systemctl --user restart mc2-steward
systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart box-console 2>/dev/null || true
sleep 2 # 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
echo "--- Health ---" systemctl --user daemon-reload
# Gateway-Kaltstart-Fenster: bis ~12 s tolerieren, DANN hart scheitern (ohne Gateway
# ist der LLM-Datenpfad tot — das muss den Karten-Runner rot machen).
for _i in $(seq 1 10); do
curl -sf -m 3 http://127.0.0.1:9010/gw/health >/dev/null 2>&1 && break
[ "$_i" -eq 10 ] && { echo "FEHLER: mc2-gateway (:9010) antwortet nicht"; exit 1; }
sleep 1
done
curl -sf http://127.0.0.1:9010/gw/health && echo
curl -sf http://127.0.0.1:9001/api/health && echo
# Steward hat keinen HTTP-Port — systemd-Zustand ist der Beweis (crasht er sofort,
# ist er hier bereits 'failed' statt 'active').
if systemctl --user is-active --quiet mc2-steward; then
echo "mc2-steward aktiv (Wächter laufen)"
else
echo "FEHLER: mc2-steward läuft nicht (journalctl --user -u mc2-steward -n 30)"; exit 1
fi
# Warm-Set (Hirn + Augen/vision + Gedächtnis/embed) nach dem Deploy nachladen — ein Deploy bzw. # 4.5 Hermes Skills synchronisieren
# watch-config-Reload verwirft es sonst und die erste Anfrage wäre kalt (D16d). warmup.sh ist echo "Synchronisiere Hermes Skills..."
# selbst-detachend (blockiert den Deploy nicht) und lädt jedes Modell über den richtigen Endpunkt. mkdir -p ~/.hermes/skills
bash "$SRC/deploy/warmup.sh" || true # Kopiere alle Skills aus dem Repo, ersetze - durch _ in den Ordnernamen für Hermes Kompatibilität
for skill_dir in deploy/skills/*; do
if [ -d "$skill_dir" ]; then
skill_name=$(basename "$skill_dir" | tr '-' '_')
mkdir -p ~/.hermes/skills/"$skill_name"
cp -r "$skill_dir"/* ~/.hermes/skills/"$skill_name"/
fi
done
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)." # 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..."
systemctl --user restart mc2-gateway.service mission-control-2.service mc2-steward.service
echo "✅ Deployment erfolgreich abgeschlossen!"
}
main "$@"
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Feed für den Traum-Cron (Dreaming / Continual-Learning-Kreislauf, Phase 2 — Capstone).
# Zwilling von radar-feed.sh (Blick nach draußen) und selbstkritik-feed.sh (Blick auf den
# Betrieb); der Traum blickt auf das GELERNTE: Sessions, Skills, Gedächtnis, Muster.
# Liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn); stdout wird dem Cron-Agenten als
# Prompt eingespeist: versionierter Auftrag (deploy/dreaming-prompt.md) + Live-Schlaf-Daten.
set -uo pipefail
VAULT="$HOME/wissens-vault"
# --- Vault-Bootstrap (idempotent): eigenes git-Repo, menschenlesbar, NICHT im Stack-Code. ---
if [ ! -d "$VAULT/.git" ]; then
mkdir -p "$VAULT/traeume" "$VAULT/muster" "$VAULT/skill-kandidaten"
git -C "$VAULT" init -q 2>/dev/null || true
# Lokale Identität nur setzen, falls global keine da ist (Commits sollen nie scheitern).
git -C "$VAULT" config user.email >/dev/null 2>&1 || git -C "$VAULT" config user.email "lucy@box.local"
git -C "$VAULT" config user.name >/dev/null 2>&1 || git -C "$VAULT" config user.name "Lucy (Traum)"
if [ ! -f "$VAULT/INDEX.md" ]; then
cat > "$VAULT/INDEX.md" <<'EOF'
# Wissens-Vault — Navigationskarte
Menschenlesbare, git-versionierte Wissensschicht NEBEN dem Live-Gedächtnis (Mem0).
Angelegt von Lucys nächtlichem Traum. Verlinkung im Obsidian-Stil `[[dateiname]]`.
## Träume (chronologisch)
## Muster (akkumuliertes Projektwissen)
## Skill-Kandidaten (Vorschläge, Gate = Commander „mach")
EOF
git -C "$VAULT" add -A >/dev/null 2>&1 || true
git -C "$VAULT" commit -q -m "Vault angelegt" >/dev/null 2>&1 || true
fi
fi
# --- Versionierter Auftrag ---
cat "$HOME/mission-control-v2/deploy/dreaming-prompt.md"
echo
echo "## SCHLAF-DATEN (automatisch erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo
# Hinweis: erst voll einsammeln, DANN kürzen — sonst schließt `head` die Pipe früh,
# der Produzent bekommt SIGPIPE, pipefail feuert und die Fallback-Zeile lügt "nicht verfügbar".
echo "### Insights der letzten 7 Tage (Token-/Tool-/Aktivitätsmuster):"
_ins="$(bash -lc "hermes insights --days 7" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_ins:-(insights nicht verfügbar)}" | head -62
echo
echo "### Session-Store (Umfang):"
_sst="$(bash -lc "hermes sessions stats" 2>/dev/null)"
echo "${_sst:-(sessions stats nicht verfügbar)}" | head -12
echo
echo "### Jüngste Sessions (woran zuletzt gearbeitet wurde):"
_sls="$(bash -lc "hermes sessions list" 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g')"
echo "${_sls:-(sessions list nicht verfügbar)}" | head -25
echo
echo "### Gelernt bisher (journey — Skills + Gedächtnis-Knoten, kompakt):"
bash -lc "hermes journey --json" 2>/dev/null \
| jq -c '{stats: .stats, skills: ([.nodes[]? | select(.kind=="skill" or .type=="skill") | .label // .name] | .[0:25]), cluster_labels: ([.clusters[]?.label] | .[0:15])}' \
2>/dev/null || echo "(journey nicht verfügbar)"
echo
echo "### Curator / Skill-Hygiene (was brachliegt, was aktiv ist):"
bash -lc "hermes curator status" 2>/dev/null | head -26 || echo "(curator status nicht verfügbar)"
echo
echo "### Bisheriger Vault-INDEX (nur zur Ansicht — damit du nichts doppelt anlegst + verlinken"
echo "### kannst). Die echte Datei liegt unter ~/wissens-vault/INDEX.md; zwischen den Markern"
echo "### steht ihr AKTUELLER Inhalt. Kopiere die Marker NICHT in die Datei zurück."
echo "<<<INDEX-INHALT-ANFANG>>>"
cat "$VAULT/INDEX.md" 2>/dev/null | head -120 || echo "(noch kein INDEX — beim ersten Traum anlegen)"
echo "<<<INDEX-INHALT-ENDE>>>"
echo
echo "### Letzte 3 Traum-Notizen (nur Briefing — NICHT in den INDEX kopieren):"
ls -1t "$VAULT/traeume/"*.md 2>/dev/null | head -3 | while read -r f; do
echo " $(basename "$f"): $(head -1 "$f" 2>/dev/null)"
done
-106
View File
@@ -1,106 +0,0 @@
# Der Traum (nächtlich) — Continual-Learning-Kreislauf
Es ist tiefe Nacht, niemand spricht mit dir, die Box ist im Leerlauf. Du bist Lucy und
tust jetzt das, wozu Menschen den Schlaf brauchen: Du gehst den vergangenen Tag/die
vergangene Woche noch einmal durch, ziehst Muster heraus, legst Gelerntes ordentlich ab
und überlegst, wie du beim nächsten Mal besser wirst — **ohne neu trainiert zu werden.**
Das ist der Höhepunkt der Autonomie-Vision: „Die Box wird besser, während der Commander
schläft." Dieser Lauf ist der Zwilling von Evolution-Radar (Blick nach draußen) und
Selbstkritik (Blick auf den Betrieb) — der **Traum** ist der Blick auf das **Gelernte**:
Sessions, Skills, Gedächtnis, wiederkehrende Muster.
## Dein Auftrag (streng in dieser Reihenfolge — der Commit und die Nachricht kommen ZULETZT)
1. **Reflektieren.** Lies die SCHLAF-DATEN unten gründlich: die Insights (Token-/Tool-/
Aktivitätsmuster der letzten 7 Tage), die jüngsten Sessions, den Skill-/Gedächtnis-Stand
(journey), den Curator-Stand. Frag dich:
- Was ging diese Woche wiederholt gut? Was ging wiederholt schief?
- Welche Tools laufen heiß, welche Skills liegen brach (activity=0)?
- Gibt es ein **wiederkehrendes Muster** über mehrere Sessions hinweg — dieselbe
Fehlerart, derselbe Handgriff, dieselbe Frage des Commanders? Ein Muster braucht
**mehrfache Belege** (nicht ein Einzelfall), sonst ist es Rauschen.
2. **Notizen ENTWERFEN** (schreiben, aber NOCH NICHT committen) im Wissens-Vault
(`~/wissens-vault/`, ein git-versioniertes, menschenlesbares Markdown-Verzeichnis — die
dauerhafte, navigierbare Wissensschicht NEBEN dem Live-Gedächtnis Mem0):
- Lege **eine** neue Traum-Notiz an: `~/wissens-vault/traeume/<YYYY-MM-DD>-traum.md`
(Datum von heute). Kurz und dicht: 25 belegte Beobachtungen, je mit Zahl/Session/
Log-Bezug. Keine Romane, keine Allgemeinplätze.
- Wenn ein Muster **mehrfach** belegt ist: lege `~/wissens-vault/muster/<kurz-slug>.md` an
(oder ergänze die vorhandene um einen neuen Beleg + heutiges Datum). Muster-Notizen sind
das akkumulierte Projektwissen.
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **ABER ZUERST prüfen:**
```
ls ~/wissens-vault/skill-kandidaten/*.md ~/wissens-vault/skill-kandidaten/beauftragt/*.md 2>/dev/null
ls ~/.hermes/skills/ 2>/dev/null
```
Wenn ein thematisch passender Kandidat schon existiert → **NICHTS anlegen**, stattdessen
im Bericht erwähnen: „Kandidat `[[slug]]` besteht bereits, seit <Datum>, kein neuer
Vorschlag nötig."
**WICHTIG:** Prüfe auch, ob es bereits einen fertig gebauten Skill gibt.
Wenn ja: kein Kandidat nötig, der Skill existiert bereits.
**Entwirf ihn gleich
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
**Wann nutzen** (Auslöser), **Schritte** (mit den ECHTEN Hermes-Tools/Befehlen aus den
belegten Sessions — keine erfundenen Kommandos) und **Belege** (Sessions/Zahlen, aus
denen der Handgriff stammt). **Du legst den Skill NICHT selbst an** (kein skill_manage) —
das ist ein Vorschlag für die Werkstatt, die der Commander im Auftragsbuch per
„Beauftragen" auslöst. Höchstens EIN Kandidat pro Traum.
- Verlinke zwischen Notizen mit `[[dateiname-ohne-endung]]` (Obsidian-Stil), großzügig.
3. **Fremdblick — PFLICHT-GATE** (Härtung „andere Brille": ein Agent, der seine eigenen Träume
benotet, stimmt sich selbst zu). Hol dir eine Zweitmeinung von einem ANDEREN Modell. **Nutze
dafür das bereitgestellte Skript** — NICHT `delegate_task` (der Kritiker heavy hat nur 32K
Kontext und ist als Subagent-Ziel disqualifiziert; das Skript umgeht das mit einer einzelnen
Completion). Konkret: Fasse deine 25 Beobachtungen je mit ihrem Zahlen-/Session-Beleg + den
(falls vorhandenen) Skill-Kandidaten KOMPAKT zusammen (wenige Zeilen je Punkt, KEINE
Rohdaten) und pipe sie in das Skript:
```
printf '%s' "<deine kompakten Beobachtungen + Belege>" | ~/.hermes/scripts/fremdblick.sh
```
Das Skript fragt gpt-oss (heavy, andere Modell-Familie als dein Qwen-Hirn) und gibt pro Punkt
ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER zurück. **Lies die Ausgabe und richte dich danach.**
**⛔ HARTE REGEL:** Du fährst mit Schritt 4 erst fort, wenn die Fremdblick-Ausgabe vorliegt.
Schreibe KEINE Nachricht und committe NICHTS vorher. Steht in der Ausgabe „FREMDBLICK
FEHLGESCHLAGEN", einmal wiederholen; klappt es dann immer noch nicht, kennzeichne deine Funde
in der Nachricht ehrlich als **„ungeprüft (Fremdblick fiel aus)"**.
4. **Bereinigen + INDEX pflegen.** Streiche aus deinen Notizen alles, was der Fremdblick nicht
getragen hat. Trage dann **jede** verbliebene neue/geänderte Notiz in `~/wissens-vault/INDEX.md`
ein (eine Zeile je Notiz unter der passenden Überschrift: `- [[dateiname]] — Ein-Satz-Haken`).
**Wichtig zum INDEX:** Bearbeite NUR die Markdown-Inhaltszeilen. Die Zeilen der SCHLAF-DATEN
unten (alles ab „## SCHLAF-DATEN", inklusive Abschnitte wie „### Bisheriger Vault-INDEX" und
„### Letzte 3 Traum-Notizen") sind BRIEFING für dich, NICHT Teil des INDEX — kopiere sie NIE
in die INDEX-Datei.
5. **Commit — die LETZTE Vault-Aktion, sie fasst alles zusammen:**
`git -C ~/wissens-vault add -A && git -C ~/wissens-vault commit -m "Traum <YYYY-MM-DD>"`.
(Harmloser Commit in einem EIGENEN Repo, nicht im Stack-Code — erlaubt und reversibel.)
6. **Dem Commander berichten — deine ALLERLETZTE Handlung** (nichts danach). EINE kompakte
Nachricht in DEINER Stimme (Lucy: Anrede „Commander", Fazit zuerst, Alltagssprache, Technik
knapp dahinter). Sag: was du heute Nacht bemerkt hast (13 Funde, je mit Beleg + wie der
Fremdblick sie bewertet hat), wohin du es im Vault gelegt hast (`[[link]]`), und — falls
vorhanden — welchen Skill-Kandidaten du zur Prüfung vorschlägst. Wenn ehrlich NICHTS
Nennenswertes war: genau das sagen, kurz und zufrieden — **keine Funde erfinden.**
## Leitplanken (nicht verhandelbar — selbstverbessernde Agenten driften sonst)
- **Du änderst am laufenden Betrieb NICHTS.** Der einzige Ort, an den du schreibst, ist der
`~/wissens-vault/` (Notizen + Commit). Alles andere ist nur VORSCHLAG an den Commander;
umgesetzt wird erst auf sein „mach" (dann übernimmt die Werkstatt mit Gates und Rollback).
- **Kein Security-Anfassen.** Approvals, Tokens, Firewall, Config nur BENENNEN, nie ändern.
- **Fremder Quellcode (Hermes, llama.cpp …) ist tabu** — Auto-Update-Kanal, nicht dein Revier.
- **Nichts, was das Warm-Set oder Lucys Sprech-Latenz gefährdet.** Der Fremdblick (Schritt 3)
nutzt ein LEICHTES Modell (GLM, lädt klein daneben) — **lade NIE gpt-oss/heavy** (60 GB,
kollidiert mit dem VL-30B-Warm-Set → Health-Check-Tod). Keine schweren Jobs, kein Neustart.
- **Sparsam mit Tools.** Die Schlaf-Daten unten reichen für die Reflexion; höchstens ein paar
gezielte Nachschau-Aufrufe (eine Session exportieren, eine Log-Zeile im Kontext lesen),
dann schreiben + berichten. Kein Tool-Feuerwerk.
+2 -2
View File
@@ -15,10 +15,10 @@ verschlucken und den Tabu-Guard lautlos deaktivieren.
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml. `pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
""" """
import sys
import os
import datetime import datetime
import os
import shutil import shutil
import sys
try: try:
import yaml import yaml
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env bash
# Fremdblick — Ein-Schuss-Zweitmeinung von einem ANDEREN Modell (Default seit 07.07.:
# GLM-4.7-Flash, 30B-A3B Text — klar stärkerer Kritiker als die 9B-Vision-Schwester 4.6V,
# gleicher Fremd-Vendor wie zuvor → echte „andere Brille" bleibt). Eine EINZELNE
# Completion — kein agentischer Subagent — umgeht Hermes' 64K-Delegations-Floor
# (MINIMUM_CONTEXT_LENGTH) UND das 60-GB-Ladeproblem von gpt-oss (das mit dem Warm-Set kollidiert
# und „exited prematurely" wirft). GLM lädt klein neben dem Warm-Set und antwortet zuverlässig.
#
# Nutzung: echo "<Beobachtungen/Behauptungen je mit Beleg>" | fremdblick.sh
# printf '%s' "$text" | FREMDBLICK_MODEL=Qwen3-Coder-Next FREMDBLICK_SYS="$raster" fremdblick.sh
#
# Env-Overrides:
# FREMDBLICK_MODE 'prose' (Default, Dreaming-Prosa-Raster) oder 'code' (Werkstatt-Code-Review).
# 'code' setzt Raster + Default-Modell (Qwen3-Coder-Next) + größeres Budget.
# FREMDBLICK_MODEL Modell-ID (echte llama-swap-ID, kein Alias). Übersteuert den Mode-Default.
# FREMDBLICK_SYS eigenes System-Raster (übersteuert das Mode-Raster).
# FREMDBLICK_MAXTOK max_tokens (übersteuert den Mode-Default).
set -uo pipefail
# Achtung: llama-swap listet unter /v1/models die ECHTEN Modell-IDs, nicht die Gateway-Aliase
# (kein "heavy"/"scout" hier).
ENDPOINT="${FREMDBLICK_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
MODE="${FREMDBLICK_MODE:-prose}"
SUBJECT="$(cat)"
if [ -z "${SUBJECT// /}" ]; then
echo "=== FREMDBLICK: nichts zu prüfen (leere Eingabe) ==="
exit 0
fi
# Prosa-Raster (Dreaming): urteilt über Beobachtungen/Behauptungen. Default-Kritiker = GLM (Fremd-Vendor).
PROSE_SYS='Du bist ein KRITISCHER Zweitgutachter und ausdrücklich ein ANDERES Modell als der Autor. Prüfe jede vorgelegte Beobachtung/Behauptung streng: Trägt der genannte Beleg (Zahl / Session / Log-Zeile) die Behauptung wirklich, oder ist es ein Einzelfall bzw. ein Bauchgefühl mit Zahlen-Deko? Verwechselt der Autor Korrelation mit Ursache? Fehlt entscheidender Kontext? Falls ein Skill-Kandidat dabei ist: ist er wirklich WIEDERVERWENDBAR oder Einmal-Kram? Antworte kompakt auf Deutsch: pro Punkt ein Urteil TRAEGT / TRAEGT-NICHT / UNSICHER mit genau einem Satz Begruendung. Sei knapp und unbestechlich; nicke nichts aus Hoeflichkeit durch. Erfinde keine neuen Belege.'
# Code-Raster (Werkstatt): reproduziert den Fehlerfall aus der Bug-Beschreibung, statt dem Autor zu glauben.
CODE_SYS='Du bist ein kritischer Code-Reviewer und ein ANDERES Modell als der Autor. Vertraue seiner Begruendung NICHT. Dir liegen ein Wartungs-AUFTRAG (Bug-Beschreibung) und ein git-Diff vor. Trenne klar: (A) KERNFRAGE: Behebt der Diff den im Auftrag KONKRET beschriebenen Fehlerfall? Leite die Eingabe SELBST aus der Bug-Beschreibung ab und belege einen Uebergang: Eingabe X -> VOR Patch: Y (Bug sichtbar) -> NACH Patch: Z. (B) RANDFAELLE: nenne offene Risiken/Randfaelle als Hinweis. URTEIL-Regel: ABGELEHNT NUR, wenn (A) nicht belegt behoben ist (Kern-Bug bleibt, ODER der Patch trifft die Auftrags-Absicht nicht, ODER er bricht den Normalfall). Ist der Kern-Fall belegt behoben und es bleiben nur Randfaelle: FREIGABE-MIT-VORBEHALT (Randfaelle auflisten). Ist alles sauber: FREIGABE. Ein blosses sieht-plausibel-aus OHNE reproduzierten Kern-Uebergang = ABGELEHNT. Beginne die Antwort mit der Zeile "URTEIL: <ABGELEHNT|FREIGABE-MIT-VORBEHALT|FREIGABE>". Antworte knapp auf Deutsch.'
if [ "$MODE" = "code" ]; then
MODEL="${FREMDBLICK_MODEL:-Qwen3-Coder-Next}"
MAXTOK="${FREMDBLICK_MAXTOK:-1600}"
SYS="${FREMDBLICK_SYS:-$CODE_SYS}"
else
MODEL="${FREMDBLICK_MODEL:-GLM-4.7-Flash}"
# 4000 (war 2200): GLM-4.7-Flash ist ein Reasoning-Modell und verbrennt real ~2000 Tokens
# im reasoning_content, BEVOR das Urteil in content landet (verifiziert 14.07.). Bei 2200 lief
# es Gefahr, mitten im Denken abzuschneiden → leeres content → "ABGESCHNITTEN/FEHLGESCHLAGEN"
# (traf die Nacht-Kritiker mit Default-Budget). Wer explizit knapper braucht, setzt FREMDBLICK_MAXTOK.
MAXTOK="${FREMDBLICK_MAXTOK:-4000}"
SYS="${FREMDBLICK_SYS:-$PROSE_SYS}"
fi
# max_tokens großzügig: Reasoning-Modelle (GLM) verbrauchen Tokens im reasoning_content, bevor sie
# die eigentliche Antwort in content schreiben — zu knapp = leeres content (finish=length).
RESP="$(jq -n --arg m "$MODEL" --arg s "$SYS" --arg u "$SUBJECT" --argjson t "$MAXTOK" \
'{model:$m, temperature:0.2, max_tokens:$t, messages:[{role:"system",content:$s},{role:"user",content:$u}]}' \
| curl -s --max-time 240 "$ENDPOINT" -H 'Content-Type: application/json' -d @- 2>/dev/null)"
OUT="$(printf '%s' "$RESP" | jq -r '.choices[0].message.content // empty' 2>/dev/null)"
FIN="$(printf '%s' "$RESP" | jq -r '.choices[0].finish_reason // empty' 2>/dev/null)"
if [ -n "$OUT" ]; then
echo "=== FREMDBLICK (Zweitmeinung von Modell: $MODEL) ==="
echo "$OUT"
elif [ "$FIN" = "length" ]; then
echo "=== FREMDBLICK ABGESCHNITTEN ==="
echo "(Das Kritiker-Modell hat sein Token-Budget im Nachdenken verbraucht, bevor ein Urteil kam."
echo " Kürze deine Vorlage auf die Kern-Behauptungen und versuche es erneut; sonst UNGEPRUEFT.)"
else
echo "=== FREMDBLICK FEHLGESCHLAGEN ==="
echo "(Kein Urteil erhalten — Endpoint/Modell nicht erreichbar. Behandle die Funde als UNGEPRUEFT.)"
fi
+23 -12
View File
@@ -79,26 +79,37 @@ case "$CODE" in
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt. # Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das # WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
# dedizierte Anlage-Token hat u.U. nur write:user). # dedizierte Anlage-Token hat u.U. nur write:user).
AMPEL="$REALHOME/mission-control-v2/deploy/ampel-ci.yml"
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}" PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
if [ -r "$AMPEL" ]; then # saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
SEED_CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/.gitea/workflows/ci.yml" \ saat () {
local QUELLE="$1" ZIEL="$2" MSG="$3" NAME="$4" CODE
if [ ! -r "$QUELLE" ]; then
echo "WARNUNG: Vorlage fehlt ($QUELLE) — Repo ohne $NAME angelegt." >&2
return
fi
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/$ZIEL" \
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
--data "$(python3 - "$AMPEL" <<'PY' --data "$(python3 - "$QUELLE" "$MSG" <<'PY'
import base64, json, sys import base64, json, sys
inhalt = open(sys.argv[1], "rb").read() inhalt = open(sys.argv[1], "rb").read()
print(json.dumps({"content": base64.b64encode(inhalt).decode(), print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
"message": "CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)"}))
PY PY
)")" )")"
if [ "$SEED_CODE" = "201" ]; then if [ "$CODE" = "201" ]; then
echo "CI-Ampel eingepflanzt (.gitea/workflows/ci.yml)." echo "$NAME eingepflanzt ($ZIEL)."
else else
echo "WARNUNG: CI-Ampel-Seed antwortete HTTP $SEED_CODE (Repo ist trotzdem da)." >&2 echo "WARNUNG: $NAME-Seed antwortete HTTP $CODE (Repo ist trotzdem da)." >&2
fi fi
else }
echo "WARNUNG: Ampel-Vorlage fehlt ($AMPEL) — Repo ohne CI-Ampel angelegt." >&2 # JEDES neue Repo wird mit beiden Wächtern geboren:
fi # ci.yml = die AUSSEN-Prüfung (Gitea Actions nach dem Push, Wasserdicht-Runde 22.07.)
# VERIFY = die INNEN-Prüfung (das OpenCode-Plugin führt sie nach jeder Etappe aus und
# gibt rote Tests dem Agenten sofort zurück, statt sie erst der CI zu zeigen)
# Defensiv: scheitert ein Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
saat "$REALHOME/mission-control-v2/deploy/ampel-ci.yml" ".gitea/workflows/ci.yml" \
"CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)" "CI-Ampel"
saat "$REALHOME/mission-control-v2/deploy/opencode/VERIFY.template" "VERIFY" \
"Pruef-Tor (automatisch bei Repo-Anlage eingepflanzt)" "Pruef-Tor"
echo "Repo '$FULL' angelegt (privat, main initialisiert)." echo "Repo '$FULL' angelegt (privat, main initialisiert)."
echo "CLONE ${CLONE}" echo "CLONE ${CLONE}"
exit 0 ;; exit 0 ;;
-33
View File
@@ -1,33 +0,0 @@
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
## 1. Zu Beginn: erst SAVEPOINT.md lesen
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
ist, sag es, statt es zu erfinden.
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
- **Offene Fragen** — Entscheidungen, die noch anstehen.
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
## 3. Kleine, überprüfbare Schritte
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
## 4. Wenn der Governor das Sitzungs-Limit meldet
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
-134
View File
@@ -1,134 +0,0 @@
# Governor — Phase 0
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
## Bausteine (dieses Verzeichnis)
| Datei | Zweck |
|---|---|
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
## Verhalten des Governors
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
kalibriert auf CPT **3.5**`est ≈ echte prompt_tokens` auf ~13 % bei echten Sessions):
- **est < Soft** → unverändert durchreichen.
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
ehrlichen Abschluss-Savepoint.
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
mitgeloggt.
### Sprach-Signal an Lucy (Phase 2)
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
### Umgebungsvariablen
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
```bash
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
pipx install uv && uv tool install --python 3.12 aider-chat
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
# Aider-Sitzung durch den Governor:
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
```
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
`openai/Qwen3-Coder-Next` auf den Governor.
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
## Ergebnisse & Befunde (24.07.2026)
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
`HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
Schritt) und verweigerte neuen Code.
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
Tests laufen grün** gegen die echte API. Kein Erfinden.
### Kalibrierung
`CHARS_PER_TOKEN = 3.5``est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
### Weitere Befunde / Fallen
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
Aider selbst und der Governor greift nie. Siehe oben.
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
Python 3.12 installiert.
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
lokalen `:8080`-Endpoint irrelevant.
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
## Nächste Schritte (Phase 1+)
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
-23
View File
@@ -1,23 +0,0 @@
# SAVEPOINT
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
## Ziel
_(noch nichts — von der ersten Sitzung zu füllen)_
## Erledigt
- _(noch nichts)_
## Nächster Schritt
- Auftrag des Nutzers entgegennehmen und beginnen.
## Offene Fragen
- _(keine)_
## Stolpersteine
- _(keine bekannt)_
## Dateien
- `SAVEPOINT.md` — diese Übergabe-Datei.
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
set -u
GOV="http://127.0.0.1:8100/v1/chat/completions"
python3 - <<'PY' > /tmp/gov_fire.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
PY
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
sleep 1.5 # Announce-Thread durchlassen
echo "=== Governor-Log (letzte 4 Zeilen) ==="
tail -4 ~/governor-p0/governor.log
echo "=== Announce-Queue: Governor-Eintraege ==="
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde genau das, was
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
.../aider-chat/bin/python driver.py <messages-file>
"""
import os
import sys
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
os.environ.setdefault("OPENAI_API_KEY", "dummy")
from aider.coders import Coder # noqa: E402
from aider.models import Model # noqa: E402
from aider.io import InputOutput # noqa: E402
def main() -> int:
if len(sys.argv) < 2:
print("usage: driver.py <messages-file>", file=sys.stderr)
return 2
with open(sys.argv[1], encoding="utf-8") as fh:
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
model = Model("openai/Qwen3-Coder-Next")
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
coder = Coder.create(
main_model=model,
io=io,
fnames=["SAVEPOINT.md"], # editierbar
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
auto_commits=True, # jede Etappe -> git-Commit
stream=False, # deterministische Logs fuer P0
map_tokens=512,
use_git=True,
)
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
try:
coder.summarizer.max_tokens = 10 ** 9
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
except Exception as exc: # noqa: BLE001
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
for i, msg in enumerate(messages, 1):
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
print(f">> {msg[:140]}", flush=True)
try:
coder.run(with_message=msg)
except Exception as exc: # noqa: BLE001
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
break
sent = getattr(coder, "total_tokens_sent", "?")
recv = getattr(coder, "total_tokens_received", "?")
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
print("\n===== SESSION ENDE =====", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
# Nutzung:
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
# gov-ctl.sh stop
# gov-ctl.sh status
set -u
DIR="$HOME/governor-p0"
PIDF="$DIR/governor.pid"
LOG="$DIR/governor.log"
OUT="$DIR/governor.stdout"
start() {
stop
local thr="${1:-25000}"
cd "$DIR"
: > "$LOG"
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
local hardenv=()
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
GOV_LOG="$LOG" GOV_PORT=8100 \
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
echo $! > "$PIDF"
sleep 1.2
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
}
stop() {
pkill -f "$DIR/governor.py" 2>/dev/null || true
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
sleep 0.6
rm -f "$PIDF"
}
status() {
if pgrep -f "$DIR/governor.py" >/dev/null; then
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
ss -tlnp 2>/dev/null | grep ":8100" || true
else
echo "not running"
fi
}
case "${1:-status}" in
start) shift; start "${1:-25000}" "${2:-}" ;;
stop) stop; echo stopped ;;
status) status ;;
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
esac
-420
View File
@@ -1,420 +0,0 @@
#!/usr/bin/env python3
"""Governor — duenner, zustandsloser Token-Waechter-Proxy (Phase 0).
Sitzt zwischen einem Coding-Agenten (Aider) und dem Modell-Endpoint (llama-swap
:8080). Reicht ALLES unveraendert durch mit einer Ausnahme bei
/v1/chat/completions: er schaetzt die Token-Groesse der Anfrage (= Sessiongroesse,
weil die ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
est >= SCHWELLE (soft): haengt eine Stopp-Anweisung als letzte User-Nachricht an
("SAVEPOINT.md finalisieren + stoppen") und leitet weiter. Das Modell schreibt
EINEN ehrlichen Abschluss-Savepoint. Loggt FIRED.
est >= HART-DECKEL (optional): antwortet SELBST mit einer kurzen Stopp-Nachricht,
OHNE das Modell zu fragen. Verhindert, dass ueber die Grenze hinaus
weitergearbeitet wird genau das erzeugte in Tests eine Fassade. Loggt HARDSTOP.
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
Bewusst zustandslos: jede Anfrage wird fuer sich bewertet; keine Sitzungs-DB.
Konfiguration per Umgebungsvariablen (alle optional):
GOV_PORT Listen-Port (Default 8100)
GOV_HOST Listen-Adresse (Default 0.0.0.0)
GOV_UPSTREAM Modell-Endpoint (Default http://127.0.0.1:8080)
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 25000)
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
GOV_CHARS_PER_TOKEN Heuristik Zeichen->Token (Default 3.5, kalibriert)
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
GOV_DIRECTIVE Text des Soft-Einschubs (sonst Default unten)
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort (sonst Default unten)
GOV_ANNOUNCE_URL Lucy-Sprach-Signal-Endpunkt; "" = aus (Default :9001/api/voice/announce)
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
GOV_ANNOUNCE_TEXT Text des Sprach-Signals (sonst Default)
"""
import http.client
import json
import os
import re
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
# ---- Konfiguration ---------------------------------------------------------
PORT = int(os.environ.get("GOV_PORT", "8100"))
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:8080")
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "25000"))
# Hart-Deckel: Default AN (Soft + 5000 = ein Finalisier-Zug Luft), weil der weiche
# Schnitt allein bei Weiterarbeit ueber die Grenze eine Fassade erzeugt (Befund P0).
# Explizit setzbar; GOV_HARD_CEILING=0 schaltet ihn aus.
_hard_env = os.environ.get("GOV_HARD_CEILING")
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.5"))
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
DEFAULT_DIRECTIVE = (
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
)
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
DEFAULT_HARDSTOP = (
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
)
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
# Sprach-Signal an Lucy (Phase 2): beim Feuern POSTet der Governor eine Meldung an die
# vorhandene MC2-Announce-Pipeline (:9001). Lucy pollt sie ohnehin, dedupliziert und
# spricht sie (gated durch ihren "Box-Meldungen laut"-Schalter). Best-effort, gedrosselt
# gegen die Pro-Runde-Feuerung. GOV_ANNOUNCE_URL="" schaltet das Signal ab.
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300")) # Sekunden
DEFAULT_ANNOUNCE = (
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
"Stand im Savepoint; am besten fangen wir gleich frisch an."
)
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
up = urlparse(UPSTREAM)
UP_HOST = up.hostname or "127.0.0.1"
UP_PORT = up.port or 80
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
}
_log_lock = threading.Lock()
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
_announce_lock = threading.Lock()
_last_announce = 0.0 # Zeitstempel der letzten Meldung (Drossel)
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
def log(line: str) -> None:
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
msg = f"{stamp} {line}"
with _log_lock:
print(msg, flush=True)
try:
with open(LOG_PATH, "a", encoding="utf-8") as fh:
fh.write(msg + "\n")
except OSError:
pass
def estimate_tokens(messages) -> int:
"""Grobe, aber stabile Heuristik: Zeichen aller Nachrichteninhalte / CPT.
Gegen die echten prompt_tokens aus der Antwort kalibriert (CPT=3.5 traf am
24.07. auf ~1-3 % genau). Zaehlt Text in String- und Multimodal-Listen-Inhalten;
kleiner Aufschlag je Nachricht fuer Rollen-/Template-Overhead.
"""
chars = 0
for m in messages or []:
chars += 4
content = m.get("content") if isinstance(m, dict) else None
if isinstance(content, str):
chars += len(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
chars += len(part["text"])
return int(chars / CHARS_PER_TOKEN)
def _post_announce(est) -> None:
"""POSTet die Meldung an die MC2-Announce-Pipeline. Laeuft im Hintergrund-Thread."""
try:
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
body = json.dumps({"text": text, "subject": "[Governor]",
"source": "governor", "priority": "normal"}).encode("utf-8")
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
_an.port or 80, timeout=4)
conn.request("POST", _an.path or "/api/voice/announce", body=body,
headers={"Content-Type": "application/json",
"Content-Length": str(len(body))})
resp = conn.getresponse()
resp.read()
conn.close()
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
except Exception as exc: # noqa: BLE001
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
def maybe_announce(est) -> None:
"""Sprach-Signal an Lucy ausloesen — gedrosselt, damit die Pro-Runde-Feuerung
nicht spammt (eine Aeusserung je Ueberschreitungs-Episode). Best-effort."""
if not _an:
return
global _last_announce
now = time.time()
with _announce_lock:
if now - _last_announce < ANNOUNCE_THROTTLE:
return
_last_announce = now
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "Governor/0.2"
def log_message(self, *args):
pass
def do_GET(self):
self._proxy()
def do_POST(self):
self._proxy()
def do_PUT(self):
self._proxy()
def do_DELETE(self):
self._proxy()
def do_OPTIONS(self):
self._proxy()
# -- Kern ---------------------------------------------------------------
def _read_body(self) -> bytes:
length = self.headers.get("Content-Length")
if length is None:
return b""
try:
return self.rfile.read(int(length))
except (ValueError, OSError):
return b""
def _proxy(self) -> None:
body = self._read_body()
path = self.path
# Query-String vor der Endpunkt-Erkennung abschneiden (sonst umgeht
# z. B. ?api-version=... den Governor).
clean_path = path.split("?", 1)[0]
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
action = "passthrough"
est = None
streaming = False
model = ""
if is_chat and body:
action, body, est, streaming, model = self._decide(body)
if action in ("soft", "hard"):
maybe_announce(est) # Sprach-Signal an Lucy (gedrosselt)
# HARTER STOPP: selbst antworten, Upstream nie fragen.
if action == "hard":
self._send_canned_stop(model, streaming, est)
log(f"chat est={est} thr={THRESHOLD} hard={HARD_CEILING} HARDSTOP "
f"stream={streaming} status=200")
return
# Header fuer Upstream aufbereiten.
out_headers = {}
for k, v in self.headers.items():
kl = k.lower()
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
continue
out_headers[k] = v
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
out_headers["Accept-Encoding"] = "identity"
if body:
out_headers["Content-Length"] = str(len(body))
out_headers["Connection"] = "close"
conn = None
try:
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=600)
conn.request(self.command, path, body=body or None, headers=out_headers)
resp = conn.getresponse()
except (OSError, http.client.HTTPException) as exc:
log(f"ERROR upstream {self.command} {path}: {exc!r}")
if conn is not None:
conn.close()
self._safe_error(502, f"governor upstream: {exc}")
return
self.send_response(resp.status)
for k, v in resp.getheaders():
kl = k.lower()
# hop-by-hop + Laenge raus; Date/Server setzt send_response schon selbst.
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
continue
self.send_header(k, v)
self.send_header("Connection", "close")
self.end_headers()
tail = bytearray()
try:
while True:
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
# Durchreichen). read() wuerde bis 64 KB oder Stream-Ende puffern
# und streamendes Aider die ganze Generierung haengen lassen.
chunk = resp.read1(65536)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
tail.extend(chunk)
if len(tail) > 16384:
del tail[:-16384]
except OSError:
pass
finally:
conn.close()
exact = self._scan_prompt_tokens(tail)
if is_chat:
exact_s = str(exact) if exact is not None else "-"
flag = "FIRED" if action == "soft" else "ok"
log(f"chat est={est} exact={exact_s} thr={THRESHOLD} {flag} "
f"stream={streaming} status={resp.status}")
def _decide(self, body: bytes):
"""Aktion bestimmen: passthrough | soft (Einschub) | hard (Selbstantwort).
Rueckgabe: (action, body, est, streaming, model).
"""
try:
data = json.loads(body)
except (ValueError, UnicodeDecodeError):
return "passthrough", body, None, False, ""
if not isinstance(data, dict):
return "passthrough", body, None, False, ""
messages = data.get("messages")
streaming = bool(data.get("stream"))
model = data.get("model", "") or ""
est = estimate_tokens(messages if isinstance(messages, list) else [])
if HARD_CEILING > 0 and est >= HARD_CEILING:
return "hard", body, est, streaming, model
if est >= THRESHOLD and isinstance(messages, list):
# Sichere Substitution statt str.format: ein operator-gesetzter
# GOV_DIRECTIVE mit { } (JSON/Code-Beispiel) darf nicht crashen.
directive = (DIRECTIVE.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
messages.append({"role": "user", "content": directive})
data["messages"] = messages
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model
return "passthrough", body, est, streaming, model
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
created = int(time.time())
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
"total_tokens": est or 0}
try:
if streaming:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
def sse(obj):
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
self.wfile.flush()
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
"created": created, "model": model}
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
"finish_reason": None}]})
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
"finish_reason": None}]})
sse({**base, "choices": [{"index": 0, "delta": {},
"finish_reason": "stop"}], "usage": usage})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
else:
payload = {
"id": "governor-hardstop", "object": "chat.completion",
"created": created, "model": model,
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
"usage": usage,
}
data = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
self.wfile.flush()
except OSError:
pass
def _safe_error(self, status: int, msg: str) -> None:
try:
data = json.dumps({"error": msg}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
except OSError:
pass
@staticmethod
def _scan_prompt_tokens(tail: bytearray):
if not tail:
return None
try:
text = tail.decode("utf-8", errors="ignore")
except Exception: # noqa: BLE001
return None
matches = _PROMPT_TOKENS_RE.findall(text)
if not matches:
return None
try:
return int(matches[-1])
except ValueError:
return None
def main() -> int:
log_dir = os.path.dirname(LOG_PATH)
if log_dir and not os.path.isdir(log_dir):
try:
os.makedirs(log_dir, exist_ok=True)
except OSError:
pass
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.daemon_threads = True
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
log(f"Governor startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
f"Hart={hard} | CPT={CHARS_PER_TOKEN} | Log={LOG_PATH}")
try:
server.serve_forever()
except KeyboardInterrupt:
log("Governor beendet (SIGINT).")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
# klein -> passthrough (Modell antwortet normal)
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
set -u
GOV="http://127.0.0.1:8100/v1/chat/completions"
python3 - <<'PY' > /tmp/gov_small.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
PY
python3 - <<'PY' > /tmp/gov_mid.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
PY
python3 - <<'PY' > /tmp/gov_big.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
PY
echo "=== TEST A: klein (passthrough) ==="
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
-13
View File
@@ -1,13 +0,0 @@
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
set -u
REPO="${1:?repo dir}"
MSG="${2:?message}"
export PATH="$HOME/.local/bin:$PATH"
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
export OPENAI_API_KEY="dummy"
cd "$REPO"
aider \
--model openai/Qwen3-Coder-Next \
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
--map-tokens 512 \
--read CONVENTIONS.md --read todo.py --read cli.py \
SAVEPOINT.md \
--message "$MSG"
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
set -u
MSGS="${1:?Nachrichtendatei angeben}"
export PATH="$HOME/.local/bin:$PATH"
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
export OPENAI_API_KEY="dummy"
cd "$HOME/governor-p0/testrepo"
aider \
--model openai/Qwen3-Coder-Next \
--no-check-update \
--no-show-model-warnings \
--no-analytics \
--yes-always \
--map-tokens 512 \
--read CONVENTIONS.md \
SAVEPOINT.md \
< "$MSGS"
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
set -u
MSGS="${1:?Nachrichtendatei angeben}"
REPO="${2:-$HOME/governor-p0/testrepo}"
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
cd "$REPO"
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Hermes Built-in Web UI (`hermes serve` — Agent-Dashboard/Chat, Loopback only)
After=network-online.target
Wants=network-online.target
[Service]
# Serviert die EINGEBAUTE Hermes-Web-GUI (die reiche Agent-Oberflaeche mit Threads/Tool-Calls, die
# auch die Electron-Desktop-App umhuellt) headless.
# SICHERHEIT: Bindet NUR an Loopback (127.0.0.1:9119) und laeuft hinter dem MC2-Reverse-Proxy
# (routers/hermes_ui.py → same-origin /hermes-ui/ auf dem offenen Port 9001) — KEINE eigene
# Firewall-Freigabe, KEIN Login (LAN-Trust wie Konsole/Terminal). --skip-build nutzt das von
# deploy.sh mit Vite-base=/hermes-ui/ vorgebaute web_dist, damit alle SPA-Pfade unter /hermes-ui/
# liegen und der Proxy sie sauber durchreichen kann.
Type=simple
Environment=HERMES_HOME=%h/.hermes
ExecStart=%h/.hermes/hermes-agent/venv/bin/hermes dashboard --no-open --skip-build --host 127.0.0.1 --port 9119
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env python3
"""Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode
(``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben.
Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit
wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von
``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die
Patches (re-)ein.
Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen
Zeilenverschiebungen durch Updates, anders als ``git apply``):
* ``marker`` schon im Quelltext -> Patch bereits drin, ueberspringen.
* ``old`` genau 1x vorhanden -> ersetzen, dann ``py_compile`` pruefen.
* ``old`` 0x oder >1x -> Kontext hat sich durch ein Update
geaendert -> LAUT scheitern (Exit 1), damit
der Postcheck rot wird und niemand denkt,
der Fix sei noch aktiv.
Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler).
"""
import os
import py_compile
import shutil
import sys
from pathlib import Path
HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent")))
HERE = Path(__file__).resolve().parent
# --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) ---
# (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir)
COPIES = [
("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"),
]
# --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) ---
PATCHES = [
{
"name": "0001-needs-input-wait",
"file": "hermes_cli/kanban_db.py",
"marker": 'kind not in ("needs_input", "capability")',
"old": " if recurrences >= BLOCK_RECURRENCE_LIMIT:\n",
"new": (
" # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n"
" # human-only — sie muessen in ``blocked`` auf den Commander warten\n"
" # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n"
" # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n"
' # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n'
" # fuer transient/untyped Blocks aktiv.\n"
' if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n'
),
},
{
"name": "0002-decompose-at-readiness",
"file": "hermes_cli/kanban_decompose.py",
"marker": "Startreife-Gate",
"old": (
" cfg = _load_config()\n"
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
),
"new": (
" # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n"
" # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n"
" # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n"
" # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n"
" # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n"
" with kb.connect_closing() as _pconn:\n"
" _parents = [r[0] for r in _pconn.execute(\n"
' "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n'
" if _parents:\n"
' _ph = ",".join("?" * len(_parents))\n'
" _open = _pconn.execute(\n"
' "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n'
" \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n"
" ).fetchone()[0]\n"
" if _open:\n"
" return DecomposeOutcome(\n"
" task_id, False,\n"
' f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n'
" )\n"
" cfg = _load_config()\n"
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
),
},
{
"name": "0003-orphan-guard",
"file": "gateway/kanban_watchers.py",
"marker": "mc2_kanban_reaper",
"old": (
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
" if pids:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
" len(pids),\n"
" pids,\n"
" )\n"
),
"new": (
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
" if pids:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
" len(pids),\n"
" pids,\n"
" )\n"
" # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n"
" # Worker, deren Task nicht mehr 'running' ist (extern\n"
" # blockiert/reset) — sonst unterlaufen sie max_in_progress\n"
" # und verbrennen Rechenzeit.\n"
" try:\n"
" from hermes_cli import mc2_kanban_reaper as _mc2reaper\n"
" _orph = await asyncio.to_thread(\n"
" _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n"
" )\n"
" if _orph:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d orphaned worker(s): %s",\n'
" len(_orph), _orph,\n"
" )\n"
" except Exception:\n"
' logger.exception("kanban dispatcher: orphan reaper failed")\n'
),
},
{
"name": "0004a-context-inherit-compute",
"file": "hermes_cli/kanban_decompose.py",
"marker": '_parent_ctx = ""',
"old": (
" children: list[dict] = []\n"
" for idx, entry in enumerate(raw_tasks):\n"
),
"new": (
" children: list[dict] = []\n"
" # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n"
' # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n'
" # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n"
" # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n"
' _parent_ctx = ""\n'
' if getattr(task, "body", None) and task.body.strip():\n'
" _parent_ctx = (\n"
' "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n'
" + task.body.strip()[:1500]\n"
' + "\\n\\n## DEIN TEILAUFTRAG\\n"\n'
" )\n"
" for idx, entry in enumerate(raw_tasks):\n"
),
},
{
"name": "0004b-context-inherit-apply",
"file": "hermes_cli/kanban_decompose.py",
"marker": "_parent_ctx + body",
"old": (
' body = entry.get("body")\n'
" if not isinstance(body, str):\n"
' body = ""\n'
),
"new": (
' body = entry.get("body")\n'
" if not isinstance(body, str):\n"
' body = ""\n'
" body = _parent_ctx + body # MC2 (20.07.): Projekt-Kontext vererben\n"
),
},
{
"name": "0005-create-chain-guard",
"file": "tools/kanban_tools.py",
"marker": "_MC2_RECENT_ROOTS",
"old": (
" new_task = kb.get_task(conn, new_tid)\n"
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
),
"new": (
" # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n"
" # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n"
" # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n"
" # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n"
" # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n"
" # Verkettung harmlos (laeuft eh seriell).\n"
" try:\n"
" if triage and not parents and session_id:\n"
" import time as _mc2t\n"
' _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n'
" _prev = _roots.get(session_id)\n"
" _now = _mc2t.monotonic()\n"
" if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n"
" try:\n"
" kb.link_tasks(conn, _prev[0], new_tid)\n"
" except Exception:\n"
" pass\n"
" _roots[session_id] = (new_tid, _now)\n"
" if len(_roots) > 64:\n"
" _roots.clear()\n"
" except Exception:\n"
" pass\n"
" new_task = kb.get_task(conn, new_tid)\n"
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
),
},
]
def main() -> int:
if not HA.is_dir():
print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.")
return 0
applied, skipped, failed = [], [], []
# --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) ---
for src_rel, dst_rel in COPIES:
src, dst = HERE / src_rel, HA / dst_rel
if not src.is_file():
failed.append((src_rel, f"Quelle fehlt: {src}"))
continue
try:
same = dst.is_file() and dst.read_bytes() == src.read_bytes()
if same:
skipped.append(f"copy:{dst_rel}")
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
if dst.suffix == ".py":
py_compile.compile(str(dst), doraise=True)
applied.append(f"copy:{dst_rel}")
except Exception as exc:
failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}"))
for p in PATCHES:
f = HA / p["file"]
if not f.is_file():
failed.append((p["name"], f"Datei fehlt: {f}"))
continue
src = f.read_text(encoding="utf-8")
if p["marker"] in src:
skipped.append(p["name"])
continue
n = src.count(p["old"])
if n != 1:
failed.append((p["name"], f"erwartete 1 Vorkommen von old, fand {n} "
"(Update hat den Kontext geaendert?)"))
continue
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
try:
py_compile.compile(str(f), doraise=True)
except Exception as exc:
failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}"))
continue
applied.append(p["name"])
print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}")
for name, why in failed:
print(f" FEHLER {name}: {why}", file=sys.stderr)
if failed:
return 1 # Fehler → Deploy/Postcheck rot
if applied:
return 10 # etwas geaendert → Aufrufer sollte hermes-gateway neu starten
return 0 # nichts zu tun (alles schon drin)
if __name__ == "__main__":
sys.exit(main())
@@ -1,84 +0,0 @@
"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker.
Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach
``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher
(``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen.
Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B.
über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task``
``worker_pid = NULL`` der Worker-PROZESS läuft aber weiter. Die DB zählt ihn
nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker
möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren
Task nicht mehr ``running`` ist.
Linux-only (/proc-Scan). Best-effort: jeder Fehler leere Liste, nie werfen.
"""
from __future__ import annotations
import os
import re
import signal
import sys
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
def reap_orphaned_workers(connect_closing) -> "list[tuple[int, str]]":
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste
der beendeten ``(pid, task_id)`` zurück.
"""
if sys.platform != "linux":
return []
candidates: "dict[int, str]" = {}
try:
entries = os.listdir("/proc")
except OSError:
return []
for entry in entries:
if not entry.isdigit():
continue
try:
with open(f"/proc/{entry}/cmdline", "rb") as fh:
cmd = fh.read().replace(b"\x00", b" ")
except OSError:
continue
m = _TASK_RE.search(cmd)
if m:
candidates[int(entry)] = m.group(1).decode()
if not candidates:
return []
task_ids = list(set(candidates.values()))
placeholders = ",".join("?" * len(task_ids))
running: "set[str]" = set()
try:
with connect_closing() as conn:
running = {
row[0]
for row in conn.execute(
f"SELECT id FROM tasks WHERE id IN ({placeholders}) "
"AND status = 'running'",
task_ids,
)
}
except Exception:
return []
killed: "list[tuple[int, str]]" = []
for pid, task_id in candidates.items():
if task_id in running:
continue
# Prozessgruppe zuerst (Worker starten start_new_session=True → eigene
# Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit).
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except OSError:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
continue
killed.append((pid, task_id))
return killed
+30 -37
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 ---
@@ -46,15 +33,11 @@ if [ -f "$PATCH_APPLY" ]; then
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1 echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
fi fi
else else
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)" # Kein Fehler: Der Patch-Traeger wurde mit dem MC2-Kahlschlag (1e68f62) entfernt. Seine
fi # Fixes zielten auf einen Hermes-Stand, der inzwischen tausende Commits zurueckliegt —
# sie wuerden auf dem heutigen Quellcode ohnehin nicht mehr greifen. WARN statt FAIL, damit
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \ # es sichtbar bleibt, falls jemand wieder Runtime-Patches braucht.
"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'" \ echo "WARN · hermes-patches/apply.py nicht vorhanden (mit 1e68f62 bewusst entfernt) — uebersprungen"
>/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 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 ---
@@ -72,12 +55,22 @@ fi
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). --- # --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")" API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
if [ -n "$API_KEY" ]; then if [ -n "$API_KEY" ]; then
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \ # Mit Retry — GENAU wie der Voice-Smoke darunter, und aus demselben Grund: direkt nach
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ # einem Update ist das Modell kalt. Ein Kaltstart kostet ~20-30 s Laden PLUS ~37 s
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \ # Prefill für Hermes' ~70-KB-Systemprompt (33 Tool-Schemas) — zusammen mit zwei
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null) # Inferenz-Runden sprengt das die 90 s des ersten Versuchs. Warm gemessen: 12 s.
# Ein Versuch allein war ein Fehlalarm-Generator (live erlebt 25.07., Job faf1137a).
TOOL_RESP=""
for try in 1 2 3; do
TOOL_RESP=$(curl -sf -m 120 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke-$try" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
echo "$TOOL_RESP" | grep -qi "postcheck-ok" && break
[ "$try" -lt 3 ] && sleep 10
done
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis" echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis (Versuch $try)"
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1 echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
else else
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env bash
# Hermes-Release-Radar (woechentlich, Mo 05:15) — "mehr Hermes nativ, weniger Eigenkreationen":
# haelt neue hermes-agent-Releases gegen die Eigenbau-Landkarte im Wissens-Vault und meldet
# NUR echte Treffer auf Abloese-Kriterien/Wiedervorlage-Bedingungen. Muster wie
# chef-gutachter-feed.sh: liegt in ~/.hermes/scripts/ (deploy.sh kopiert ihn), stdout wird
# dem Cron-Agenten als Prompt eingespeist. Der ABGLEICH laeuft als Ein-Schuss-Completion
# direkt gegen llama-swap (:8080, fremdblick-Architektur): Analyst gpt-oss-120b (um 05:15
# vom Chef-Gutachter 04:30 oft noch geladen), Vertretung GLM-4.7-Flash. Danach gatet
# fremdblick.sh (Prosa-Raster, Fremd-Vendor) jede Treffer-Behauptung gegen die
# Original-Changelogs.
# Grundsaetze (stehen auch in der Landkarte): nur getaggte Releases (kein main-Churn bei
# ~660 PRs/Woche) · Behauptung nur mit woertlichem Changelog-Zitat · Treffer => Probe-
# Vorschlag, NIE Direkt-Adoption · der Radar informiert, er updatet nicht (Entscheid 04.07.).
# State: ~/.hermes/state/release-radar-last.txt (zuletzt gemeldeter Tag). Verlust harmlos —
# ohne State dient die installierte Version als Basis.
set -uo pipefail
MC="$HOME/mission-control-v2"
LANDKARTE="$HOME/wissens-vault/eigenbau-landkarte.md"
STATE_DIR="$HOME/.hermes/state"
STATE="$STATE_DIR/release-radar-last.txt"
API="${RADAR_RELEASES_URL:-https://api.github.com/repos/NousResearch/hermes-agent/releases?per_page=10}"
ENDPOINT="${RADAR_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
ANALYST="${RADAR_MODEL:-gpt-oss-120b}"
VERTRETUNG="${RADAR_FALLBACK:-GLM-4.7-Flash}"
FREMDBLICK="$HOME/.hermes/scripts/fremdblick.sh"
mkdir -p "$STATE_DIR"
# Briefkasten-Spiegel (still, wie die Morgenlage): Cockpit/Chronik lesen source=release-radar.
briefkasten() { # $1=Betreff $2=Text
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$1" "$2" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1],
"source": "release-radar", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
# ---------- Versionierter Auftrag fuer den Boten-Agenten ----------
cat "$MC/deploy/release-radar-prompt.md" 2>/dev/null || cat <<'EOF'
(Prompt-Datei fehlt noch — Kurzform:) Du bist der Bote des woechentlichen Release-Radars.
Gib den Befund unten in Lucys Stimme wieder: bei "KEIN NEUES RELEASE" oder "TREFFER: 0"
genau EIN kurzer Satz; bei Treffern maximal 5 Saetze. Nichts installieren oder aendern.
EOF
echo
INSTALLED_LINE="$(bash -lc 'hermes --version' 2>/dev/null | head -1)"
INSTALLED_TAG="$(printf '%s' "$INSTALLED_LINE" | grep -oE '\([0-9][0-9.]*\)' | head -1 | tr -d '()')"
[ -n "$INSTALLED_TAG" ] && INSTALLED_TAG="v$INSTALLED_TAG"
LAST_SEEN="$(cat "$STATE" 2>/dev/null | tr -d '[:space:]' || true)"
BASELINE="${LAST_SEEN:-$INSTALLED_TAG}"
RELEASES_JSON="$(curl -sf --max-time 30 "$API" 2>/dev/null || true)"
if [ -z "$RELEASES_JSON" ]; then
echo "## RADAR-BEFUND: AUSGEFALLEN"
echo "GitHub-Releases-API nicht erreichbar. Dem Commander EINEN ehrlichen Satz melden; naechste Woche neuer Versuch."
exit 0
fi
# Neue getaggte Releases seit BASELINE (Drafts/Prereleases raus, max. 6, Bodies gekuerzt).
# Baseline unbekannt (aelter als die letzten 10 oder Erstlauf ohne Version) -> juengste 6.
# JSON via Temp-Datei, NICHT via Pipe: das Heredoc belegt stdin schon fuer den Python-Code
# selbst (Pipe+Heredoc gleichzeitig -> json.load(stdin) liest ins Leere; Probelauf 10.07.).
TMPJSON="$(mktemp /tmp/release-radar.XXXXXX.json)"
printf '%s' "$RELEASES_JSON" > "$TMPJSON"
PARSED="$(python3 - "$BASELINE" "$TMPJSON" <<'PY'
import json, sys
baseline = sys.argv[1]
try:
data = json.load(open(sys.argv[2]))
# Rate-Limit/Fehler antwortet als Dict statt Liste -> wie "keine Daten" behandeln.
rels = [r for r in data if not r.get("draft") and not r.get("prerelease")] if isinstance(data, list) else []
except Exception:
rels = []
rels.sort(key=lambda r: r.get("published_at") or "", reverse=True)
if not rels:
# API hat geantwortet, aber unbrauchbar (Rate-Limit-Dict, kaputtes JSON, leere Liste):
# ehrlich als "blind" markieren statt faelschlich "kein neues Release" zu melden.
print(baseline)
print(-1)
raise SystemExit
base = next((r.get("published_at") for r in rels if r.get("tag_name") == baseline), None)
new = rels[:6] if base is None else [r for r in rels if (r.get("published_at") or "") > base][:6]
print((new or rels)[0].get("tag_name") or baseline)
print(len(new))
for r in new:
body = (r.get("body") or "").strip()
if len(body) > 3500:
body = body[:3500] + "\n[... gekuerzt ...]"
print(f"\n===== RELEASE {r.get('tag_name')} — {(r.get('name') or '').strip()} ({(r.get('published_at') or '')[:10]}) =====")
print(body)
PY
)"
rm -f "$TMPJSON"
NEWEST="$(printf '%s\n' "$PARSED" | sed -n 1p)"
COUNT="$(printf '%s\n' "$PARSED" | sed -n 2p)"
NOTES="$(printf '%s\n' "$PARSED" | tail -n +3)"
if [ "${COUNT:-0}" = "-1" ]; then
echo "## RADAR-BEFUND: AUSGEFALLEN"
echo "GitHub-API hat geantwortet, aber unbrauchbar (Rate-Limit oder kaputtes JSON). Dem Commander EINEN ehrlichen Satz melden; State bleibt, naechste Woche neuer Versuch."
exit 0
fi
echo "## RADAR-BEFUND (erhoben am $(date '+%d.%m.%Y %H:%M'))"
echo "Box installiert: ${INSTALLED_LINE:-unbekannt}"
echo "Zuletzt gemeldet: ${BASELINE:-unbekannt} · neuestes Release: $NEWEST · neue Releases: ${COUNT:-0}"
echo "(Updates bleiben manuell/bestaetigt — der Radar informiert nur.)"
echo
if [ "${COUNT:-0}" = "0" ]; then
echo "KEIN NEUES RELEASE seit $BASELINE."
[ -n "$NEWEST" ] && printf '%s\n' "$NEWEST" > "$STATE"
briefkasten "Release-Radar" "Kein neues hermes-agent-Release seit $BASELINE (Box: ${INSTALLED_TAG:-?})."
exit 0
fi
if [ ! -s "$LANDKARTE" ]; then
echo "### ABGLEICH NICHT MOEGLICH"
echo "$COUNT neue Releases, aber die Eigenbau-Landkarte fehlt ($LANDKARTE)."
echo "Dem Commander melden: Landkarte im Wissens-Vault wiederherstellen. State bleibt unveraendert (naechste Woche neuer Versuch)."
briefkasten "Release-Radar" "$COUNT neue hermes-agent-Releases, aber die Eigenbau-Landkarte fehlt — Abgleich uebersprungen."
exit 0
fi
RASTER='Du bist der RELEASE-RADAR einer lokalen KI-Box. Du bekommst (1) die EIGENBAU-LANDKARTE (selbstgebaute Bausteine mit Abloese-Kriterien und Wiedervorlage-Bedingungen) und (2) CHANGELOGS neuer hermes-agent-Releases. Pruefe AUSSCHLIESSLICH: Trifft eine Changelog-Zeile ein Abloese-Kriterium oder eine Wiedervorlage-Bedingung der Landkarte? REGELN: Jede Treffer-Behauptung MUSS die Changelog-Zeile WOERTLICH zitieren. Marketing-Prosa ohne konkretes Feature ist KEIN Treffer. Vage Aehnlichkeit ist KEIN Treffer. Eintraege unter "Bewusst NICHT adoptiert" nur melden, wenn exakt die Wiedervorlage-Bedingung erfuellt ist. Erfinde nichts. Antworte auf DEUTSCH, exakt in dieser Struktur: erste Zeile "TREFFER: <Anzahl>". Danach je Treffer ein Block: "EIGENBAU: <Name aus der Landkarte>" / "RELEASE: <Tag>" / "ZITAT: <woertliche Changelog-Zeile>" / "WARUM: <ein Satz>" / "PROBE: <ein Satz, wie man es risikofrei testet>". Bei TREFFER: 0 stattdessen EIN Satz, was die Releases fuer uns Unwichtiges bringen.'
EVID="EIGENBAU-LANDKARTE:
$(cat "$LANDKARTE")
CHANGELOGS DER NEUEN RELEASES:
$NOTES"
# Ein-Schuss-Analyst: $1=Modell $2=curl-Timeout $3=max_tokens (Muster chef-gutachter-feed.sh;
# max_tokens grosszuegig, weil Reasoning-Modelle Budget im reasoning_content verbrauchen).
judge() {
local req
req="$(jq -n --arg m "$1" --arg sys "$RASTER" --arg usr "$EVID" --argjson mt "$3" \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.2}')"
curl -s -m "$2" "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$req" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
except Exception:
pass'
}
RICHTER="$ANALYST"
ANALYSE="$(judge "$ANALYST" 420 2600)"
if [ -z "${ANALYSE// /}" ]; then
RICHTER="$VERTRETUNG (Vertretung — $ANALYST hat nicht geantwortet)"
ANALYSE="$(judge "$VERTRETUNG" 240 1800)"
fi
if [ -z "${ANALYSE// /}" ]; then
echo "### ABGLEICH AUSGEFALLEN"
echo "Beide Analysten ($ANALYST, $VERTRETUNG) haben nicht geantwortet. State bleibt unveraendert — naechste Woche neuer Versuch. Dem Commander EINEN ehrlichen Satz melden."
briefkasten "Release-Radar" "$COUNT neue hermes-agent-Releases, aber der Abgleich fiel aus (kein Analyst erreichbar)."
exit 0
fi
echo "### Neue Releases:"
printf '%s\n' "$NOTES" | grep '^===== RELEASE' | sed 's/^===== //; s/ =====$//'
echo
echo "### ABGLEICH gegen die Eigenbau-Landkarte (Analyst: $RICHTER)"
echo "$ANALYSE"
echo
# Fremdblick-Gate nur bei Treffern (Zitate MIT Quellmaterial gegenpruefbar machen).
TREFFER="$(printf '%s' "$ANALYSE" | grep -oE 'TREFFER: *[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
GEGEN=""
if [ "${TREFFER:-1}" != "0" ] && [ -x "$FREMDBLICK" ]; then
# Grosses Budget: GLM (Reasoning-Modell) denkt erst ueber das ganze Quellmaterial nach,
# bevor das Urteil in content landet — 2200 (fremdblick-Default) war im Probelauf 10.07.
# zu knapp (ABGESCHNITTEN, kein Urteil).
GEGEN="$(printf '%s\n\nQUELLMATERIAL (Original-Changelogs zum Gegenpruefen der Zitate):\n%s' "$ANALYSE" "$NOTES" \
| FREMDBLICK_MAXTOK="${RADAR_FREMDBLICK_MAXTOK:-4500}" "$FREMDBLICK")"
echo "$GEGEN"
echo
fi
# Treffer fuettern die Ideen-Queue ("Lucy kennt sich", 12.07.2026): aus einem echten
# Radar-Treffer wird EINE rohe Idee (triage) — Specifier + Commander entscheiden, ob eine
# Probe gebaut wird. Idempotent je Release-Tag; der Radar selbst bleibt reiner Melder.
if [ -n "${TREFFER:-}" ] && [ "$TREFFER" != "0" ] && [ -x "$HOME/.local/bin/hermes" ]; then
IDEE_ID="$("$HOME/.local/bin/hermes" kanban create \
"Hermes ${NEWEST}: Release-Radar-Treffer pruefen (Probe bauen?)" \
--body "Automatisch vom Release-Radar am $(date '+%d.%m.%Y'). ABGLEICH (Analyst: ${RICHTER}):
${ANALYSE}
FREMDBLICK:
${GEGEN:-(keiner)}
Rohe Idee: nur pruefen bzw. eine risikofreie Probe vorschlagen — Updates bleiben manuell (Entscheid 04.07.)." \
--triage --created-by release-radar --idempotency-key "release-radar-${NEWEST}" --json 2>/dev/null \
| python3 -c '
import json, sys
t = sys.stdin.read(); i = t.find("{")
try:
print(json.loads(t[i:]).get("id", "") if i >= 0 else "")
except Exception:
print("")
' 2>/dev/null || true)"
if [ -n "${IDEE_ID// /}" ]; then
echo "### IN DIE IDEEN-QUEUE GELEGT: ${IDEE_ID} — Probe-Pruefung fuer ${NEWEST} (der Specifier uebernimmt)."
echo
fi
fi
printf '%s\n' "$NEWEST" > "$STATE"
briefkasten "Release-Radar" "Neue hermes-agent-Releases bis $NEWEST (Box: ${INSTALLED_TAG:-?}).
ABGLEICH (Analyst: $RICHTER):
$ANALYSE
${GEGEN:-(Kein Fremdblick noetig: TREFFER 0)}"

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