373 Commits

Author SHA1 Message Date
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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 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
Hitonabi 798de0be8f Governor Phase 0 fertig + Phase 2: Hart-Deckel default + Sprach-Signal an Lucy
Phase 0 abgeschlossen:
- Hart-Deckel jetzt Default AN (Soft+5000), da der weiche Schnitt allein bei
  Weiterarbeit ueber die Grenze eine Fassade erzeugt (P0-Befund). GOV_HARD_CEILING=0
  schaltet ihn aus. gov-ctl reicht Arg 2 nur bei Bedarf durch.
- README: Empfehlung/Doku auf Default-an aktualisiert, Commit-Msg-Restpunkt notiert
  (--no-auto-commits als saubere Option).

Phase 2 (Voice-Hook): beim Feuern (soft/hard) POSTet der Governor best-effort +
gedrosselt (Default 300 s) eine Meldung an Lucys vorhandene Announce-Pipeline
(POST :9001/api/voice/announce, source=governor, priority=normal). Lucy pollt,
dedupliziert und spricht sie via lokales TTS -- KEIN Lucy-Code noetig. E2E bewiesen:
Feuern -> ANNOUNCE status=200 -> Eintrag id 394 source=governor in der Queue.
GOV_ANNOUNCE_URL="" schaltet es ab. announce-test.sh beigelegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:06:10 +02:00
Hitonabi c13cfd2bd0 Governor Phase 0: Token-Waechter-Proxy + Aider Session-Hygiene (bewiesen)
Duenner, zustandsloser Proxy (nur stdlib) zwischen Aider und llama-swap :8080.
Schiebt an einer Token-Schwelle "SAVEPOINT.md finalisieren + stoppen" ein (weich)
bzw. antwortet oberhalb eines optionalen Hart-Deckels selbst. Beweist Session-
Hygiene per hartem Schnitt statt Auto-Compaction -- ohne eine Zeile Lucy-Code.

Alle 4 Akzeptanzkriterien gruen: feuert im Log; SAVEPOINT.md gepflegt; ehrlicher
Grenz-Savepoint am ersten Feuern; frische Sitzung liest Savepoint -> baut Tests,
9 unittest gruen, keine Fassade. CPT 3.5 kalibriert (est ~= echte prompt_tokens).
Hart-Deckel nachgeruestet (weicher Schnitt allein erzeugt Fassade bei Weiterarbeit
ueber die Grenze). Streaming-read1-Fix + 4 kleinere aus adversarialer Review.

Laeuft deployt auf der Box unter ~/governor-p0/ (gov-ctl.sh start <soft> <hart>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:17:36 +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
Hitonabi 8e735df32d Gitea-Weg der Worker: intern statt DDNS + gitea-pr-Helfer + Waechter-Scope-Fix
- werkstatt-SOUL Regel 3: Klonen IMMER ueber http://192.168.178.153:3000
  (DDNS-Domain nachts wegen Zwangstrennung tot -> Worker hielt Gitea am
  24.07. faelschlich fuer kaputt und blockte). Nie SSH-Remotes, nie nach
  Passwoertern fragen.
- deploy/gitea-pr (neu): PR per Gitea-REST-API mit Token aus
  ~/.git-credentials (Vorfall 23.07.: Worker bat um Web-Passwort).
- ampel-waechter: /repos/search statt /user/repos - Token hat nur
  write:repository, /user/repos gab 403 und der stille exit 0 versteckte
  das seit Inbetriebnahme (Merkliste blieb leer, kein Alarm ging je raus).
  API jetzt intern, Telegram-Links bleiben auf der Domain.
- tabu-pfade-guard: Klon-Empfehlung in der Blockmeldung auf interne URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:40:40 +02:00
Auftragsbuch 057cc2bb47 Auftragsbuch: 'wartung/traum-duplikatscheck-v2' angenommen (Ein-Klick-Gate) 2026-07-23 08:28:59 +02:00
Hitonabi e686f95545 Traum: Duplikats-Check für Skill-Kandidaten 2026-07-23 08:03:37 +02:00
Hitonabi 8a90b7507e Ampel-Waechter: Status-Luegen-Wache (Job-Log = Wahrheit)
Alter Runner (0.2.13) meldete Abschluesse nicht -> Gitea zombie-markierte
gruene Jobs als failure. Waechter prueft jetzt vor Alarm das Job-Log auf
"Job succeeded". Runner auf 0.6.1 gehoben (eigentlicher Fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:27:05 +02:00
Hitonabi 59b09e4a9a Watchlist: LM Studio Bionic aufgenommen (Linux-Port-Issue als Anker)
Preview seit 16.07., Win+Mac, gratis, closed source. Keine Custom-
Endpoints (Box :9001 nicht anbindbar), keine CLI/API. Radar meldet,
wenn Issue #2185 (Linux-Port) zugeht; Changelog gegenlesen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:03:45 +02:00
Hitonabi 5894c24703 Ampel-Waechter: Telegram bei ROTEN CI-Laeufen (no-agent-Cron, 30 min)
Meldet nur NEUE Fehlschlaege (Merkliste), running zaehlt nicht als
gesehen. Teil 3 der Wasserdicht-Runde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:33:17 +02:00
Hitonabi 3f279fc7f2 Ampel-Vorlage v3: set +e (Runner-bash -e schnitt das Urteil ab)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:19:16 +02:00
Hitonabi af28a75338 Ampel-Vorlage: klare Lockfile-Pruefung statt kryptischem npm-ci-Fehler
Erster rippy-Lauf bewies: fehlendes package-lock.json = EUSAGE-Wand.
Jetzt: verstaendliche rote Meldung mit Fix-Anleitung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:11:31 +02:00
Hitonabi a121ca7dc0 Wasserdicht-Runde: universelle CI-Ampel fuer ALLE Lucy/MC2-Repos
- deploy/ampel-ci.yml: sprachneutrale Actions-Ampel (erkennt Python/Node
  selbst; Doku-Repo=gruen, Code ohne Tests=ROT)
- gitea-repo-create.sh: pflanzt die Ampel bei JEDER Repo-Anlage ein
  (Contents-API, Push-Token, defensiv)
- projektstart-SOUL: drei harte AGENTS-Regeln (Ampel-Pflicht,
  Spec-Abweichung=STOPP, Deploy nur via deploy.sh) — rippy-Lehren

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:59:50 +02:00
Hitonabi cce32810fc Watchlist: Electron-Notiz korrigiert (Fehlalarm-Wurzel)
Der '33'-Stand in der warum-Prosa war veraltet (real: 43.2.0 stable)
und liess den Rundumschlag ein 11-Major-Loch alarmieren. Notiz sagt
jetzt: Versionsstand immer live pruefen, nie der Notiz glauben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:35:42 +02:00
Hitonabi 682be9b63b VERDIKTE: Hirn auf DFlash (live 22.07.), DFlash-je-Rolle-Messung
hermes: DFlash schlaegt MTP (86-90 vs 81-83 t/s, live 89-94).
coder: DFlash verliert trotz guter Akzeptanz. heavy: wartet auf
Engine-Support (Tensor-Mismatch b10087). vision/scout: keine Drafts.
Inkl. Eigenbau-Rezept + Quantize-Falle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:24:37 +02:00
Hitonabi 96c4e417ec Rundumschlag-Prompt: 100%-lokal-Praemisse hart verankern
Probelauf empfahl GLM-5.2 per Cloud-API — widerspricht dem
Abloesungs-Zielbild. Regel 6: keine API-Empfehlungen, >122 GB
Unified = kein Kandidat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:05:56 +02:00
Hitonabi 90ab873924 Stack-Rundumschlag: monatlicher freier Rundumblick (Cron am 4., 06:40)
Ergaenzt die mechanischen Waechter um tagesaktuelle Web-Recherche —
Lehrbeispiel Gemma-4-Stealth-Update 15./16.07. ohne Versionssprung,
das kein Radar fing. Feed sammelt IST-Stand (Rollen, Engine, Puls,
Watch, Pruefstand, Ressourcen, Fehlerbild), Agent recherchiert und
empfiehlt max. 3 Schritte; umgesetzt wird per Karten-Klick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:35:26 +02:00
Hitonabi 6d3a0bf3a6 IDE-Skill 'projekt-uebernehmen': vorbereitetes Projekt in Zed starten
Der Weg von der Box in die IDE endete bisher am Repo: der Commander klont es,
oeffnet Zed/OpenCode — und der Agent weiss nicht, dass hier bereits ein
gehaertetes Konzept liegt. Ohne Anleitung plant er entweder neu (projekt-start
springt an) oder fragt ins Blaue.

- client/ide-skills/projekt-uebernehmen/SKILL.md (Quelle im Repo, installiert
  nach ~/.agents/skills/ — dort liegen die uebrigen PC-Skills, OpenCode 1.18
  laedt von dort). Ablauf: einlesen (Konzept/Roadmap/Savepoint + git-Stand) ->
  3-6 blockierende Fragen MIT Empfehlung -> Etappe-1-Plan -> "los" -> bauen,
  Savepoints, live testen. Zwei Abbruch-Faelle: schon Code da (dann Fortsetzung
  nach SAVEPOINT) und nur KONZEPT.md ohne ROADMAP (das ist ein geprueftes
  Ideen-Repo -> Hinweis auf "Gefaellt mir - Projekt daraus machen").
  Modus-Wechsel ist Teil des Skills: lesen/fragen im Planer, bauen im Coder.
- deploy/projektstart-SOUL.md: die von der Box erzeugte AGENTS.md nennt den
  Skill jetzt woertlich. AGENTS.md wird automatisch als Kontext geladen — das
  ist der zuverlaessige Kanal; auf spontane Skill-Wahl ist bei den lokalen
  Modellen kein Verlass (nachgemessen: heavy und coder greifen bei einem vagen
  "ich moechte hier loslegen" NICHT von selbst zum Skill).
- ~/.agents/skills/projekt-start/SKILL.md (ausserhalb des Repos) bekam eine
  Abgrenzungszeile: nicht nutzen, wenn KONZEPT.md + ROADMAP.md schon liegen.
  Sonst plant er ein vorbereitetes Projekt neu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:41:59 +02:00
Hitonabi c3fe6dbe8d No-Progress-Bremse: Schauen ist keine Schleife + Wiederaufnahme in alle SOULs
‼️ Die Bremse hat den Schaden angerichtet, den sie verhindern soll. Sie signiert
den ERGEBNISTEXT von terminal-Aufrufen; die Bestandsaufnahme in Schritt 0 besteht
aber aus vielen kurzen Schau-Befehlen (ls, cat, git log), deren Ausgaben sich stark
aehneln. Nach dreien blockte sie den naechsten Blick — ausgerechnet `ls /tmp/konzept-*`.
Der Worker las die Block-Meldung als "Verzeichnis existiert nicht", schloss daraus,
das fertige Konzept sei nicht verifizierbar, und warf eine halbe Stunde Arbeit weg
(Karte t_d26c3203, Log: "kein /tmp/konzept-* Verzeichnis gefunden (wegen
No-Progress-Bremse)"). Die Bremse verlangt in ihrem eigenen Text "Diagnose statt
Variation" — und verhinderte genau die.

- _nur_lesend(): rein lesende Befehle (ls/cat/head/find/grep/git status|log|diff|
  ls-remote …, keine Umleitung) werden weder gezaehlt noch geblockt. Strukturell
  nach Befehls-ART, nicht per Fehlertext-Liste — die Bremse bleibt generisch.
- Block-Meldung sagt jetzt ausdruecklich: "Dein Befehl wurde NICHT ausgefuehrt, das
  ist eine Bremse, KEIN Ergebnis — schliesse daraus nichts ueber Existenz oder
  Zustand." Ohne diesen Satz liest ein Agent den Block als Befund.
- Schritt 0 (Wiederaufnahme) + FORTSCHRITT.md jetzt auch in werkstatt-SOUL
  (Klon/Branch pruefen: liegt der Branch schon auf Gitea -> verifizieren und
  abschliessen statt neu bauen) und betrieb-SOUL (vorhandene Messwerte NICHT neu
  messen — ein wiederholter Bench laedt 70-GB-Modelle und gefaehrdet Lucys Warm-Set).
- projektstart: FORTSCHRITT.md gehoert ins Workspace-Wurzelverzeichnis, nicht in den
  Repo-Klon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:37:17 +02:00
Hitonabi 584f257853 SOUL: Wiederaufnahme statt Neuanfang nach Kontext-Tod
Live-Fall 21.07. (Karte t_d26c3203 "Rippy"): Lauf 1 war praktisch fertig —
Fliessband komplett (Recherche, 3 Rollen x 2 Runden, Haertetest, konzept.md
14,7 KB), Repo Hitonabi/rippy angelegt und gepusht, KONZEPT.md im Klon
geschrieben. Dann Kontext voll, Exit ohne kanban_complete -> "protocol
violation". Es fehlten git add/commit/push und die Uebergabe: zwei Minuten.

Lauf 2 fing komplett von vorne an — neuer Rollen-Cast, neue Kaskade, neuer
/tmp/konzept-*-Ordner —, obwohl Hermes den Abschnitt "Prior attempts on this
task" in den Auftrag schreibt UND der Workspace derselbe ist. Die SOUL hatte
dafuer keinen Platz: sie begann mit "Der Ablauf (genau diese vier Schritte)".

- Neuer Schritt 0 WIEDERAUFNAHME: bei "Prior attempts" erst inventarisieren
  (FORTSCHRITT.md, Workspace-Repo inkl. `git status` — uncommittete Dateien
  sind fertige Arbeit —, /tmp/konzept-*, `git ls-remote`), dann an der ERSTEN
  LUECKE ansetzen. Ein zweiter kompletter Denk-Durchlauf gilt als Fehlschlag.
- Neuer Abschnitt "Fortschritt hinterlassen": eine Zeile je Stufe in
  FORTSCHRITT.md im Workspace, SOFORT geschrieben, dazu als Heartbeat-Notiz.
  Das unterscheidet einen Kontext-Tod von einem Totalverlust.
- Schritt 2: den vom Vorgaenger vergebenen Repo-Namen weiterbenutzen, sonst
  entsteht fuer dieselbe Idee ein zweites Repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:27:41 +02:00
Hitonabi 73513f5e5b SOUL: Push-Beleg bleibt, falsche Anekdote raus
Die Begruendung zur ls-remote-Regel war eine Fehlannahme von mir: das Repo
`Hitonabi/arm-ui` fehlte nicht wegen eines verpufften Pushes, sondern weil der
Commander es selbst geloescht hat (Konzept ging in die falsche Richtung). Der
Push war echt.

Die Regel selbst bleibt — der Worker hatte seine eigene Pruefung tatsaechlich
uebersprungen und dabei gegen die No-Progress-Bremse argumentiert. Statt der
erfundenen Folge steht jetzt der eigentliche Grund da: nicht wegargumentieren,
der Commander soll "Repo liegt bereit" ohne Nachpruefen glauben koennen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:21:37 +02:00
Hitonabi 18f5522612 Konzept-Raterei enger: keine fremden Konzepte an laufende Karten haengen
Der Datei-Fallback darf den Dateinamen ueber den Kartentitel raten. Live gesehen:
eine gerade LAUFENDE neue ARM-Karte zeigte das alte arm-ui-Konzept, als waere es
ihr Ergebnis — ein einzelnes gemeinsames Wort ("arm") reichte.

- Titel-Raterei nur noch bei FERTIGEN Karten; laeuft eine noch, zaehlt allein ein
  im Text genannter Pfad.
- Und sie braucht jetzt zwei gemeinsame Woerter oder ein langes (>=6 Zeichen),
  damit nicht irgendeine Datei auf irgendeine Karte passt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:08:46 +02:00
Hitonabi 0cea01c699 Konzept nachschaerfen: Rueckmeldung statt nur annehmen-oder-wegwerfen
Nach dem Lesen eines Konzepts gab es bisher nur zwei Wege: "Gefaellt mir" oder
archivieren. Der haeufige Fall fehlte komplett — EIN Punkt passt nicht und der
Commander hat einen besseren Vorschlag.

- POST /api/ideen/nachschaerfen -> konzept_ueberarbeiten(): legt eine neue
  Idee-Karte an (kein Bau), verkettet an die Quell-Karte, mit dem Wortlaut der
  Rueckmeldung. Auftrag: bestehendes Konzept lesen, genannte Punkte UND deren
  Folgewirkungen aendern, Rest stehen lassen, kurzer Haertetest nur auf die
  geaenderten Teile. Schafft der Vorschlag ein Problem: einbauen UND die Folge
  sichtbar in die Risiken schreiben — nicht uebergehen, nicht schoenreden.
  Die neue Fassung geht an DIESELBE Stelle (Repo-Commit bzw. Datei + .bak), so
  zeigt "Konzept" ueberall den frischen Stand. Beliebig oft wiederholbar, weil
  die Ueberarbeitung selbst wieder eine Idee-Karte ist.
- UI: Rueckmeldefeld direkt unter dem gelesenen Konzept, ueber dem
  Gefaellt-mir-Weg (der haeufigere Fall gehoert nach oben und offen sichtbar).
- SOUL: neuer Sonderfall UEBERARBEITUNG (Schritt 2 entfaellt, kein zweites Repo).

Zwei Fehler nebenbei gefunden und behoben:
- _wo_liegt(): der Auftrag verwechselte "Repo bekannt" mit "Konzept kommt aus
  dem Repo". Faellt MC2 auf die lokale Datei zurueck, stand vorher "liegt im
  Repo als <lokaler Dateiname>" im Auftrag — eine Datei, die es dort nie gab.
- SOUL-Regel 4: der Push gilt erst als erledigt, wenn `git ls-remote` ihn
  BEWEIST. Ein Worker hat am 21.07. genau hier abgekuerzt, Erfolg gemeldet, und
  das Repo war nachher nicht auffindbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:05:34 +02:00
Hitonabi 94dc3fba9b Karten-Titel: kurz und an der Wortgrenze statt abgeschnittener Textwueste
Der Commander schreibt seine Ideen roh — ein ganzer Absatz wurde 1:1 zum
Kartentitel und war danach ueberall abgeschnitten ("... und lass uns ueberlegen
wie"). Drei Stellen:

- add_idea(): Zurufe ueber 90 Zeichen bekommen einen kurzen Anzeige-Titel
  (erster Satz, sonst Schnitt an der Wortgrenze). Der VOLLE Wortlaut wandert in
  den Auftrag, mit Kopfzeile "der Kartentitel ist nur eine Kurzfassung" — sonst
  arbeitet der Worker am abgeschnittenen Satz.
- _projekt_titel(): der Gruppenname nahm stur die aelteste Karte und schnitt sie
  bei 80 Zeichen mitten im Wort ab. Jetzt gewinnt bei fehlendem gemeinsamen
  Praefix der kuerzeste Mitglieds-Titel (die aus einem Konzept abgeleiteten
  Karten sind die gepflegten) — Praefixe wie "Idee:" fallen weg, Schnitt an der
  Wortgrenze. Aus der ARM-Kette wird so "ARM-UI — Modernes Dashboard fuer
  Automatic Ripping Machine" statt des halben Zurufs.
- UI: Tooltip der Zeile zeigt den vollen TITEL statt des Auftragstexts (der
  beginnt mit Vorspann-Bausteinen und half beim Lesen nie).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:46:40 +02:00
Hitonabi 3cdd5cdd10 Auftragsbuch: Konzepte sichtbar, Modi unterscheidbar, Ideen weiterfuehrbar
Drei Luecken, die die neue Idee-Bahn unbrauchbar machten (User-Fund 21.07.):

1. Konzept unsichtbar. Der projektstart-Worker folgte dem Ende des
   konzept-fliessband-Skills (Kopie nach ~/konzepte + Telegram) statt seiner
   SOUL (Repo + KONZEPT.md) und schloss die Karte ab. Die Konzept-Ansicht sucht
   nur in Gitea -> "noch kein Repo hinterlegt", obwohl das Konzept fertig war
   (zweimal passiert: arm-ui, pomodoro-timer). konzept_of() faellt jetzt auf
   ~/konzepte/*.md zurueck (Pfad aus der Abschlussmeldung, sonst ueber den Titel
   geraten; streng auf dieses Verzeichnis begrenzt) und nennt die Quelle.
   Zusaetzlich sagen SOUL und Skill jetzt ausdruecklich, dass die Skill-Ablage
   fuer Kanban-Worker NICHT das Ende ist.

2. Modus nicht erkennbar. Idee und IDE-Projekt trugen dasselbe "IDE"-Abzeichen,
   Box-Karten gar keins. list_queue() liefert jetzt `art` (aus dem Body-Marker
   AUFTRAGS-ART: IDEE PRUEFEN), die UI zeigt BOX / IDEE / IDE mit Erklaerung —
   plus eine immer sichtbare Zeile, wann man welchen Modus nimmt.

3. Idee war eine Sackgasse. Neu: POST /api/ideen/weiterfuehren legt aus einer
   fertig durchdachten Idee eine IDE-Projekt-Karte an — verkettet an die Idee,
   mit dem fertigen Konzept als Vorlage ("denk es NICHT neu"), Titel aus der
   Konzept-Ueberschrift statt aus dem Rohtext. Liegt das Konzept schon in einem
   Repo, wird genau dieses weitergenutzt statt ein zweites anzulegen.
   In der UI sitzt der Knopf unter dem gelesenen Konzept (Ziel + Aufwand).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:40:16 +02:00
Auftragsbuch 9307c8b752 Auftragsbuch: 'doku/orchestrator-skill' angenommen (Ein-Klick-Gate) 2026-07-21 08:03:33 +02:00
Auftragsbuch 204238925a Auftragsbuch: 'doku/gitea-skill-doku' angenommen (Ein-Klick-Gate) 2026-07-21 08:03:25 +02:00
Hitonabi a16cff39ce Doku: orchestrator Skill dokumentieren 2026-07-21 04:05:11 +02:00
Hitonabi 11c3416571 Doku: gitea-workflow Skill dokumentieren 2026-07-21 03:53:59 +02:00
Hitonabi 7389386791 Vierter Modus 'Idee': Produkt-Idee durchdenken statt Projekt starten
Fuer echte Software-Ideen (z.B. Game-Server-Management), die weder MC2/Lucy-
Umbau noch schon ein Zed-Projekt sind. Volle Denk-Kaskade mit PRODUKT-Blick
(was, fuer wen, was gibt es schon, kleinster Wurf, was spricht dagegen) statt
Umsetzungs-Blick. Ergebnis: NUR KONZEPT.md — keine Roadmap, kein Geruest, keine
Bau-Aufforderung; der Commander entscheidet danach. Kein Ziel-Schalter (eine
Idee braucht noch keine Plattform). Eigene Farbe (sky), Button 'Durchdenken'.
Nebenbei: projektstart-SOUL.md ist jetzt versioniert + deploy-fest (lag bisher
nur auf der Box).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:57:06 +02:00
Hitonabi 40c8ed4754 Docker-Ziel bekommt portablen Kontext statt Heimlabor-Steckbrief
Docker heisst bei diesem Commander: bewusst raus aus der LXC-Welt, portabel/
extern. Der Proxmox-/pve-/PBS-Kontext haette das Konzept nur in die falsche
Richtung geschubst. LXC-Pfad (und die Kein-Ziel-Wahl = seine Standard-Welt)
behalten den vollen Heimlabor-Steckbrief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:45:08 +02:00
Hitonabi 656c5045da Infrastruktur-Kontext: LXC-Bestand nicht hartkodieren, nachschlagen lassen
Die fest eingetragene Container-Liste veraltet (106 homepage war schon weg) und
kostet im Steckbrief bei JEDEM Worker-Turn Kontext. Jetzt nur noch das Prinzip:
AI-Box = Werkbank, pve = separates Ziel-System — Bestand bei Bedarf per
'ssh pve pct list' nachsehen statt annehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:43:09 +02:00
Hitonabi 828dbe13b7 Infrastruktur: AI-Box vs Proxmox-Host klar trennen (Blindfleck)
Die IDE-Planer kannten den Proxmox-Host gar nicht — der Worker laeuft AUF der
AI-Box und nahm an, 'hier' sei auch das Ziel (User musste das muehsam erklaeren).
Steckbrief + IDE-Auftrag nennen jetzt beide Maschinen explizit: AI-Box
192.168.178.151 = Werkbank (nie Deployment-Ziel), Proxmox 'pve' 192.168.178.108
= separates Ziel-System mit den LXCs (100 adguard, 101 npmplus, 102 netbird,
103 pve-scripts-local, 104 gitea/.153, 105 PBS), per 'ssh pve' erreichbar, pct.
Live verifiziert (pct list, gitea-IP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:39:59 +02:00
Hitonabi 82a3bc3c59 Ziel Docker = NATIVES Docker (keine LXC-Nesting-Konstrukte)
Korrektur: Docker-Wahl bedeutet normales, portables Docker nach Standard-Praxis
(Dockerfile, compose, ueber Gitea-/GitLab-Pipelines baubar) — nicht 'compose im
LXC mit Nesting'. Genau dann geht der Commander bewusst aus der LXC-Welt raus.
Infrastruktur-Steckbrief bleibt Kontext (lokal, kein Cloud/Bezahldienst), zwingt
aber nicht mehr LXC-Denke auf den Docker-Pfad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:23:16 +02:00
Hitonabi d5da853db2 IDE-Intake: Ziel-Plattform waehlbar (LXC oder Docker) + Infrastruktur-Steckbrief
Zweiter, dezenter Schalter (nur bei IDE): Ziel = LXC (Standard) oder Docker.
Die Wahl fliesst als 'ZIEL-PLATTFORM'-Zeile in den Auftrag, dazu immer ein
Infrastruktur-Steckbrief (Proxmox/LXC, PBS-Backups, systemd, self-hosted Gitea,
alles lokal) — sonst plant jedes Modell nach Industrie-Default Docker/Cloud.
Skill: Ziel + Infrastruktur sind bindend; traegt die Wahl nicht, muss das
Konzept es ausdruecklich sagen statt sie still zu ignorieren.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:18:20 +02:00
Hitonabi 7b9752261a Konzept-Ansicht: Repo-URL auch in Markdown-Backticks erkennen
Der Worker schreibt die Repo-URL in der Abschluss-Zusammenfassung als
`https://...` — die Lookahead-Liste der Regex kannte den Backtick nicht,
also fand die Konzept-Ansicht kein Repo ('wird gerade vorbereitet') obwohl
Repo + KONZEPT.md laengst da waren. Fix: greedy matchen, Treffer hinterher
saeubern (Backticks/Satzzeichen/.git) statt das Endezeichen zu raten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:14:45 +02:00
Hitonabi f77c09295a Auftragsbuch: Modus-Knoepfe farblich trennen (Box=teal, simpel=violett, gruendlich=amber)
Simpel und gruendlich waren beide violett. Jetzt drei klar unterscheidbare
Farben; der Vorbereiten-Button und die Erklaerung faerben sich mit dem
gewaehlten Modus (amber = intensiver/laenger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:10:37 +02:00
Hitonabi cfcb5d2db5 IDE-Intake: Tiefe-Wahl (simpel/gruendlich) statt einem IDE-Knopf
Drei Modus-Knoepfe: Box · IDE: simpel · IDE: gruendlich. Die Wahl fliesst als
'AUFWAND (vom Commander gewaehlt): SIMPEL/GRUENDLICH'-Zeile in den Auftrag und
UEBERSTIMMT die Groessen-Selbsteinschaetzung von konzept-fliessband (simpel=2
Rollen/1 Runde, gruendlich=volle Kaskade). Skill: bei echter Ambition zur vollen
Tiefe (nicht 'im Zweifel kleiner'), explizite Commander-Wahl schlaegt Schaetzung.
Auto-Skalierung bleibt Fallback fuer Telegram/Lucy (ohne Knopf).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:04:19 +02:00
Hitonabi efca31c21f gitea-repo-create: Anlage-Token im ECHTEN User-Home finden (getent)
Worker-Subprozesse laufen mit abweichendem $HOME → das Skript fand das
write:user-Anlage-Token nicht und fiel aufs Push-Token (write:repository)
zurueck → 'Token hat fehlenden Scope', IDE-Vorbereiter blockte am Repo-Anlegen
(Pomodoro-Testlauf). Fix: echten Home aus /etc/passwd aufloesen (getent),
unabhaengig von $HOME.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:50:49 +02:00
Hitonabi a57e971607 konzept-fliessband: Aufwand zur Projektgroesse skalieren + Profil-Sync
Mini-Projekte bekamen die volle Kaskade (3-5 Rollen x bis 3 Runden = ~15-20
heavy-Aufrufe a ~90s → 35 min fuer einen Pomodoro-Timer). Neu: Rollen+Runden
skalieren mit Groesse (klein=2 Rollen/1 Runde, gross=volle Kaskade), frueher
raus bei WASSERDICHT. Deploy spiegelt den Skill jetzt auch in Profile, die ihn
haben (projektstart). Ergaenzend (Box-Config): projektstart-Manager coder->hermes,
damit heavy warm bleibt (coder 46G verdraengte heavy 60G bei jedem Aufruf;
hermes 22G persistent koexistiert mit heavy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:45:10 +02:00
Hitonabi bf9c22ab78 Auftragsbuch: Konzept-Ansicht fuer IDE-Projekte (KONZEPT.md inline)
IDE-Vorbereiten-Karten bekommen einen 'Konzept'-Knopf: MC2 holt KONZEPT.md
aus dem vorbereiteten Gitea-Repo (Gitea-Raw-API + Auth aus git-credentials)
und rendert es direkt im Auftragsbuch (leichter MD-Renderer lib/markdown.tsx).
So liest der Commander das ausgearbeitete Konzept in der GUI, ohne das Repo
aufzumachen (User-Wunsch: 'lesen gehoert in MC2, feilen in die IDE').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:02:12 +02:00
Hitonabi ff8125d2ac No-Progress-Bremse: worker.sh/fremdblick.sh ausnehmen (Fliessband-Motor)
konzept-fliessband/orchestrator rufen worker.sh pro Runde/Rolle wiederholt auf
(verschiedene Prompts, aehnliche Prosa) — die Bremse hielt das faelschlich fuer
eine Schleife und blockte (Pomodoro-IDE-Test lief 19 min dagegen an). Diese
Delegations-Helfer haben ihr eigenes Runden-Limit → von der Bremse ausnehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:47:45 +02:00
Hitonabi 4bb6b28297 Auftragsbuch: Intake-Toggle kuerzer beschriftet (Modus / Box / IDE)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:38:39 +02:00
Hitonabi e3b3725b6f Hermes-Runtime-Patch-Traeger + Fan-out-/Kontext-Fixes
Neuer update-fester Patch-Traeger (deploy/hermes-patches/apply.py) haelt
MC2-eigene Fixes im Hermes-Quellcode, idempotent, in deploy.sh + postcheck
verdrahtet (Exit 10 = Gateway-Neustart, Exit 1 = rot):
 0001 needs_input/capability warten auf Commander (keine triage-Eskalation
      -> keine Muenz-Schleife)
 0002 Decompose erst bei Startreife (Parents done)
 0003 Orphan-Guard: Reaper killt Worker, deren Task nicht mehr running
      (mc2_kanban_reaper.py) -> max_in_progress leckt nicht
 0004 Projekt-/Repo-Kontext vererbt sich auf Kind-Karten
 0005 Etappen-Auto-Kette: Bulk-Root-triage-Karten einer Session verketten
Steckbrief: 'Repo fehlt -> selbst anlegen' + verschaerfte Kontext-Regel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:22:07 +02:00
Hitonabi 77ae504b58 IDE-Welt: Vorbereiten-Bahn — Idee → Konzept + leeres Repo + Doku, Bau in Zed
Neue Intake-Weiche Box/IDE. IDE-Ideen gehen direkt an das neue Profil
'projektstart' (am Triage/Auto-Zerleger vorbei, kein Werkstatt-Bau): es
haertet via konzept-fliessband ein Konzept, legt ein leeres Gitea-Repo an
und fuellt es NUR mit Doku (Konzept/Roadmap/AGENTS.md/.aiexclude). Ergebnis
ist ein vorbereitetes Projekt, das der Commander in Zed selbst baut.
UI: Toggle 'Box baut / Ich bau in Zed', IDE-Badge auf Karten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:43 +02:00
Hitonabi 8153d14d9f Queue-Steuerung: Stopp/Neuer-Versuch/Prio + Haenger-Warnung (UI+Telegram)
Karten lassen sich gezielt anhalten (generischer Block + Prozessbaum-Kill),
frisch starten (Retry beendet Worker samt Kindern - uvicorn-Falle) und per
Prio-Pfeilen umsortieren (Dispatcher zieht priority DESC nativ). Laufende
Karten ohne Heartbeat >10 min bekommen ein Warn-Badge; der Ketten-Digest
alarmiert nach 15 min per Telegram, inkl. Kontext-Verdichtungs-Hinweis.
Ausloeser: Scaffold-Worker hing 45 min an selbst gestartetem uvicorn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:26:09 +02:00
Hitonabi a91d70d9b7 Auftragsbuch: Ketten-Sicht (Projekt-Gruppen) + Telegram-Ketten-Digest
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende
Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'.
Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links +
Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort,
Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:07:15 +02:00
Auftragsbuch eb1f333bba Auftragsbuch: 'wartung/gedaechtnis-check-timer-dokumentation' angenommen (Ein-Klick-Gate) 2026-07-20 04:10:55 +02:00
Hermes-Agent 24e2017d5b Doku: Gedaechtnis-Check-Timer in Automatik-Fahrplan aufnehmen 2026-07-20 03:54:55 +02:00
Hitonabi 2383dd26fa Uebergabe: Abnahme-Ergebnis des Selbst-Audits (t_4dd12efb, 6/6 PASS)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:56:44 +02:00
Hitonabi aebe10306d Uebergabe-Dokument: Drei-Welten-Umbau 19.07. (finaler Claude-Stand)
Die Box-lesbare Kapsel des kompletten Umbaus: Architektur, neue Mechanik,
Verdikte, IDE-Pfade, offene Faeden. Claude-Gedaechtnis/Artifacts werden mit
Abo-Ende unlesbar - alles Betriebsrelevante steht ab jetzt HIER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:44:30 +02:00
Hitonabi 6b03a2c3cf Gedaechtnis-Bereiche: alltag/projekt-Feld statt zweitem mem0
- classify_facts ordnet neue Fakten zusaetzlich einem Bereich zu (alltag=Person,
  projekt=Technik); Metadatum bereich, in /memory-Items sichtbar.
- GET /memory?bereich=... filtert; Alt-Fakten ohne Feld laufen IMMER mit.
- docs/wissen/GEDAECHTNIS-BEREICHE.md: Entscheid (kein zweites mem0), Stand,
  4-Wochen-Selbstpruefung und kompletter Bauauftrag fuer den Recall-Filter -
  damit die Werkstatt das ohne externe Hilfe fertigbauen kann.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:38:12 +02:00
Hitonabi a1d7a4b6b9 mem0-Konsolidierung: monatlicher Lauf (3. des Monats)
Legt semantisch gleiche Fakten zusammen und loest Widersprueche (neuester gewinnt)
ueber /graph-Cluster + fast-Hirn (NoThink). Harte Leitplanken: pro Gruppe ueberlebt
immer ein Fakt, max 1 Update, LLM-Fehler = Gruppe unangetastet, Kappung pro Lauf.
Erster Live-Lauf 19.07.: 459 -> 420 Fakten (15 zusammengefasst, 39 geloescht).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:32:52 +02:00
Hitonabi 903f3bc074 Phase 2 Drei-Welten-Plan: No-Progress-Bremse + Betrieb-Playbook + mem0-Lern-Buendelung
- no-progress-bremse.py: generische, turn-uebergreifende Schleifenbremse (pre/post_tool_call);
  Muster-Hash statt hartkodierter Fehler-Strings, Selbst-Lernen via neue-signaturen.jsonl.
  Schliesst die Luecke der nativen guardrails (pro Turn, nur klassifizierte Fehler).
- ensure-profile-hooks.py: kann Eintraege in BESTEHENDE Event-Bloecke einfuegen
  (zweiter pre_tool_call-Key haette den Tabu-Guard lautlos verschluckt) + registriert Bremse.
- betrieb-playbook-Skill: SSH-/Deploy-Diagnose-Checklisten (Passphrase-Falle zuerst),
  Triage-Regel im Steckbrief-Hook; Skill wird in alle Profile gesynct.
- mc2-memory: Lern-Extraktion gebuendelt (4 Turns oder 180 s idle = EIN Lauf) statt pro Turn -
  Extraktion konkurrierte mit Lucys Hirn um die Slots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:21:18 +02:00
Hitonabi 331aafa25d gitea-repo-create: dediziertes Anlage-Token (Push-Token bleibt unberuehrt)
Sicherer als .git-credentials zu ueberschreiben: Anlage-Token (write:user) aus
$GITEA_CREATE_TOKEN oder ~/.config/gitea/create-token; Push/PR-Token in
~/.git-credentials bleibt unangetastet (kein Bruch, falls das neue Token kein
write:repository hat). User/Host nicht-geheim mit Defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:43:31 +02:00
Hitonabi cab748f32e Lucy kann Projekt-Repos anlegen (gitea-repo-create.sh) - PRIVAT-only
Lucke im "Projekte selbst starten"-Weg: Werkstatt konnte nur Branches in
bestehende Repos proposen, kein neues Projekt-Repo anlegen. Neues Skript:
POST /api/v1/user/repos, IMMER privat (Gitea ist oeffentlich erreichbar!),
auto_init + default main, Token aus ~/.git-credentials (nie ausgegeben),
Audit-Log + stiller Chronik-Spiegel je Anlage.
Braucht Token-Scope write:user (aktuelles Box-Token hat nur write:repository);
ohne das meldet das Skript GENAU, welches Token in Gitea zu generieren ist,
statt kryptisch zu scheitern. Token-Upgrade = Commander-Handgriff (Credential).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:29:40 +02:00
Auftragsbuch d91bd5a139 Auftragsbuch: 'wartung/gitea-disable-registration' angenommen (Ein-Klick-Gate) 2026-07-17 17:30:42 +02:00
Hitonabi f21fe90090 feat(gitea): DISABLE_REGISTRATION=true & Security-Memo draft 2026-07-17 17:24:53 +02:00
Hitonabi 7b826f66c4 Venv-Audit: grep -c Zaehl-Bug (doppelte Zeile -> Rechenfehler)
Erstlauf-Befund: "grep -c ... || echo 0" haengte bei 0 Treffern eine zweite
Zeile an (grep -c druckt selbst schon "0") -> "integer expected" + kaputte
Summe. || echo 0 raus, Ergebnis auf Ziffern reduziert. Fund selbst war echt:
1 CVE in mem0, 27 Pakete veraltet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:03:10 +02:00
Hitonabi e556dcc853 Venv-Audit: uv-venvs haben kein pip - Inspektion via Audit-venv pip --path
Erstlauf-Befund: mem0-/voice-venvs sind uv-erstellt (kein bin/pip, kein
pip-Modul). Das Audit-venv prueft deren site-packages jetzt von aussen
(pip list --outdated/--format=freeze --path) - Dienst-venvs bleiben unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:59:24 +02:00
Hitonabi b26fb40cbc Restore-Probe: --ignore-ownership (Erstlauf-Befund) + Tarball-Grep nur Dateien
Erstlauf fand echten Stolperstein: pxar-Restore extrahiert als User korrekt,
scheitert aber am chown root-eigener Dateien (llamaswap) -> Fehl-Exit trotz
intakter Daten. Restore-Aufrufe jetzt mit --ignore-ownership/--ignore-acls
(uns interessiert der INHALT); llamaswap-Fallback prueft den Inhalt statt des
Exit-Codes. Kern-Datei-Grep matcht nur noch Dateien, keine Verzeichnisse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:55:28 +02:00
Hitonabi bc60c6fefc Betriebs-Luecken: Restore-Probe, Venv-Audit, Disk-Waechter, PC-Watch
Scope-Entscheid 17.07.: MC2 bleibt Box-Zentrale (KEINE Homelab-Zentrale) -
Infra-Radar (Proxmox/PBS/QNAP/Gitea) geht als eigenes Projekt an Lucys Queue.
- restore-probe.sh (Cron 1. d. M. 06:40, no-agent): stellt WIRKLICH wieder her -
  PBS-Canary aus hermes.pxar (selektiv via --pattern, sudo-frei ueber die
  User-Unit-Zugaenge) + Tarball-Probe + Frische-Check <36h; Erstlauf-Fallbacks
  (llamaswap.pxar / Kern-Datei) bis der Canary in den Sicherungen ankommt.
- venv-audit.sh (Cron 2. d. M. 06:40, no-agent): pip outdated + pip-audit-CVEs
  fuer mem0-/voice-venv ueber eigenes Audit-venv; Funde -> Kanban-Karte je Monat,
  Update bleibt Commander-Entscheid.
- self-smoke: Disk-Waechter (Check 7, Schwelle 85%, nennt die groessten Brocken).
- Trend-Radar-Watchlist: neue Typen github_release_major + pypi; Eintraege
  electron-major (PC-Shell auf 33!) und pocket-tts (PyPI-Versionswatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:50:48 +02:00
Hitonabi de21e1ac68 Pruefstand+Radar: MTP-Fairness, Tempo-Baseline-Refresh, Puls-Langzeit-Historie
Drei Luecken aus der Selbst-Review 17.07.:
1. MTP-Fairness: Kandidaten mit MTP-Kopf (Dateiname oder nextn-Tensoren im
   GGUF-Kopf) bekommen eine Zusatz-Tempomessung mit draft-mtp; Steckbrief
   rechnet best-of speed/speed_mtp - das Live-Hirn laeuft schliesslich MIT MTP.
2. Baseline-Refresh: baseline.json traegt jetzt den Engine-Build; weicht die
   installierte Engine ab (So-Auto-Update), misst der Pruefstand-Cron nur den
   Tempo-Teil aller Rollen neu (Geister-Zahlen-Schutz, Qualitaet bleibt gueltig).
3. Langzeit-Historie: Puls wird als JSONL angehaengt statt ueberschrieben;
   Radar vergleicht zusaetzlich gegen ~8 Wochen zurueck und meldet
   SCHLEICHENDE REGRESSION, die woechentlich unter der 10-%-Schwelle bleibt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:28:27 +02:00
Hitonabi 9c03316755 Trend-Radar: mechanische Amtsinhaber-Sperre (Testlauf-Befund 17.07.)
Der erste echte Radar-Lauf schlug prompt das eigene Hirn-Repo
(unsloth/Qwen3.6-35B-A3B-MTP-GGUF) als hermes-Kandidat vor - Zitat-Gate korrekt
passiert, aber Repackaging des Amtsinhabers ist nie ein Kandidat. Jetzt doppelt:
Raster-Regel + mechanischer Block ueber Modellnamen-Token im Gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:47:28 +02:00
Hitonabi f5e362e902 Pruefstand: Vision-Checkliste beugungsfest (Baseline-Befund 17.07.)
Beide Augen-Modelle beschrieben das Testbild korrekt ("in der oberen linken
Ecke"), scheiterten aber an undeklinierten Pflicht-Stichworten ("obere linke").
Gruppen um Beugungs-Stämme ergänzt (oberen link / unteren recht).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:29:50 +02:00
Hitonabi 2be599bfe9 Pruefstand-Harness: Think-Blöcke strippen + Budgets rauf (Baseline-Befund 17.07.)
Rohe :8080-Aufrufe an Denk-Modelle (Qwen3.6) liefern <think>-Monologe im content:
der Deutsch-Richter benotete Grübel-Text (Note 1-3), das Zitat-Gate suchte darin,
und 900 max_tokens gingen komplett fürs Denken drauf (leere Antworten).
- inhalt_von() entfernt <think>-Blöcke; abgeschnittenes Denken = ehrlich leer
- recherche 1200->2800, deutsch 900->2400 max_tokens (Denkbudget einpreisen)
- Fehlschläge speichern jetzt antwort_auszug (Diagnose ohne Nachstellen)
- pv-anteil: überstrenges Pflicht-Stichwort 412 entfernt
Mock-E2E nach Patch: coding 6/6, recherche 4/4; Think-Strip-Unit-Probe grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:09:06 +02:00
Hitonabi b0dce954e9 Trend-Radar + Pruefstand: Box beobachtet Modell-/Engine-Trends und testet Kandidaten selbst
- trend-radar-feed.sh (Cron Sa 05:15): Live-Puls ALLER Rollen mit Vorwochen-Vergleich,
  Engine-A/B gegen neuen llama.cpp-Build (Fruehwarnung vor So-Auto-Update), mechanische
  Watch-Liste (MMQ-Issue, ROCm-Releases, Community-Grid), HF-Kandidaten-Suche mit
  Zitat-Gate -> max. 1 Pruefstand-Kandidat/Woche
- pruefstand.sh + harness.py (Cron So 01:00, no-agent): volles Programm je Kandidat -
  6 Coding-Aufgaben mit echten Unit-Tests, 4 Agenten-Aufgaben (Tool-Loop + Endzustand),
  4 Recherche-Fragen mit Zitat-Ehrlichkeits-Gate, Deutsch-Richter, Vision-Testbild,
  Tempo kurz+lang; Baseline der Amtsinhaber als Vergleichsmassstab
- Leitplanken (User-Entscheid 17.07.): Download-Deckel 35 GB (drueber -> Karte),
  1 Kandidat/Woche, Cache mit Auto-Aufraeumen, RAM-/Platz-Wache, Bench-Port :5899,
  Radar/Pruefstand EMPFEHLEN nur - Rollen-Wechsel bleibt Commander-Klick
- E2E bewiesen (Mock-LLM): coding 6/6, recherche 4/4 inkl. Zitat-Gate, Agent-Roundtrip,
  Richter-Parsing; Suiten-Selbsttest mit Referenzloesungen 6/6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:57:07 +02:00
Auftragsbuch 8579fe9934 Auftragsbuch: 'wartung/mc2-gateway-venv-check' angenommen (Ein-Klick-Gate) 2026-07-17 12:15:27 +02:00
Auftragsbuch 32c24d7b60 Auftragsbuch: 'feature/skill-inactive-filter' angenommen (Ein-Klick-Gate) 2026-07-17 12:14:22 +02:00
Hitonabi 547851f1c3 feat: Skript zur Filterung inaktiver Skills
- filter_inactive_skills.py: Filtert Skills mit use_count=0 oder last_used_at=null
- Ausschluss von Media-Skills (gif-search, heartmula, songsee, youtube-content)
- Ausgabe sortiert nach last_used_at (älteste zuerst)

Hinweis: Die Dateien liegen im scripts/ Verzeichnis, da es dort keine
semantisch passendere Location gibt. Das Skript ist für den Hermes Skill-Index gedacht.
2026-07-17 04:27:16 +02:00
Hitonabi 1daacea468 feat(gateway): Prüfskript für Python-Interpreter im MC2-Virtualenv
- check_venv.sh prüft Existenz + Ausführbarkeit des Interpreters
- Gibt bei Erfolg 0 zurück, sonst 1 mit klarer Fehlermeldung
- Wird als ExecStartPre= in der systemd-Unit eingebunden

Hinweis für Commander: Die systemd-Unit deploy/mc2-gateway.service
muss nach Annahme manuell um ExecStartPre=... erweitert werden.
2026-07-17 03:53:19 +02:00
Hitonabi 5aded967ef Gateway: Kontext-Limit-Warnung ignoriert Warm-Pings (max_tokens<=16)
Lucys Augen-Waermer (alle 10 min, max_tokens=1 an "vision"), warmup.sh und
models/load enden konstruktionsbedingt mit finish_reason=length — die Warnung
vom 15.07. hielt jeden Ping fuer einen abgerissenen Worker und spammte den
Briefkasten (~alle 20 min, Serie 16.07. abends). Wer freiwillig auf <=16
Tokens deckelt, will keine echte Antwort -> keine Warnung; echte Abrisse
melden weiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:01:05 +02:00
Hitonabi a22f27bc60 Gateway: No-Think-Schnellspur fuer Lucys Voice-Turns (Hirn-Latenz 6-21s -> <1s)
Live-Diagnose 16.07.: Die 'Hirn'-Zeit der Voice-Turns (Dashboard 6,4-21,5s) war fast
vollstaendig Qwen3.6-Reasoning VOR dem ersten sprechbaren Wort (reasoning_content
2500+ Zeichen bei ~100 t/s; /no_think-Softswitch wird ignoriert; Prefix-Cache war
NICHT das Problem - live gemessen: Zeitstempel-Aenderung kostet nur ~0,8s).

Fix: Lucy traegt Marker [[MC:NO_THINK]] im System-Prompt; das Gateway strippt ihn
aus allen Messages (konstanter Text -> Prefix-Cache stabil) und setzt
chat_template_kwargs enable_thinking:false. Direkt am Hirn gemessen: 9,9s -> 0,8s,
Persona-Qualitaet unveraendert. Requests ohne Marker (Crons/Telegram/Werkstatt)
denken unveraendert weiter. Abschaltbar via MC_NO_THINK_MARKER="".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:55:14 +02:00
Hitonabi 31fde2a68e self-smoke/postcheck: Browser-Tool-Check + Selbstheilung (agent-browser-Link-Falle, Vorfall 16.07.)
Hermes' browser_navigate starb still als "Failed to open <url>": das
Hermes-Update 15.07. 23:53 (npm ci --workspace) bog ~/.local/bin/agent-browser
per postinstall auf node_modules/ um und loeschte das Paket im selben Lauf
wieder -> toter Symlink. Kein fehlender MCP/Skill.

Neu, damit die Box das OHNE Zuruf erkennt und heilt:
- self-smoke.sh Check 6: agent-browser --version; bei totem Link auf das
  npm-global-Binary (~/.local/lib/node_modules/...) zurueckbiegen und
  hermes-gateway/hermes-builtin-ui try-restarten (browser_tool cached
  "nicht installiert" pro Prozess). Selbstheilung wird als Info gemeldet
  (neuer HEALED-Kanal), nicht als Alarm; unfixbar -> Rot wie gehabt.
- hermes-postcheck.sh: gleicher Check direkt nach jedem Hermes-Update,
  denn genau dort entsteht der Bruch.

Heil-Logik isoliert auf der Box getestet (Fake-HOME + systemctl-Stub):
toter Link -> geheilt+Restart; danach PASS ohne Restart; Binary weg -> FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:38:15 +02:00
Hitonabi 0890698750 Auftragsbuch: Ergebnis-Panel klebt nicht mehr auf Fehler (nochmal versuchen)
Fiel der erste Ladeversuch in ein Deploy-Fenster (Zentrale startet nach
Karten-Annahme kurz neu), blieb "Ergebnis nicht ladbar" dauerhaft stehen,
obwohl die API das Ergebnis liefert (Vorfall 16.07., t_2a7cfd46). Die
Fehlzeile ist jetzt ein Klick-Retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:08:19 +02:00
Hitonabi 77dfac79ee selbst-inventur: Diff-Filter warf alle Bullet-Zeilen weg (immer "0 Aenderungen")
Steckbrief-Zeilen sind Bullets ("- ..."), im Diff also "+- "/"-- " — der alte
Filter ^[+-][^+-] (gedacht gegen +++/----Kopfzeilen) verschluckte damit jede
echte Aenderung. Jetzt werden nur die Datei-Kopfzeilen ausgefiltert.
Beweis 16.07.: realer Nacht-Diff hatte 7 Aenderungen, Meldung sagte 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:04:58 +02:00
Hitonabi fa22435d7c tabu-pfade-guard: V16 — relative Pfade bei riskantem cwd blocken (Vorfall 16.07.)
Nacht-Worker t_7a05992a ersetzte 03:59 den Live-Checkout mit cwd=$HOME und
RELATIVEN Pfaden (rm -rf mission-control-v2 && git clone ...) — der Guard
prueft beim terminal-Tool nur den Kommandotext auf home-verankerte Formen
und liess das durch (.venv weg -> Gateway 203/EXEC-Crash-Loop).

Drei Stopfen:
(1) cwd IN einem Tabu-Pfad -> jede Schreib-Operation geblockt (Lesen frei).
(2) git clone/init mit Ziel direkt in $HOME geblockt (Worker klonen im
    Task-Workspace); explizites Ziel ausserhalb $HOME bleibt erlaubt.
(3) cwd == Vorfahr eines Tabu-Pfads -> relative Nennung des Tabu-Ziels
    zaehlt als Treffer; Lookarounds lassen den Workspace-Klon-Pfad
    (.hermes/kanban/workspaces/<id>/mission-control-v2) weiter durch.
Nebenbefund zu: clone fehlte in der git-Schreibliste (a) — auch
git clone URL ~/mission-control-v2 war vorher ungeblockt.

Lokal bewiesen: 35/35 Payload-Faelle (Vorfalls-Kommando geblockt,
Workspace-Arbeit/Lesen frei, Alt-Verhalten regressionsfrei).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:02:35 +02:00
Auftragsbuch 4df6a3ecf9 Auftragsbuch: 'wartung/parse-mc2-timeout-user-journal' angenommen (Ein-Klick-Gate) 2026-07-16 11:01:48 +02:00
werkstatt d697f3cc0d fix: parse_mc2_timeout.py auf User-Journal umstellen (--user) 2026-07-16 11:00:33 +02:00
Auftragsbuch fd241aca51 Auftragsbuch: 'wartung/parse-mc2-timeout' angenommen (Ein-Klick-Gate) 2026-07-16 10:39:41 +02:00
werkstatt d2d726c105 wartung: Skript zum Parsen von systemd-Timeout-Fehlern (parse_mc2_timeout.py)
Einfaches Python-Skript, das journalctl nach 'State stop-sigterm timed out' durchsucht und Ergebnisse mit ISO-Zeitstempel in ~/logs/mc2-timeout.log schreibt. Bereitet Berechtigungsfehler sauber auf.
2026-07-16 04:08:10 +02:00
Hitonabi 71192b3db0 Zeitachse (24h-Historie), Verbinden-Autofill, Modelltausch abgesichert
Paket 1 - Metrik-Historie mit Zeitachse (User: 'WANN war Last?'):
- services/metrics_history.py: 10s-Sampler (CPU/RAM/GPU/Disk + Token-Totale) in
  24h-Ringpuffer (aelteres faellt automatisch raus, nichts waechst unbegrenzt),
  persistiert alle 5 min -> uebersteht Deploys; GET /api/system/history?minutes
  mit Downsampling auf ~300 Punkte; Lifespan-Task in app.py
- Cockpit-Karten System-Status + Token-Durchsatz: Bereichs-Schalter Live/1h/24h,
  sichtbare Zeitachse (HH:MM:SS bei kurzen, HH:MM bei langen Fenstern), Tooltip
  zeigt Uhrzeit; Token-Raten in 1h/24h aus Totale-Deltas

Paket 2 - Verbinden selbsterklaerend:
- Box-IP vorbefuellt aus window.location.hostname (Dev-Fallback bleibt)
- MCP-Scriptpfad-Feld aus der Kopfzeile in die 'Gedaechtnis anbinden'-Karte
  verschoben, mit Erklaerung WANN man es braucht

Paket 3 - Modelltausch-Loecher gestopft (Review 16.07.):
- install: Registrierung OHNE Alias-Umzug; Rolle wird erst NACH erfolgreichem
  Download uebernommen (on_done) - hermes dabei durch den warm-bewussten
  set_agent_brain-Flow statt rohem Alias-Move (Lucy waere sonst bis Download-
  Ende tot gewesen); Re-Install erhaelt bestehende Aliase
- set_role_alias: lebenswichtige Aliase (hermes/embed) des Ziel-Modells
  ueberleben jeden Rollen-Klick (Qwen3.6 haelt live hermes+fast!)
- Rollen-Endpoint verweigert Rollen-Wechsel am Halter geschuetzter Aliase
  mit klarer Anleitung; Werkbank sperrt die Rollen-Chips sichtbar

Verifiziert: py_compile + Sampler-Smoke (2 Punkte, Persist ok) + Alias-Logik-
Unittest (3 Faelle) + Browser (Zeitachse tickt, Schalter, Leerzustand, Verbinden).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 01:08:03 +02:00
Hitonabi 26224804ed Tab-Review-Fixes: toter Gedaechtnis-Knopf, Konsole-Restart, Cockpit-Dopplung
- Gedaechtnis: 'Im Terminal starten' navigierte zu nicht existenter View 'terminal'
  -> 'konsole'; interne Duplikate (AUTO-Set, CAT_LABEL_MAP) entfernt
- Konsole: box-console jetzt in Dienste-Liste + Restart-Allowlist; Offline-Overlay
  bekommt Ein-Klick-'Dienst neu starten' statt SSH-Anweisung
- Cockpit Werkzeuge: Doppel-Eintrag ('Box-Shell' + 'Interaktives Terminal', beide
  -> Konsole) zu EINEM Eintrag 'Konsole (Box-Shell)' zusammengelegt
- Auftragsbuch: 'Nichts zu entscheiden'-Banner beruecksichtigt blockierte
  Ideen-Karten; Abschnitt heisst neutral 'Patch-Vorschlaege'
- Hermes-Diagnose: Klickweg (Dienste-Schublade oeffnen) statt veraltetem
  Drawer-Namen + systemctl-Kommandos
- Entdecken: Upgrade-Knopf uebernimmt fremde Quant nicht mehr blind
- Skills: Status-Badge zeigt Klartext (abgeschaltet/defekt/...) statt rohem State

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:42:04 +02:00
Hitonabi bc60f9254b Verbinden-Tab: eine Geschichte statt Lanes-vs-Modelle-Widerspruch
- Hero 'Leitung 1' zeigte 'Lanes chat / coding', alle Snippets nutzen aber die
  ECHTEN Modelle (Verdikt 09.07.: IDE-Welt = coder/heavy/vision, kein Lane-Routing)
  -> Zeile zeigt jetzt die Modelle; Fusstext erklaert die Lanes als Zusatz
- Kilo-Note entwirrt: 'Ask/Debug -> Lane chat' als virtuelles Modell benannt
- Anleitung Par.10 (IDE-Tools) auf dieselbe Story (coder/heavy/vision statt chat/coding)
- connect.py: stale '_gw'-Kommentar (MC2 serviert /v1 selbst) auf UMBAU-v3-Stand;
  check_health nutzt HERMES_BUILTIN_UI_UPSTREAM statt hartem 127.0.0.1:9119

Live geprueft vor dem Fix: Lanes chat/coding/auto funktionieren (200), stehen aber
nicht in /v1/models; /v1/messages weiterhin 404 (Claude-Code-Hinweis korrekt);
hermes-ui 200 + Token-Datei existiert (Anleitung stimmt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:32:12 +02:00
Hitonabi 0c69c491e5 Single Source of Truth: Frontend/Backend-Ungereimtheiten ausgeraeumt (Review 15.07.)
Backend (Wahrheit reparieren):
- gateway_reachable() prueft jetzt den ECHTEN mc2-gateway (:9010 via V1_UPSTREAM)
  statt nur die Engine - toter Denkpfad war in Health/Cockpit unsichtbar
- /api/system/services vollstaendig: + LLM-Gateway, + Steuerpult, + Waechter
  (mc2-steward via is-active); scope-Feld (user/system); mc2-gateway/mc2-steward
  in die Restart-Allowlist
- Connect-Health Leitung 1 prueft den Client-Pfad (V1_UPSTREAM statt llama-swap)
- agent_status liefert pc_executor_url (UI zeigte hartcodierte IP/Ports)
- SSE-Endlosschleife gefixt: /api/auftragsbuch schrieb announce-branches bei JEDEM
  Aufruf neu -> mtime-Bump -> invalidate -> Refetch im 3-s-Takt je Client;
  jetzt nur noch bei echter Aenderung

Frontend (ein Datenweg):
- SystemDrawer komplett auf React-Query-Hooks (vorher eigenes 3-s-setInterval auf
  dieselben Endpunkte inkl. teurem Updates-Check); Dienste-Liste = Backend-Antwort
  (SERVICES-UI-Kopie geloescht); useDialog statt Eigenbau; fmtBytes statt Duplikat
- useLucyHealth: Dienst-Matching per systemd-unit statt Namensfragment; Gateway-
  Reparatur zielt auf mc2-gateway; neuer Waechter-Check; Backend-tot => ehrliches
  rotes Verdikt statt eingefrorener letzter Stand (auch Sidebar)
- Toter Code raus: views/models/Cockpit.tsx (908 Z.) + RoleAssignModal + Placeholder;
  LaneEditor (Routing-Policy) + WarmSetManager damit ZURUECK im UI als Tab
  "Routing & Warm-Set" im Modell-Manager
- Cockpit: blockierte Ideen-Karten erscheinen in "Braucht dich" + Kachel-Hint;
  Verbinden-Kachel zeigt 3 Leitungen live statt hartem "Zed"
- Maschinenraum: ehrliches "frei" (reale Belegung) + eigenes KV-Cache-Segment
- AgentView: Ports/PC-Adresse aus der API; Guide lehrt Lanes chat/coding
- queries.ts: useModels nutzt SSE-relax; Chronik-Limit im Query-Key;
  useJobs/useZeitmaschine mit enabled-Schalter

Verifiziert: tsc+vite gruen, Backend-Smoke beide Gateway-Modi, UI live gegen die
Box (Cockpit/Werkbank/Routing-Tab/Drawer rendern mit Echtdaten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 22:27:32 +02:00
Auftragsbuch b36561990e Auftragsbuch: 'doku/sofort-paket-erledigt' angenommen (Ein-Klick-Gate) 2026-07-15 22:08:21 +02:00
Hitonabi 41b0edba53 UMBAUPLAN: P1 KOMPLETT - Stufe 2 gefahren + Neustart-Beweis bestanden
Cutover 15.07. 21:26 (alle 14 Hermes-base_urls auf :9010, Backups).
Abschlussbeweis 21:27: MC2-Restart mitten im Betrieb, Denkpfad :9010
antwortete durchgehend HTTP 200 - null Aussetzer. Reine Doku.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:28:18 +02:00
Auftragsbuch b7e6cf4acc Auftragsbuch: 'doku/sofort-paket-erledigt' angenommen (Ein-Klick-Gate) 2026-07-15 19:45:45 +02:00
Auftragsbuch f271b04754 Auftragsbuch: 'feature/pydantic-response-models' angenommen (Ein-Klick-Gate) 2026-07-15 19:44:20 +02:00
Hitonabi 78bff28acf UMBAUPLAN: P1/P2-Status auf AKTIV getrued (beide angenommen+verifiziert)
Fahrplan-Punkte 1-3 als erledigt markiert (inkl. Bild-Weiche-Fix d2398b3
und 256er-Testbild-Lehre); einzig offener Schritt ist Punkt 4 = Stufe 2
(gateway-cutover.sh). Reine Doku - Bagatell-Klasse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:43:22 +02:00
Hitonabi ff6ef3eb1f UMBAUPLAN: Sofort-Paket (Abschnitt 3) als komplett erledigt markiert
Alle vier Punkte live verifiziert (15.07. abends): Bild-Weiche-Karte
angenommen+E2E, t_8231d6b8 abgeschlossen, sudo-Runde durch (v1-Unit/opt
restlos weg, Root-Warmup-Kopie byte-identisch mit Repo), Briefing-Cron
auf unendlich. Reine Doku — Bagatell-Klasse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:42:09 +02:00
Hitonabi 21ea7e1dd3 backend: Pydantic response_models für 5 GET-Endpoints hinzufügen
- health.py: HealthResponse
- eigenleben.py: EigenlebenOverviewResponse + SkillTextResponse
- chronik.py: ChronikResponse + ChronikItem
- wissen.py: WissenListResponse + WissenFileResponse
- reminders.py: ReminderListResponse + ReminderOut

py_compile bestanden, /docs zeigt neue Schemata.
Response-Payloads unverändert — nur Typisierung hinzugefügt.
2026-07-15 19:33:39 +02:00
Hitonabi e91f6b7ba8 SSE-Eventstrom versoehnt: getesteter Kern + Modell-Quelle aus der Werkstatt-Karte
Die angenommene Karte feature/sse-backend-v1 ersetzte die bereits E2E-
getestete Hand-Implementierung mit drei harten Fehlern: 'type:' statt
'event:' (ungueltiges SSE-Framing - EventSource-Listener feuern NIE),
globaler Snapshot ueber alle Clients (verschluckt Events), Ideen-Zaehler
gegen nicht existierenden Endpunkt :9010/v1/ideen. Dazu doppelter
Router-Mount in app.py (Merge-Folge).

Zurueck auf den bewiesenen Kern (3s-Fingerprints, Basislinie je
Verbindung, is_disconnected, korrektes Framing) + die GUTE Idee der
Karte uebernommen: Running-Set der Modelle als Quelle (Laden/Entladen
invalidiert die Modelle-Ansicht). System-/Token-Metriken bleiben bewusst
beim Polling (aendern sich jede Sekunde = invalidate-Laerm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:30:27 +02:00
Auftragsbuch d0a967e765 Auftragsbuch: 'feature/sse-backend-v1' angenommen (Ein-Klick-Gate) 2026-07-15 16:10:08 +02:00
Hitonabi 87670dd3a6 self-smoke Check 5: Bild-Weiche v2 taeglich aktiv durchspielen
Der Pfad (Bild an coder -> Vision beschreibt -> Coder antwortet) brach am
15.07. zweimal still: erst als nie verdrahteter Patch in der Hermes-Quelle,
dann am gepoolten Keep-Alive-Socket nach Modell-Swap (:9010). 256x256-Rot
als Testbild - Winzbilder liefern VL-Halluzinationen (Kapuzinerkresse).
Nebeneffekt: waermt coder+vision morgens vor. Vorschlag der P2-Session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:58:52 +02:00
werkstatt 41e569cf99 backend: SSE-Eventstrom /api/events (UMBAU v3 P3a)
- Neuer Router routers/events.py mit EventSource-Endpunkt\n- Heartbeat alle 25s, invalidate-Event bei Status-Änderung\n- Keys: system-status, models, agent-status, ideen, token-stats
2026-07-15 15:38:32 +02:00
Hitonabi d2398b3624 Bild-Weiche v2: frischer Client + Retry fuer den Vision-Unteraufruf
Befund :9010 (Journal): httpx.ReadTimeout nach Sekunden trotz timeout=240 -
der gepoolte Keep-Alive-Client reicht Sockets wieder aus, die llama-swap
beim Modell-Swap gekappt hat. Der Beschreibungs-Unteraufruf nutzt jetzt
einen eigenen Kurzzeit-Client und versucht es einmal erneut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:30:36 +02:00
Hitonabi 202e97333a Bild-Weiche v2: stummen Fallback-except durch Logging ersetzen (Diagnose :9010)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:22:46 +02:00
Auftragsbuch 0eb2b37abc Auftragsbuch: 'wartung/skills-text-kategorie-fix' angenommen (Ein-Klick-Gate) 2026-07-15 15:10:52 +02:00
Hitonabi 0f13a4f18d Skills-View: Text-Endpoint matcht jetzt Kategorie-Skills ({skill_id:path})
Kategorie-Skills (z. B. autonomous-ai-agents/hermes-agent) haben einen
Schraegstrich in der ID. Die Route /api/eigenleben/skill/{skill_id} nahm nur
EIN Pfadsegment -> Request fiel in den SPA-Fallback (index.html, Status 200),
die UI zeigte "(Konnte den Skill-Text nicht laden.)". Live bewiesen:
skill/wartung = JSON, skill/autonomous-ai-agents%2Fhermes-agent = HTML.

Fix: :path-Konverter in der Route; dazu im Service "."/".."-Segmente
explizit abweisen (das Zeichen-Set der bestehenden Validierung liesse
".." als Segment durch, bisher irrelevant, mit :path sauber dicht).
Backend-only, kein dist-Rebuild noetig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:07:44 +02:00
Hitonabi 5d0a61c38e UMBAU v3 P3a handgebaut: SSE-Eventstrom /api/events (Invalidation-Bus)
User-Entscheid 15.07. abends: P3-Worker-Jobs abgebrochen (22 Anlaeufe,
0 Ergebnisse) - 'du bearbeitest sie ein letztes Mal manuell'.

P3a KOMPLETT: backend/routers/events.py streamt SSE; ein Sammler prueft
alle 3 s billige Fingerabdruecke (Briefkasten-Cursor, Queue-Status,
Auftragsbuch-/Reminder-mtimes) und schickt bei Aenderung 'invalidate'
mit den React-Query-Keys. frontend/src/lib/events.ts haelt EIN
EventSource, invalidiert gezielt und entspannt die vier gedeckten
Poller x5 (relax() in queries.ts); reisst der Strom, reconnectet
EventSource selbst und die UI pollt wie bisher. Live-Graphen bleiben
bewusst beim Polling (aendern sich jede Sekunde).

P3b (TanStack Router) BEWUSST NICHT umgesetzt: Hash-Navigation liefert
Deep-Links + Zuruecktaste bereits; Router-Migration = hohes Regressions-
risiko ueber alle Views bei minimalem Gewinn fuer 1-Nutzer-LAN (gleiche
Logik wie das React-bleibt-Verdikt in UMBAUPLAN 3b).
P3c (OpenAPI-Typen) VERTAGT: ohne Pydantic-response_models liefert der
Generator leere Typen - Voraussetzung ist erst das Backend-Typing
(eigene, klein geschnittene Karten-Serie).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:00:52 +02:00
Hitonabi 5dde4c9f5c Box kann jetzt Frontend bauen: Node LTS sudo-frei installiert, Hook-Wahrheit umgedreht
User-Entscheid 15.07.: 'Node sollte auf jeden Fall auf die Box - generell
alles, um sich selbst zu warten.' Node v24.18.0 (LTS) liegt unter
~/.local/node (offizielles Tarball, KEIN sudo), Symlinks in ~/.local/bin,
das im PATH des hermes-gateway (= aller Worker) bereits enthalten ist.
Bau-Beweis auf der Box: frischer Klon, npm ci + npm run build -> dist in
~4 s. Der Steckbrief-Hook weist Worker jetzt an, dist selbst zu bauen und
mitzucommitten (vorher: 'Box kann nicht bauen'-Vermerk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:51:04 +02:00
Auftragsbuch bc69cf5cc9 Auftragsbuch: 'feature/p2-steward' angenommen (Ein-Klick-Gate) 2026-07-15 14:47:55 +02:00
Hitonabi 7223ca96c1 Deploy-Robustheit: Selbst-Reset-Guard + TimeoutStopSec=5 (Lehren P1-Deploy 15.07.)
1) deploy.sh fuehrt sich nach dem git reset einmal frisch neu aus (exec-Guard,
   Env-Marke MC2_DEPLOY_REEXEC): der Reset ersetzt die laufende Datei, bash las
   einen alt/neu-Zeilen-Mix — beim P1-Deploy fielen so daemon-reload und der
   mc2-gateway-Start aus (Lucy 502, Heilung von Hand).
2) mc2-gateway.service TimeoutStopSec=5: uvicorn wartete beim Stop auf offene
   LLM-Streams (default 90 s SIGKILL-Fenster ohne Listener = 502-Fenster bei
   jedem Deploy-Restart, live gemessen 14:29->14:31). Stateless Proxy, Clients
   retrien — hart nach 5 s ist verlustarm.
UMBAUPLAN: Annahme-Hinweis fuer P2 (einmal deploy.sh von Hand nachlaufen lassen,
weil der Guard erst MIT diesem Deploy an Bord kommt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:47:35 +02:00
Hitonabi 3cf111d973 Kontext-Limit sichtbar machen: Gateway-Warnung bei finish_reason=length + Etappen-Regel
User-Wunsch 15.07. ('es sollte eine Warnung geben - Kontext ist nicht
unendlich'): 4 P3-Worker starben still an finish_reason=length.
- Gateway erkennt abgerissene Antworten (Stream-Chunk + Non-Stream) und
  meldet je Modell max. alle 10 min in den Briefkasten (silent -> Chronik),
  Zustellung per MC_ANNOUNCE_HTTP ans Steuerpult (Store bleibt Ein-Schreiber).
- Steckbrief-Hook: KONTEXT-BUDGET & ETAPPEN-REGEL - gezielt lesen, grosse
  Aufgaben in Etappen schneiden (kanban_create/complete mit Plan) statt
  am Limit zu sterben.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:43:36 +02:00
Hitonabi 3468bc9c80 Steckbrief-Hook: Repo-Wegweiser + Frontend-Wahrheit (kein node auf der Box)
User-Beobachtung 15.07.: jeder Worker kartiert das Repo neu (ls-Ketten,
geratene Pfade wie 'read queries.ts -> not found'). Der Hook sagt jetzt
jedem Worker-Turn, WO was liegt (Router/Services/Views/queries.ts/nav) und
die dist-Wahrheit: Box hat KEIN node/npm -> frontend/dist ist auf der Box
nicht baubar, Vorschlaege muessen das ehrlich vermerken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:36:51 +02:00
Auftragsbuch ba0458c0af Auftragsbuch: 'feature/p2-steward' angenommen (Ein-Klick-Gate) 2026-07-15 14:29:20 +02:00
Hitonabi fdabe1768e Skills-Ansicht: Umbenennung + aktiv/inaktiv + Karten-Ergebnisse im Auftragsbuch
User-Feedback 15.07.:
1) 'Von allein' heisst jetzt 'Skills' (id #eigenleben bleibt, Links halten).
2) Jeder Skill traegt aktiv/inaktiv - Quelle ist Hermes' EIGENE Wahrheit
   (hermes_cli.banner.get_available_skills via Hermes-venv, dieselbe Logik
   wie das eingebaute WebUI: Plattform/Voraussetzungen/Config). Bewusst NUR
   die Datenquelle wiederverwendet, nicht deren UI - Skill-VERWALTUNG bleibt
   im Hermes-GUI-Tab, alles andere waere Bloat/Doppelbau.
3) Auftragsbuch: fertige Queue-Karten sind aufklappbar und zeigen das
   ERGEBNIS des Workers (kanban_complete-Summary, lazy via
   GET /api/ideen/{id}/ergebnis) - 'Dinge werden getestet, ich sehe aber
   das Ergebnis nicht' ist damit zu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:22:05 +02:00
Auftragsbuch bf9fd808d8 Auftragsbuch: 'feature/von-allein-und-gateway-p1' angenommen (Ein-Klick-Gate) 2026-07-15 14:19:45 +02:00
Hitonabi a9c0239f8a UMBAU v3 P2: Steward — Waechter-Loops als eigener Prozess mc2-steward
Re-Warm, Health-Sentry und Mem0-Dedupe laufen als eigener Dienst
(backend/steward.py, Restart=always) statt im Steuerpult-Lifespan:
MC2-Neustarts nehmen den Waechtern nicht mehr Timing/Flanken-Gedaechtnis,
und der Sentry ueberwacht erstmals MC2 SELBST + mc2-gateway (Telegram
funktioniert auch bei totem Steuerpult; Briefkasten-Abgabe per HTTP via
MC_ANNOUNCE_HTTP, Store bleibt exklusiv beim MC2-Prozess). Warm-Nudge
nach Config-Aenderung via mtime-Watch (5 s) statt In-Process-Signal.
Reiner Konfig-Split: MC2-Unit setzt die drei ENABLED-Schalter auf 0,
Zeilen entfernen = Rollback. reminders_loop bleibt bewusst in MC2
(teilt Datei+CRUD mit /api/reminders, Zwei-Schreiber-Risiko).

Stellt ausserdem den beim Karten-Neuaufbau (ccc9a25) verlorenen
stack-postcheck-Block fuer mc2-gateway wieder her (+ Steward-Check 4c).

Baut auf feature/von-allein-und-gateway-p1 auf; Annahme schliesst P1 ein.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:15:36 +02:00
Hitonabi ccc9a25a25 UMBAU v3 P1: Gateway-Auszug — /v1 wird eigener Prozess mc2-gateway (:9010)
ACHTUNG: Annahme AKTIVIERT die Stufe 1 (deploy.sh installiert
mc2-gateway.service + setzt MC_V1_UPSTREAM in der MC2-Unit; Health mit
Kaltstart-Retry bis 12s, dann hart rot -> Runner-Rollback greift).
MC2 :9001/v1 wird duenner Roh-Weiterleiter, LAN-Clients merken nichts;
Rollback = MC_V1_UPSTREAM-Zeile aus der Unit entfernen. token_stats
laedt bei Fremd-Aenderung per mtime nach (Gateway schreibt, Steuerpult
liest). UMBAUPLAN Abschnitt 3b dokumentiert P1-P4. Stufe 2 (Lucy direkt
an :9010, ueberlebt MC2-Neustarts) = deploy/gateway-cutover.sh, separat.

Neu aufgesetzt 15.07. auf aktuellem main (a3d9c74): die urspruengliche
Karte trug die inzwischen veraltete Von-allein-View doppelt - die ist
laengst auf main live. Inhalt = P1 der Parallel-Session, unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:51:51 +02:00
Hitonabi a3d9c745cd Von allein: Punkt-Ordner (.archive) nicht als Skills listen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:49:15 +02:00
Hitonabi b0eec715c8 Von allein: Kategorie-Skills (2. Ebene) mitzeigen (research/blogwatcher & Co.)
Die mitgelieferten Hermes-Skills sind Kategorie-Ordner (DESCRIPTION.md +
Unter-Skills mit eigener SKILL.md) - der Walker lief nur ueber Ebene 1
und zeigte 12 statt ~40 Skills. Jetzt beide Ebenen, Unter-Skills mit
Kategorie-Badge; Detail-Endpunkt erlaubt Kategorie/Name-Pfad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:47:53 +02:00
Hitonabi 86bd72838a Neue Ansicht 'Von allein': Skills + Vorschlags-Bilanz der Box (+ schlafende P1-Basis)
User-Wunsch 15.07.: EIN Viewpoint, was die Box selbst geleistet hat -
Chronik ist zu kompliziert. Neue Operativ-Ansicht #eigenleben:
- Skills aus ~/.hermes/skills (= Lucys Satz) mit Klartext-Beschreibung,
  Nutzungszaehler (.usage.json) und Herkunft (selbst erstellt / von uns
  gebaut / mitgeliefert), aufklappbar mit vollem SKILL.md.
- Vorschlags-Bilanz: angenommen (Auftragsbuch-Chronik) + abgelehnt mit
  Grund (Lern-Journal) als eine Timeline + Zaehler.
Backend: services/eigenleben.py + routers/eigenleben.py (read-only).

Mit an Bord (User-Ja, SCHLAFEND): config.V1_UPSTREAM + app.py-Weiche +
gateway_forward aus dem P1-Gateway-Auszug - ohne MC_V1_UPSTREAM in der
Unit exakt altes Verhalten. Der aktive Rest von P1 kommt separat ueber
Branch umbau/p1-gateway-auszug (dort liegt auch diese View schon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:45:04 +02:00
Hitonabi 734cafd98f R5: git-init-Orphans mechanisch verhindern (Guard-Block + Orphan-Gate)
Drei Naechte in Folge bauten Worker git-init-Branches ohne gemeinsamen
Ursprung mit main (cleanse-skill-index 12.07., skill-kanten-generator
13.07., blogwatcher-logging ~14.07.) - bei der Annahme technisch tot,
Nacht-Arbeit verloren. SOUL-Prosa allein driftet (Meta-Lehre Fallstrick-
Audit) -> jetzt mechanisch: tabu-pfade-guard blockt 'git init' im
Kanban-Workspace hart; pre-verify-gates prueft VOR dem Fertigmelden
merge-base gegen origin/main (Orphan-Gate ROT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:25:03 +02:00
Hitonabi abc8a365d2 Review-Haertung R1/R2/R4 + Repo-Waechter (Vorfall verlorener Merge 12.07.)
R1: Steckbrief-Hook erinnert Worker an kanban_complete/kanban_block-Pflicht
    (Nacht-Karte 14.07. blockierte trotz fertiger Arbeit am fehlenden Aufruf).
R2: tabu-pfade-guard sperrt ~/.config/systemd/user fuer Worker — Unit-/
    Override-Dateien nur noch ueber angenommene Karte.
R4: Orchestrator-/Wartungs-Worktrees von /tmp nach ~/.hermes/worktrees
    (ueberleben Reboot, keine kaputten Registrierungen).
Neu: self-smoke Check 4 'Repo-Waechter' — Box-main mit Commits, die origin
    fehlen, loest Alarm aus (so ging die angenommene Karte
    wartung/lucy-annahme-fixes am 12.07. still verloren; am 15.07. als
    rescue/-Branch gerettet und wieder gemergt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:04:16 +02:00
Hitonabi 1cd492eb33 Merge branches 'wartung/bild-weiche-v2' and 'doku/umbauplan-abloesung' 2026-07-15 10:03:18 +02:00
Hitonabi 3abc133118 Umbauplan Abschluss-Review 15.07.: IST-Befund, Konfig-Urteil, priorisierte Arbeitsliste
Komplettes Projekt-Review (Repo+Box live+Recherche). Enthaelt Sofort-Paket
(Klicks+sudo-Runde), Robustheit/Aufraeumen/Features/Tempo-Karten und die
Uebergabe-Logik fuer die Zeit nach Claude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:52:11 +02:00
Hitonabi 0354ce999f Bild-Weiche v2: Coder-Ziele bekommen Vision-BESCHREIBUNG statt Umleitung
Idee aus verwaistem (nie verdrahtetem) Patch in der Hermes-Quelle
api_server.py - regelkonform in den MC2-Gateway umgezogen, Original
als docs/archiv/hermes-api_server-vision-patch-verwaist.diff archiviert.
Request mit Bild an coder: Bilder werden vorab von VL-30B beschrieben
und als Text injiziert, die Code-Frage bleibt beim Spezialisten.
Fallback bei Analyse-Fehler: bisherige Vision-Umleitung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:46:36 +02:00
Hitonabi 960bda6021 fremdblick.sh: Prosa-Default 2200 -> 4000 Tokens (Reasoning-Modell-Headroom)
GLM-4.7-Flash (Prosa-Kritiker) ist ein Reasoning-Modell und verbrennt real ~2000
Tokens im reasoning_content, bevor das Urteil in content landet (verifiziert 14.07.).
Bei 2200 schnitt es Gefahr mitten im Denken ab -> leeres content -> FEHLGESCHLAGEN;
das traf die naechtlichen Kritiker mit Default-Budget (Traum-Gate etc.). Der
Release-Radar ueberschreibt ohnehin auf 4500; der Code-Modus (Qwen-Coder, 1600) ist
kein Reasoning-Modell und bleibt unberuehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:54:07 +02:00
Hitonabi 5f6e3c77d4 Konzept-Fliessband: Skill fuer wasserdichte Konzepte (heavy denkt, Skeptiker haertet)
Neuer Skill konzept-fliessband: rohe Idee -> mehrere Fach-Rollen (heavy/gpt-oss-120b
waehlt den Cast) schaerfen ein Konzept in Runden, ein Advocatus Diaboli (fremdblick/GLM)
haertet bis wasserdicht (max 3 Runden) -> Konzept-Dokument als Vorlage fuer projekt-start
in Hermes Desktop. Propose-only, KEIN Code.

Behebt das delegate_task=coder-Loch: Konzept-/Recherche-Arbeit lief bisher stumm auf dem
Code-Modell, weil natives delegate_task hart auf delegation.model=coder verdrahtet ist und
pro Aufruf kein Modell waehlen kann.

Gehaertet gegen die Reasoning-Modell-Token-Falle (heavy & GLM antworten erst in
reasoning_content): WORKER_MAXTOK=6000 / FREMDBLICK_MAXTOK=4000 + Kaltstart-Retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:30:13 +02:00
Hitonabi 85a744ac7b Faden 11-Fix: ASCII im Routing-Header (kein '→' -> latin-1-500)
Bild-Request loeste HTTP 500: HTTP-Header muessen latin-1 sein, das '→'
(U+2192) im x-mc-route-reason war nicht kodierbar. Die Weiche selbst
routete korrekt (VL-30B lud). Pfeil auf '->' geaendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:54:57 +02:00
Hitonabi faf25bf76c Faden 8+5-Fix: Worker-Scope korrigieren + Profil-Hooks reproduzierbar
Zwei zusammenhaengende Bugs, beim echten Worker-Test entdeckt:

1) HOOKS FEUERTEN FUER WORKER NIE. Kanban-Worker laufen unter IHREM Profil
   (HERMES_HOME=~/.hermes/profiles/<name>) und lesen dessen config.yaml, NICHT
   die Top-Level ~/.hermes/config.yaml. Die neuen Hooks waren nur oben
   registriert -> fuer Worker inaktiv. FIX: ensure-profile-hooks.py registriert
   pre_llm_call + pre_tool_call in jeder Worker-Profil-config (idempotent,
   validiert, Backup); deploy.sh ruft es je Profil auf -> reproduzierbar.

2) FALSCHER SCOPE. Beide Hooks scopeten auf task_id == t_<hex>. Aber Worker
   tragen als effective_task_id den SESSION-Zeitstempel (z.B.
   20260713_224206_267304), NICHT die Kanban-t_-ID -> der Check schlug immer
   fehl -> box-steckbrief injizierte nie, tabu-guard liess alles durch.
   FIX:
   - tabu-pfade-guard.py: Schutz jetzt UNBEDINGT (kein Task-Scope) - kein
     legitimer Hermes-Agent-Flow schreibt je in Live-Checkout/Hermes-Quelle
     (Werkstatt arbeitet im Workspace-Klon, Wartung ist Shell nicht Agent-Tool).
   - box-steckbrief-inject.sh: Scope jetzt cwd unter ~/.hermes/kanban/workspaces/
     (zuverlaessiger Worker-Signal; Lucy/Voice/CLI laufen nie dort).

Verifiziert: echter betrieb-Worker versuchte in den Live-Checkout zu schreiben
-> GEBLOCKT, Datei nicht erstellt, TABU-Meldung im Log. 26 Guard-Unit-Faelle gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:51:47 +02:00
Hitonabi 5d02e7af91 Faden 11: Gateway-Bild-Weiche - Requests mit Bild automatisch an VL-30B
Entscheid Weg A (vision-harness-verdikt): das MTP-Hirn (fast/hermes) bleibt
schnell und bildunfaehig; Bild-Requests routet das MC2-Gateway automatisch ans
Vision-Modell - kein manueller Modellwechsel, Lucys Flow bleibt.

router_logic.py: has_image(body) erkennt OpenAI-multimodalen content
(image_url/input_image/image); _text_of zieht jetzt nur Text-Parts fuer das
Komplexitaets-Routing (str() einer content-Liste haette die Zeichen-Schwelle
verfaelscht). VISION_CAPABLE = {vision, scout}.
routing_policy.py: neues UI-editierbares vision-Alias (Default env MC_ROUTE_VISION
= "vision"; leer = Weiche aus), darf wie coder_lite leer sein.
gateway_proxy.py _proxy: nach der Alias-Wahl - wenn ein Bild dabei ist und das
Ziel nicht bildfaehig - Override auf das vision-Alias (auch bei explizitem
model=hermes; genau Lucys Fall). Header x-mc-route-reason.

Verifiziert (Unit): Bild+hermes->vision, Bild+auto->vision, Text->fast,
Bild+scout->scout (schon bildfaehig), has_image korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:42:08 +02:00
Hitonabi 913256e241 gitattributes: agent-hooks/*.py auf LF pinnen (Shebang-Falle)
Ohne Regel wuerde autocrlf tabu-pfade-guard.py bei Checkout zu CRLF machen ->
`#!/usr/bin/env python3\r` bricht auf der Box. Analog zur *.sh-LF-Regel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:27:27 +02:00
Hitonabi 5a236b4b28 Faden 8: Tabu-Pfade hart sperren (pre_tool_call-Guard)
Fallstrick #2: Worker koennen in die falsche Ebene wandern - Patches im
Live-Checkout ~/mission-control-v2 ablegen oder die Hermes-Quelle
~/.hermes/hermes-agent anfassen. Hermes' native is_write_denied deckt diese
Pfade NICHT ab (nur Credentials/System, live geprueft).

deploy/agent-hooks/tabu-pfade-guard.py (neu): pre_tool_call-Hook, blockt
Schreib-Operationen (write_file/patch + terminal git-write/sed-i/redirect/rm)
auf den home-verankerten Live-Checkout und die Hermes-Quelle - NUR fuer echte
Kanban-Worker (task_id t_<hex>); Lucy/CLI (UUID) = No-op (~15 ms). Der
Workspace-KLON (<workspace>/mission-control-v2) bleibt beschreibbar (nur der
home-verankerte Pfad ist tabu) -> legitime Worker-Arbeit ungestoert. Lesen
erlaubt. 25 Faelle gruen (Block + Allow, inkl. git log 2>/dev/null, Redirect
nach /tmp, Workspace-Commit). deploy.sh synct den Hook (cmp-Guard).

Registrierung in ~/.hermes/config.yaml (pre_tool_call) einmalig von Hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:26:29 +02:00
Hitonabi 967225b1d7 Faden 7: betrieb-Profil (Ops/Bench/Mess-Worker) - SOUL + deploy-Sync
Fallstrick #1: Mess-/Bench-/Verify-Aufgaben landeten auf default (Lucys
nicht-technische Persona) und flailten. Neues Worker-Profil betrieb (analog
werkstatt): misst/bencht/diagnostiziert/verifiziert auf der LEBENDEN Box,
aendert aber nichts (kein Restart/Config/Deploy).

deploy/betrieb-SOUL.md: Ops-Leitplanken - nur lesen am Live-System, kennt die
Werkzeuge (Health-curl, systemctl --user status MIT XDG_RUNTIME_DIR, bench-
Skripte), Bench-Vorsicht (kein OOM, Lucys Warm-Set schuetzen), Verifizieren-
vor-Behaupten, falsche Ebene -> werkstatt/default. deploy.sh synct sie (guarded
auf existierendes Profil).

Profil selbst + Modell (hermes) + Routing-Descriptions werden einmalig auf der
Box angelegt (hermes profile create betrieb --clone-from werkstatt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:44:07 +02:00
Hitonabi e7f143823e Faden 6: Grenzen-Landkarte (was gehoert wohin) + Worker-Hinweis
Verhindert das "Chat-in-MC2"-Muster und Ebenen-Verwechslungen: neue
docs/wissen/GRENZEN.md mappt die fuenf Flaechen (MC2=Steuerpult,
Lucy=Chat/Stimme, Hermes=Fundament/Quelle-tabu, Telegram, Hermes-Desktop)
mit "gehoert hin / gehoert NICHT hin", den Code-Ebenen-Grenzen und einer
"ich will X bauen - wohin?"-Entscheidungshilfe + roten Linien.

Eingewoben: README-Lesereihenfolge, ARBEITSWEISE (Rollen-Abgrenzung zeigt
auf GRENZEN + "MC2 = Steuerpult, kein Chat"), und der pre_llm_call-Worker-
Hook (box-steckbrief-inject.sh) gibt jedem Worker die Kurz-Grenzen inline
(MC2=Steuerpult/kein Chat-UI, Lucy=Chat, Hermes-Quelle tabu) + Zeiger auf
GRENZEN.md. Verifiziert: Hook injiziert 1236 Z inkl. Grenzen, Lucy-UUID No-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:29:33 +02:00
Hitonabi 4e8f0b91dd deploy: Agent-Hooks nur bei echter Aenderung kopieren (cmp-Guard)
Ein blindes cp aktualisiert die mtime auch bei identischem Inhalt und loest
dadurch bei jedem Deploy die harmlose "script modified since approval"-Warnung
von `hermes hooks doctor` aus. cmp -s ueberspringt unveraenderte Hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:21:46 +02:00
Hitonabi 69537de304 Faden 5: Scope-Gate auf Kanban-Muster t_<hex> verschaerfen
Wichtige Korrektur vor dem Scharfschalten: Hermes setzt
effective_task_id = task_id OR uuid4() (agent/turn_context.py:216) — Lucys
interaktiver/Voice-Chat laeuft also mit einer NICHT-leeren UUID-Ersatz-task_id.
Der urspruengliche "task_id nicht leer"-Check haette damit auch Lucy getroffen
(Persona-/Latenz-Schaden). Fix: nur injizieren, wenn task_id dem echten
Kanban-Muster t_<hex> entspricht; UUIDs (mit Bindestrichen, nie t_-Prefix)
fallen auf No-op. Verifiziert: t_-ID -> injiziert, UUID/leer/non-t_ -> No-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:18:16 +02:00
Hitonabi c81523c0f4 Faden 5: Box-Selbstwissen mechanisch in jeden Kanban-Worker (pre_llm_call-Hook)
Fallstrick #1 behoben: Ops-/Mess-/Verify-Aufgaben landen oft auf `default`
(= Lucys Persona, "nicht technisch, keine Pfade/Versionen") und flailen, weil
sie llama-swap :8080 & Co. nicht kennen. Nur `werkstatt` hatte Box-Wissen.

deploy/agent-hooks/box-steckbrief-inject.sh (neu): pre_llm_call-Hook, haengt
eine kompakte Box-Orientierung ephemer an die User-Message JEDES Kanban-Workers
(egal welches Profil) — aber NUR wenn eine task_id gesetzt ist. Lucys
interaktiver/Voice-Chat hat keine task_id -> sofortiger No-op (7 ms gemessen),
kein Persona- oder Latenz-Eingriff. Inhalt: Identitaet + Modell-Endpunkte
(llama-swap :8080, MC2 :9001/v1, gateway :8642, mem0 :8765) + Tabu-Live-Checkout
+ Zeiger auf den vollen Selbst-Steckbrief und docs/wissen. = der "via Hook/
Wrapper/Guard"-Weg aus der Fallstrick-Karte (Dispatcher-Spawn ist Hermes-intern).

deploy/agent-hooks/pre-verify-gates.sh: der bestehende py_compile/dist-Gate-Hook,
bisher nur manuell auf der Box -> jetzt verbatim im Repo getrackt (ueberlebt
Box-Neuaufbau, schliesst eine Autonomie-Luecke).

deploy.sh synct beide nach ~/.hermes/agent-hooks/ (+chmod). Die Registrierung
in ~/.hermes/config.yaml (hooks: pre_llm_call) bleibt einmalig von Hand
(config.yaml ist Zwei-Schreiber-sensibel).

Verifiziert: Hook-Logik auf der Box gegen synthetische Payloads (Worker ->
987-Zeichen-Kontext injiziert, Lucy/leer -> {}, JSON valide), bash -n + LF sauber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:10:34 +02:00
Hitonabi e5f546a72f Faden 4: Antwort-Feld fuer haengende Aufgaben (needs_input)
Sackgasse behoben: ein Worker blockte eine Aufgabe mit einer Frage
(kanban block --kind needs_input), aber die Zentrale zeigte nur den
roten Status "haengt - braucht dich" - ohne die Frage und ohne
Eingabefeld. Der Worker blieb so dauerhaft stehen.

Backend (services/ideen.py):
- _blocker_frage(): liest die Worker-Frage aus dem juengsten
  blocked-Event (payload.reason/kind), Fallback: BLOCKED:-Kommentar.
- list_queue() reichert blockierte Karten mit frage + frage_kind an
  (max 6 Extra-show-Aufrufe pro Poll).
- answer_task(): unblock --reason schreibt die Antwort als Kommentar
  und stellt die Karte auf ready -> Dispatcher spawnt den Worker neu,
  der die Antwort im Kontext vorfindet. Mit ID-/Laengen-Validierung.

Router: POST /api/ideen/antwort.

Frontend (AuftragsbuchView): blockierte Karten zeigen die Frage
(je nach Grund "Die Box hat eine Frage:" / "kam hier nicht weiter:")
plus Antwortfeld + "Antworten & weiter". Fallback-Text ohne Grund.
Vertraegt sich mit dem Live-Log-Blick (nur triage/running).

Verifiziert: E2E gegen die echte Box-Kanban-CLI (Frage auslesen,
answer_task entsperrt + protokolliert, Fehlerpfade), py_compile +
Frontend-Build gruen, Antwort-UI im Dev-Server gerendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:48:18 +02:00
Auftragsbuch 5436e4df16 Auftragsbuch: 'feature/soul-push-verify' angenommen (Ein-Klick-Gate) 2026-07-13 18:23:33 +02:00
werkstatt a1957d7094 deploy/werkstatt-SOUL.md: Drittes Gate 'Push wirklich gelandet' hinzufügen 2026-07-13 18:21:49 +02:00
Auftragsbuch 5690217d84 Auftragsbuch: 'feature/soul-push-verify' angenommen (Ein-Klick-Gate) 2026-07-13 18:18:03 +02:00
claude-direkt 0a9293f669 Werkstatt-Anleitung: Push wirklich verifizieren bevor Abschluss
Drittes Gate nach merge-base + rebase: git ls-remote origin <branch> muss den Branch zeigen, sonst kanban_block statt kanban_complete. Behebt Schein-Erfolg (13.07.2026: Worker meldete gepusht+done, Branch war nie auf Gitea).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:17:08 +02:00
Auftragsbuch 5496159b8b Auftragsbuch: 'feature/live-log-blick' angenommen (Ein-Klick-Gate) 2026-07-13 17:17:41 +02:00
werkstatt 9a41cce99b MC2: Live-Log-Blick - Worker-Log laufender Ideen im Auftragsbuch aufklappbar machen
Backend:
- backend/services/ideen.py: neue Funktion log_of(task_id)
  - ruft 'hermes kanban log <id>'
  - validiert task_id mit _TASK_ID_RX
  - strippt ANSI-Steuerzeichen (\x1b\[[0-9;]*m)
  - gibt letzte ~120 Zeilen zurück
  - kurzer Cache (~4s)
  - auf Windows/available=False leer zurück, nie crashen
- backend/routers/ideen.py: GET /api/ideen/{id}/log -> ideen.log_of(id)

Frontend:
- frontend/src/views/AuftragsbuchView.tsx: IdeenSection
  - Ideen mit status 'triage' oder 'running' per Klick aufklappbar
  - im aufgeklappten Zustand /api/ideen/{id}/log alle ~4s pollen
  - Log monospace/<pre> in scrollbaren, höhenbegrenztem Kasten
  - vorhandenes api()-Muster + react-query nutzen
- frontend/dist mitgebaut und committet

Branch: feature/live-log-blick
2026-07-13 17:17:16 +02:00
Auftragsbuch 1427048f38 Auftragsbuch: 'wartung/telegram-ping-neue-karte' angenommen (Ein-Klick-Gate) 2026-07-13 17:05:41 +02:00
Hitonabi 82453e9f12 Wartung: Telegram-Ping bei neuer Auftragsbuch-Karte
- Persistenz-Datei ANNOUNCE_BRANCHES_PATH (mc2-announce-branches.json) für gemeldete Branches
- list_proposals() pingt NEUE Branches per notify_telegram() und announce.add()
- Idempotenz: nur EINMAL pro Branch, bei jedem Poll nur wirklich NEUE melden
- notify_telegram ist best-effort/posix (Windows-Dev = No-op), in try/except gekapselt
2026-07-13 17:04:12 +02:00
Hitonabi a2bfe0e480 Fix: Speicher-Anzeige einheitlich (Box-Health == System-Status)
Die 'Box gesund'-Speicher-Pille zeigte den GTT-Pool (GPU-Sicht auf den
Unified-Memory der APU), die System-Status-Kachel daneben den System-RAM
— zwei verschiedene Zahlen fuer denselben Speicher (z.B. 73/124 vs 90/122).

useLucyHealth: Speicher-Ampel jetzt aus sys.ram (dieselbe Quelle wie die
RAM-Zeile), Schwellen am RAM-Charakter geeicht (warn 85%, full 93%; 4 Modelle
warm ~78% bleibt gruen). CockpitView: Pille auf 1 Nachkommastelle wie gb().
Live verifiziert: beide zeigen jetzt identisch 95.9/122.7 GB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:42:41 +02:00
Hitonabi c71a0a1abd Anleitung: System-Schaubild + Ist-Stand-Kur (Stand Juli 2026)
- Neue Sektion 'So funktioniert dein System' ganz oben: Landkarte
  (Du -> 4 Tueren -> Box-Innenleben -> PC-Executor) + Kreislauf
  (Idee -> Queue -> Werkstatt -> Auftragsbuch -> DEIN Klick -> Live,
  Rueckkanal Chronik/Morgenlage) + Nacht-Fahrplan; reines Inline-SVG
  mit Design-Tokens (frontend/src/views/guide/SystemSchaubild.tsx)
- Veraltete 'Bei dir konkret'-Kaesten repariert:
  * Modell-Line-up auf Klartext-Rollen (Hirn/Gross/Coder/Augen/Spaeher/
    Kritiker/Gedaechtnis/Sortierer) statt fast/heavy/scout
  * Hermes: eigenes Agent-Hirn statt 'Hirn = fast'; Zugaenge Desktop//hermes-ui,
    Konsole, Telegram, Lucy statt totem 'Terminal'-Tab
  * IDE-Kapitel: Hermes Desktop als Haupt-IDE ergaenzt
  * Backup: PBS-Backup-Zentrum + Tarball statt nur restore.sh; Auto-Revert erwaehnt
  * Troubleshooting: Zentrale->Cockpit, Terminal->Konsole, Zeitmaschine ergaenzt
- Datumsstand Juni -> Juli 2026

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:33:06 +02:00
Hitonabi 94444f4dcb Frontend-Erneuerung: Mobile, Diät, Tempo, UI-Politur (10-Punkte-Paket)
- Fix: 'Interaktives Terminal' im Cockpit führte ins Leere (#terminal) → Konsole
- Mobile: Off-Canvas-Sidebar mit Hamburger unter md, Inhalt volle Breite
  (vorher: 240px starre Sidebar, 135px Rest mit Quer-Scroll auf dem Handy)
- Dead Weight raus: src/mc3/ (verworfener Prototyp), DashboardView (alte
  Zentrale) + 6 nur dort genutzte Karten, 8 ungenutzte npm-Pakete
  (three, three-vrm, react-three fiber/drei, sigma, graphology ×2, @types/three)
- Code-Splitting: alle Views außer Cockpit per React.lazy → Haupt-Bundle
  983 → 739 KB, jede View ein eigenes Chunk
- Fonts lokal (@fontsource) statt Google-CDN → UI offline identisch
- Polling harmonisiert: EINE Takt-Tabelle in queries.ts (Graphen 3s, Rest
  8-60s), keine Override-Intervalle mehr in Komponenten; Updates-Check
  (apt/git!) von 4s auf 60s
- CSS-Hack-Layer aufgelöst: !important-Klassen-Sniffing → explizite
  .mc-card/.mc-card-sm/.nav-active-Klassen (Look identisch, ungeschichtete
  Regeln statt Utility-Raten); totes .light-Theme raus; Scrollbars 1× definiert
- Aurora: animierte Fullscreen-Blur-Filter → statische radial-gradients
  (gleicher Look, keine Dauer-GPU-Last)
- Sicherheit: Sudo-Passwort/HF-Token nur noch bei mutierenden Requests,
  nicht mehr auf jedem GET-Poll
- Strg+K statt ⌘K auf Windows; dist neu gebaut

Live gegen die Box verifiziert: alle 10 Views + Mobile-Flow, 0 Konsolen-Fehler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:29:51 +02:00
Auftragsbuch f88f8e8bf9 Auftragsbuch: 'feature/projekt-start-skill' angenommen (Ein-Klick-Gate) 2026-07-13 11:13:15 +02:00
Hitonabi 52f25432b8 Projekt-Start-Skill: "erst denken, dann bauen" fuer Hermes Desktop
Portiert vom PC-Skill (F:\Coding Stuff\projekt-start): 4 Phasen mit hartem
Plan-Riegel, Savepoints, AGENTS.md/SAVEPOINT.md/.aiexclude-Ritual und
Kontext-Waechter. Zed-Verweise durch Hermes Desktop ersetzt (Modellwaehler
"Box / Coder (bauen)" / "Box / Planer (denken)", Planer-Kontexthinweis),
.aiexclude-Regel in die AGENTS.md-Vorlage gehoben, Verweis auf den
Werkstatt-Weg fuer Box/Lucy-Wartung ergaenzt. deploy.sh installiert ihn
nach ~/.hermes/skills/projekt-start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:56:33 +02:00
Auftragsbuch 2caecb0a79 Auftragsbuch: 'wartung/annahme-selbstheilung' angenommen (Ein-Klick-Gate) 2026-07-13 09:36:13 +02:00
Hitonabi 552299a761 Annahme-Selbstheilung: Orphan-Branches entlarven, veraltete Branches auto-rebasen
Anlass: feature/cleanse-skill-index — ein Werkstatt-Worker hatte git init statt
Klonen gemacht (Orphan-Commit ohne gemeinsamen Vorfahren, Inhalt: ~/.hermes-Index-
Dateien statt Repo-Dateien). Solche Karten sahen aus wie normale Vorschlaege,
liefen bei Annahme aber IMMER in 'refusing to merge unrelated histories' — und
die Fehlermeldung ('bitte am PC aufloesen') war eine Sackgasse. Das passiert
oefter; darum drei Schichten dagegen:

1) ERKENNEN (Auftragsbuch): neue Karten-Flags 'verwaist' (kein merge-base mit
   main -> rote Markierung 'kein gemeinsamer Ursprung', Annehmen-Knopf fehlt,
   API+Runner halten zusaetzlich dicht) und 'leer' (Diff gegen main leer ->
   'bringt nichts'-Badge). Empfehlung auf der Karte: ablehnen mit Grund,
   Idee frisch in die Queue.

2) HEILEN (beide Runner, mc2 + lucy): kollidiert der Merge, weil main weiter-
   gelaufen ist, versucht der Runner automatisch einen Rebase des Branches auf
   main (isolierter Worktree; Gates laufen danach normal, Merge-Message sagt
   'auto-rebased'). Nur wenn auch der Rebase kollidiert, faellt die Karte durch —
   mit ehrlicher Meldung statt 'am PC aufloesen'.

3) VERHINDERN (werkstatt-SOUL): voll klonen (nie git init/--depth), Selbstcheck
   'git merge-base HEAD origin/main' + fetch/rebase vor JEDEM Push, nie
   ~/.hermes-Artefakte committen.

Doku: AUFTRAGSBUCH.md + FALLEN.md (Erkennungsmuster: Diff 0 Dateien + behind ~
ganze Historie) + OFFENE-FAEDEN. Geprueft: py_compile gruen, bash -n beide
Runner gruen, tsc+vite build gruen (dist dabei); merge-base-Verhalten am echten
kaputten Branch auf der Box verifiziert.
2026-07-13 09:34:23 +02:00
Auftragsbuch 15c137e849 Auftragsbuch: 'wartung/idle-radar' angenommen (Ein-Klick-Gate) 2026-07-12 20:20:23 +02:00
Hitonabi fc591da024 Karten-Gutachter: leerer Diff => mechanisch UNKLAR (ohne Richter)
Erstlauf-Befund 12.07.: Karte mit 0 Dateien bekam ANNEHMEN - ein LLM
kann einen leeren Vorschlag nicht pruefen. Jetzt deterministische Regel
vor dem Richter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:12:08 +02:00
Auftragsbuch 9872c1b3f6 Auftragsbuch: 'wartung/idle-radar' angenommen (Ein-Klick-Gate) 2026-07-12 20:10:33 +02:00
Hitonabi 1570b2fc71 Lucy kennt sich: Selbst-Inventur, Karten-Gutachter, Lern-Klick, Radar->Queue
User-Auftrag 12.07. ("bau das alles ein im Sinne der Autonomie. Auch muss
Lucy selbst bemerken wenn sich etwas aendert"):

- deploy/selbst-inventur.sh (Cron 03:35): SELBST-STECKBRIEF wird LIVE aus
  der Realitaet GENERIERT (Versionen, Dienste, Timer, Modelle, Crons,
  Queue, Karten -> ~/.hermes/state/selbst-steckbrief.md) statt von Hand
  gepflegt. Diff gegen gestern => die Box BEMERKT SELBST Aenderungen an
  sich (stille Chronik-Karte + kurze Telegram-Zeile; sonst still).
- deploy/karten-gutachter.sh (Cron 04:00): stempelt jede neue Karte mit
  "Empfehlung: ANNEHMEN/ABLEHNEN/UNKLAR + ein ehrlicher Satz" (prueft
  Redundanz gegen den Steckbrief, Risiko, Nutzen; Ein-Schuss-Richter
  gpt-oss/GLM; Stempel = Meinung, kein Gate; neuer Commit entwertet den
  alten Stempel). Backend mischt den Stempel in /api/auftragsbuch,
  UI zeigt ihn farbig auf der Karte.
- Ablehnen mit Grund (Lern-Klick): UI-Inline-Feld beim Ablehnen ->
  /srv/models/mc2-ablehnungen.jsonl -> Idle-Radar bekommt die Gruende
  als "NIE wieder vorschlagen"-Material vorgelegt.
- idle-radar-feed.sh: Material + Raster erweitert um Selbst-Steckbrief,
  Inventur-Diff und Ablehn-Gruende (Fehlerfall 12.07.: redundanter
  Gateway-Restart-Vorschlag waere damit gestorben).
- werkstatt-SOUL Regel 7 + wartung-Skill: REALITAETS-CHECK - existiert es
  schon? Dann kanban_block statt Branch (ein Branch fuer Ueberfluessiges
  ist ein gescheiterter Lauf).
- hermes-release-radar-feed.sh: echter Treffer legt zusaetzlich EINE rohe
  Idee in die Queue (idempotent je Release-Tag) - der Blick nach draussen
  fuettert denselben Kreislauf.
- deploy.sh: kopiert beide neuen Skripte; Doku AUFTRAGSBUCH.md +
  wissen/OFFENE-FAEDEN.md; frontend/dist frisch gebaut (tsc+vite gruen,
  UI gegen Live-Box verifiziert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:58:00 +02:00
Hitonabi 961b003df4 Zuendung (S4): Idle-Radar + Grossbau-Etappen-Regeln
- deploy/idle-radar-feed.sh (Hermes-Cron 03:45): destilliert aus Journal-
  Fehlermustern (24h) + juengster Traum-Notiz max. 2 belegte Wartungs-
  Kandidaten und legt sie als rohe Ideen (triage) ins native Kanban.
  Ehrlichkeits-Gate: Beleg-Zitat wird MECHANISCH gegen das Material
  geprueft (reproduziert-oder-abgelehnt). Stau-Bremse (>=4 offene Queue-
  Aufgaben oder >=3 offene Karten -> nichts Neues), Dedup dreifach
  (State-Datei + Board-Abgleich inkl. archivierter + --idempotency-key),
  Security/Config/Updates tabu. Analyst gpt-oss-120b, Vertretung GLM.
- deploy/idle-radar-prompt.md: versionierter Boten-Auftrag (Lucys Stimme,
  max 3 Saetze, nichts selbst umsetzen).
- deploy.sh: kopiert idle-radar-feed.sh nach ~/.hermes/scripts/ (greift
  wegen Selbst-Reset-Falle erst ab dem 2. Deploy -> Vorinstallation
  einmalig von Hand; Cron-Registrierung einmalig: hermes cron create).
- Grossbau-Etappen-Regeln (Zielbild-Entscheid 4, 10.07.): werkstatt-SOUL
  Regel 6 + wartung-/orchestrator-SKILL Scope-Check: Grosses nicht mehr
  ablehnen, sondern in Etappen bauen - jede Etappe eigener Branch, fuer
  sich lauffaehig + annehmbar, Folge-Etappen als neue Queue-Aufgaben,
  NIE auf unangenommene Branches aufbauen.
- Doku: AUFTRAGSBUCH.md (Idle-Radar-Abschnitt), wissen/OFFENE-FAEDEN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:58:05 +02:00
Auftragsbuch 90d9b68d3d Auftragsbuch: 'wartung/lucy-annahme-fixes' angenommen (Ein-Klick-Gate) 2026-07-12 15:20:13 +02:00
Hitonabi b4c405a127 Lucy-Annahme-Fixes: Start-Process-Quoting, Python-3.14-Parse, Executor ohne Fensterblitz
Der erste Live-Lauf der Lucy-Annahme (wartung/pc-annahme-und-turncheck-aus) fand
zwei Bugs im Box-Runner — der Build selbst war gruen, nur Start und Abschluss
mussten manuell nachgezogen werden:

1) Start-Process -ArgumentList quotet Elemente NICHT (PS 5.1): der Runner-Pfad
   'F:\Coding Stuff\lucy\...' zerbrach am Leerzeichen, die gespawnte powershell
   starb still (kein Status, kein Log). Fix: Anfuehrungszeichen ins -File-Element
   eingebettet. (Der Probe-Test lief ueber einen Pfad OHNE Leerzeichen — deshalb
   nicht gefangen.)

2) Poll-Parser: \" in f-String-Ausdruecken ist seit Python 3.12 ein SyntaxError
   (Box: 3.14) -> der Poll blieb blind und haette den GRUENEN Merge nach 25 min
   revertiert. Fix: keine f-String-Quote-Akrobatik mehr, Werte in Variablen.
   Neuer Parser 1:1 auf der Box getestet.

3) executor.py /shell: CREATE_NO_WINDOW — der Executor laeuft unter pythonw,
   jede gespawnte powershell bekam ein SICHTBARES Konsolenfenster (Polling
   blitzte im 10-s-Takt auf dem Desktop). Nach Annahme: PC-Checkout pullen +
   HermesPCExecutor-Task neu starten (mache ich, steht auch in FALLEN.md).

Beide Fallen + Fensterblitz in docs/wissen/FALLEN.md; OFFENE-FAEDEN: S3-Stand.
Geprueft: bash -n gruen, py_compile gruen, Parser auf Box-Python 3.14 verifiziert.
2026-07-12 15:18:20 +02:00
Auftragsbuch 3fa2fbb9d8 Auftragsbuch: 'feature/lucy-pipeline' angenommen (Ein-Klick-Gate) 2026-07-12 14:58:04 +02:00
Hitonabi 6d5b09a592 Lucy-Pipeline (S3): Auftragsbuch liest zwei Repos, Annahme baut am PC
Das Auftragsbuch-Gegenstueck fuer Lucy (Abloesungs-Paket S3, PC-Annahme-Weg):

- backend/services/auftragsbuch.py: Multi-Repo (mc2 + lucy via ~/lucy-Clone).
  Karten tragen repo-Feld; Status-Schluessel fuer Lucy = 'lucy:<branch>';
  fehlt ~/lucy, werden Lucy-Karten still weggelassen. Runner je Repo.
- deploy/lucy-annahme.sh: detached Annahme-Runner fuer Lucy-Karten.
  Vorpruefung am PC VOR dem Merge (Executor erreichbar, Arbeitskopie main+clean,
  laeuft Lucy?), dann Merge im isolierten Worktree von ~/lucy -> Push main ->
  PC zieht ff-only und baut detached (deploy/lucy-annahme.ps1 im Lucy-Repo),
  Box pollt .lucy-annahme.json und spiegelt Fortschritt auf die Karte.
  Rot = Merge automatisch revertiert, laufende Lucy bleibt die alte.
  Executor-Zugang (URL+Token) kommt aus ~/.hermes/config.yaml — kein zweiter
  Ablageort. Neustart nur, wenn Lucy vorher lief (User-Entscheid).
- Router/UI: repo-Parameter (rueckwaertskompatibel, Default mc2), rosa
  Lucy-Badge, eigener Annahme-Confirm-Text, Diff/Ablehnen je Repo.
- werkstatt-SOUL: Lucy-Auftraege ebenfalls propose-only, bauen macht der PC
  bei der Annahme.
- Doku: docs/AUFTRAGSBUCH.md (Lucy-Annahme-Kapitel), docs/wissen/OFFENE-FAEDEN
  (S2 erledigt, S3-Stand).

Geprueft: py_compile gruen, bash -n gruen, tsc+vite build gruen (dist dabei).
2026-07-12 14:48:03 +02:00
Auftragsbuch e951db36c5 Auftragsbuch: 'wartung/fix-double-zero-self-repair' angenommen (Ein-Klick-Gate) 2026-07-12 14:09:25 +02:00
Hitonabi 73a20daa52 wartung: Fix double-zero output in self-repair.sh grep command
n="0
0" erzeugt bei 0 Treffern '0\n0'.
Sichere Zuweisung: n=$(grep -cE ...); n=${n:-0}
2026-07-12 14:05:05 +02:00
Claude (Werkstatt PC) e1ca7f6a5e Ideen-Queue: natives Hermes-Kanban wird DIE Sammelstelle (Abloesung S2)
Entscheid nach Probe (Hermes-first): Dispatcher im Gateway, Specifier
(triage -> sauberer Auftrag), Profil-Routing und Artefakt-Tracking sind
nativ E2E bewiesen — kein Eigenbau. Auftragsbuch bleibt das Klick-Gate.

- backend services/ideen.py + routers/ideen.py: MC2-Tuer zur Queue
  (GET /api/ideen, POST /api/ideen -> kanban create --triage, Archiv)
- AuftragsbuchView: Ideen-Queue-Sektion (Eingabefeld + Status-Liste),
  dist frisch gebaut
- mcp_voice.py: idee_notieren (Lucys Sprech-Leitung, Prompt-Diaet: 1 Tool)
- chef-gutachter-feed.sh: Morgenlage bekommt kanban stats + Bewegung
- Bagatell-Pfad: bagatell-annahme.sh + mc2-bagatell.timer (04:10) —
  NUR .md-Anlagen/-AEnderungen, max 2/Nacht, gleicher Annahme-Runner
  mit Health-Gate + Auto-Revert, Chronik/Telegram-Meldung
- werkstatt-SOUL.md: Leitplanken des Kanban-Worker-Profils (propose-only,
  nie main, nie Live-Checkout) — deploy.sh zieht sie kuenftig nach

Box-live heute schon (ausserhalb Repo, mit Backups): toolsets+kanban,
platform_toolsets.telegram+kanban, SOUL.md-Ideen-Regel, Profil werkstatt
(model.default=coder), Gateway neu gestartet + E2E-Tuer-Test gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:02:50 +02:00
Auftragsbuch ae4b0f229e Auftragsbuch: 'doku/uebergabe-wissenspaket' angenommen (Ein-Klick-Gate) 2026-07-10 12:57:28 +02:00
Hitonabi 7f224ec98e Uebergabe S1: Auto-Update-Entscheid 10.07. nachgezogen (Timer bleibt AN)
User-Antwort in der Uebergabe-Session: Router/Engine/Hermes duerfen automatisch
(So 04:30, Fangnetz Rollback+Pin) - ersetzt "nur bestaetigt" vom 04.07.
STACK/RUNBOOK/OFFENE-FAEDEN/deploy.sh-Kommentar entsprechend korrigiert;
deploy.sh schaltet den Timer weiterhin NICHT selbst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:19:09 +02:00
Hitonabi 2d32d21742 Uebergabe S1: Wissens-Heimat docs/wissen/ + Gemini-Briefing + Monats-Review-Wrapper ins Repo
- docs/wissen/: kuratierte Projekt-Wahrheit fuer alle Agenten (Zielbild, Arbeitsweise,
  Stack live-verifiziert 10.07., Verdikte, Betriebs-Fallen, offene Faeden)
- docs/GEMINI_BRIEFING.md: Notfall+Review-Briefing (baut auf ANTIGRAVITY_REVIEW auf)
- RUNBOOK: Seite "Wann Gemini rufen" + Automatik-Fahrplan/Werkstatt/sudo aktualisiert
- deploy/radar-selbstkritik-wrapper.sh: lag NUR auf der Box (Monats-Review-Cron),
  jetzt versioniert + deploy.sh kopiert ihn
- docs/memory/-Altkopien mit Veraltet-Banner auf docs/wissen/ verwiesen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:13:05 +02:00
Auftragsbuch ae523a171b Auftragsbuch: 'wartung/hermes-release-radar' angenommen (Ein-Klick-Gate) 2026-07-10 11:28:11 +02:00
Hitonabi 3f32a9a746 Politur: Fremdblick-Budget fuer den Release-Radar auf 4500 (GLM-Reasoning verbrauchte 2200 vor dem Urteil) 2026-07-10 10:37:13 +02:00
Hitonabi 6e093ed163 Politur: Release-Radar-Parse repariert (Pipe+Heredoc-stdin-Kollision) + ehrlicher Blind-Pfad bei unbrauchbarer API-Antwort 2026-07-10 10:33:51 +02:00
Hitonabi 73f593b1a1 Release-Radar: hermes-Releases woechentlich gegen die Eigenbau-Landkarte halten
Baustein 2-4 des Plans "mehr Hermes nativ, weniger Eigenkreationen":
- deploy/hermes-release-radar-feed.sh: woechentlicher Cron-Feed (Muster chef-gutachter) —
  GitHub-Releases seit letztem Stand holen (nur Tags, kein main-Churn), Ein-Schuss-Abgleich
  gegen ~/wissens-vault/eigenbau-landkarte.md (Analyst gpt-oss-120b, Vertretung GLM),
  Zitat-Pflicht, fremdblick-Gegenpruefung bei Treffern, stiller Briefkasten-Spiegel
  (source=release-radar). Informiert nur — Updates bleiben manuell (Entscheid 04.07.).
- deploy/release-radar-prompt.md: versionierter Auftrag fuer den Boten-Agenten.
- deploy.sh: Feed nach ~/.hermes/scripts/ ausrollen.
- Skills wartung+orchestrator: Hermes-first-Leitplanke (vor Neubau nativ pruefen,
  Befund in den Vorschlag).
- backup.sh/restore.sh: Nebenbefund geschlossen — ~/.hermes/cron (ALLE Cron-Jobs!) und
  ~/.hermes/state wurden bisher nicht gesichert; Restore haette sie still verloren.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:31:50 +02:00
Auftragsbuch 83cca21113 Auftragsbuch: 'wartung/gateway-modell-liste-aufraeumen' angenommen (Ein-Klick-Gate) 2026-07-09 19:44:42 +02:00
Hitonabi 07be502e4e Gateway: Modell-Liste aufgeraeumt - Router-Lanes + rohe Doppel-IDs raus
Die /v1/models-Liste zeigte Router-Lanes (coding/chat), Rollen-Aliase und rohe
llama-swap-IDs durcheinander (verwirrte coding-vs-coder im Hermes-Desktop-Waehler).
Jetzt nur noch die Klarnamen-Aliase: Router-Lanes nicht mehr gelistet (Routing
bleibt, /chat/completions nimmt coding/chat weiter an), rohe Doppel-IDs ausgeblendet
wenn ein Alias sie schon zeigt, tote Konstanten LANES/_LANE_LABELS entfernt.
fast/scout/heavy unangetastet (tragend). py_compile gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:27:36 +02:00
Hitonabi 042c3f16d9 Politur: Gedaechtnis-Graph als Themen-Inseln statt Hairball
Datenbefund: 336 der 679 Kanten laufen quer zwischen Kategorien und uebermalten
das Quadranten-Clustering komplett; Kantengewichte (0,45-1,0) wurden nie genutzt.

- Querkanten im Ruhezustand ausgeblendet, erscheinen nur am aktiven Knoten
  (Hover/Auswahl zeigt ALLE seine Verbindungen, auch schwache/quere)
- Gewichts-Schwelle mit Regler in der Legende (alle <-> stark, Default 0,75,
  Live-Zaehler): 679 -> 114 sichtbare Kanten im Standard
- Cluster-Huellen: konvexe, geglaettete, getoente Flaeche + Landkarten-Label
  je Kategorie via onRenderFramePre (Monotone-Chain-Hull, pro Frame billig)
- Insel-Physik: Intra-Kanten ziehen nach Gewicht (distance 70), Querkanten
  fast gar nicht (strength 0,01, distance 200); Cluster-Kraefte 0,07 -> 0,1

E2E verifiziert (Preview mit gepatchtem rAF): Huellen zeichnen ueber viele
Frames fehlerfrei, Regler 0,45=342/0,95=1 Kanten, Hover+Klick unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:35:47 +02:00
Hitonabi 35be5e13f6 Politur: Gedaechtnis-Graph Hover/Klick via eigenes Picking exakt gemacht
Ursache im force-graph-Internals gefunden: die Hit-Erkennung laeuft ueber eine
Schatten-Canvas, die nur alle 800 ms aktualisiert wird (HOVER_CANVAS_THROTTLE_DELAY)
- waehrend Simulation/Zoom/Pan zeigt sie auf Positionen von vor fast einer Sekunde,
und Klicks werden ueber genau dieses veraltete hoverObj ausgeloest. Dazu verschluckt
das eingebaute Node-Dragging Klicks ab 2 px Mausbewegung.

- Eigenes Picking gegen LIVE-Knotenkoordinaten (screen2GraphCoords + Naechster-
  Nachbar, Trefferradius min. ~16 Bildschirm-Pixel, zoom-kompensiert)
- enablePointerInteraction=false: Schatten-Canvas wird gar nicht mehr gemalt (Perf)
- enableNodeDrag=false: kein Klick-Verschlucken, kein Layout-Anschubsen beim Klicken
- Pan-Schutz: >6 px Mausweg zaehlt nicht als Klick

E2E im Browser verifiziert: Hover (Cursor), Knoten-Klick oeffnet Detail,
Hintergrund-Klick waehlt ab, Pan aendert Auswahl nicht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:20:54 +02:00
Hitonabi dfcf86902a Politur: Gedaechtnis-Graph Performance + Treffsicherheit grundsaniert
- Doppel-Mount behoben: GraphView lief unsichtbar doppelt (Desktop+Mobile), jetzt nur eine Instanz via matchMedia
- ctx.shadowBlur pro Knoten/Frame durch billigen Halo-Kreis ersetzt (Blur nur fuer aktiven Knoten)
- Kantenfarben beim Datenaufbau vorberechnet statt nodes.find() pro Kante pro Frame
- Knoten-Objekte ueber Daten-Updates wiederverwendet: Positionen ueberleben Refetch/Suche, Reheat nur bei Strukturaenderung
- Hit-Zone zoom-kompensiert (min. ~14 Bildschirm-Pixel) - Knoten sind rausgezoomt jetzt klick- und hoverbar
- Suche via useDeferredValue vom Graph-Umbau entkoppelt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:07:31 +02:00
Hitonabi 6ad6c09cc0 Politur: Zoom-Lagging durch restriktives Label-Rendering und native Rects komplett geloest 2026-07-09 12:50:10 +02:00
Hitonabi c452b873b7 Politur: Proportionale Hit-Erkennungszone (size + 5) in nodePointerAreaPaint fuer exaktes Hovern bei skalierten Knotengroessen 2026-07-09 12:48:15 +02:00
Hitonabi c0586c3fad Politur: Reheat-Schleifen bei Mausbewegungen in GraphView behoben (D3-Simulation stabilisiert) 2026-07-09 12:46:11 +02:00
Auftragsbuch 1983d70805 Auftragsbuch: 'wartung/betriebs-luecken' angenommen (Ein-Klick-Gate) 2026-07-09 12:43:48 +02:00
Hitonabi 65d5a49411 Politur: Performance-Bottleneck (ctx.measureText) durch clientseitiges Text-Breiten-Caching auf dem Knoten geloest 2026-07-09 12:41:16 +02:00
Hitonabi 83e5a04735 Betriebs-Lücken nach dem Hermes-Desktop-Umzug geschlossen
Systemblick 09.07. abends fand vier Lücken, drei davon hier (die vierte,
blogwatcher-Cron, ist Box-Config):
1. deploy.sh enablete mc2-autoupdate.timer bei JEDEM Deploy — und hat damit
   den User-Entscheid vom 04.07. ("Updates nur bestätigt") über die heutige
   Annahme-Kaskade still rückgängig gemacht. Zeile raus, Timer manuell
   wieder disabled; wer Auto-Updates will, enabled einmalig von Hand.
2. self-smoke.sh prüft jetzt auch das Desktop-Gateway (:9119 /api/status) —
   stirbt hermes-builtin-ui, ist die Desktop-App tot, das muss der
   Selbst-Test melden statt der User.
3. backup.sh/restore.sh sichern die neue Infrastruktur mit: agent-hooks/
   (pre_verify-Gates), shell-hooks-allowlist.json, desktop-gateway-token,
   pc-paths.yaml und den systemd-Token-Drop-in (mit chmod 600 + +x beim
   Restore). Vorher hätte ein Restore Desktop-Verbindung und Gates STILL
   verloren. bash -n auf allen vier Skripten grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:40:25 +02:00
Hitonabi 082f4a659d Politur: Robustes ID-Parsing bei D3-Objektmutationen fuer hover/degree in GraphView behoben 2026-07-09 12:39:25 +02:00
Hitonabi 103b277643 Politur: nodePointerAreaPaint fuer pixelgenaues Hovern und Klicken im Graphen implementiert 2026-07-09 12:37:58 +02:00
Hitonabi 64f45ed0ff Politur: Praeziserer Klickbereich (nodeVal) fuer Graphen-Knoten und Entkoppelung der Detail-Sicht von Listen-Filtern 2026-07-09 12:33:22 +02:00
Hitonabi 90d8a7d56d Politur: Zoom-abhaengiges Rendering fuer Gedaechtnis-Labels und staerkere Physik-Abstossung 2026-07-09 12:31:14 +02:00
Hitonabi dec4922171 Politur: Vollbild-Maximierungs-Modus (fixed overlay) fuer Gedaechnis-Dashboard hinzugefuegt (deaktiviert Box-Einschraenkung) 2026-07-09 12:29:22 +02:00
Hitonabi 2ed7100f90 Politur: Verschmelzung von Liste, Graph und Details zu einem einheitlichen, schwebenden Glassmorphism-Dashboard (Graph im Hintergrund, Panels schweben elegant davor) 2026-07-09 12:27:46 +02:00
Hitonabi 895eb9cac7 Politur: d3Force-Kollision und Quadranten-Clustering korrigiert (Zentrumskraft entfernt, starker charge-Faktor und farbkodierte Kanten) 2026-07-09 12:23:23 +02:00
Hitonabi a261462857 Fix: d3ReheatSimulation anstelle von unzulaessigem alpha.restart aufgerufen (behebt 'alpha is not a function' Fehler) 2026-07-09 12:22:02 +02:00
Hitonabi 35e8951a0d Politur: Gedaechnis-Graph um Quadranten-Clustering und forceCollide-Kollisionstuning erweitert (vollstaendige Entzerrung und farbige, gut sichtbare Kanten) 2026-07-09 12:20:43 +02:00
Hitonabi 520cfd9fe4 Politur: Robustheits-Glow-Graph-Fixes fuer D3-Simulations-Initialisierung und Performance-Optimierung 2026-07-09 12:18:18 +02:00
Hitonabi 9375b8eea8 Politur: Gedaechnis-Graph auf react-force-graph-2d umgestellt (elastische D3-Physik, fliessende Datenpartikel, 100% lesbare Text-Pillen) 2026-07-09 12:16:29 +02:00
Hitonabi 3ea0e2d540 Politur: Gedaechnis-Graph-Visualisierung veredelt (Neon-Glow, radialer Hintergrundverlauf und reaktives Zoom-Scaling) 2026-07-09 12:14:32 +02:00
Hitonabi c20c070a6b Wartung: MemoryView-Layout: Header-Elemente direkt in die Spalten integriert (kein versetzter Freiraum mehr) 2026-07-09 12:12:45 +02:00
Hitonabi 5c279ccc38 Wartung: Cockpit-Layout: StatusLine und NeedsYou direkt in das zweispaltige Haupt-Grid integriert (kein versetzter Freiraum mehr) 2026-07-09 12:10:50 +02:00
Hitonabi 7fab2cbcaa Wartung: Cockpit-Layout: Leistung (Live-Graphen) ueber die Bereiche verschoben 2026-07-09 12:08:43 +02:00
Hitonabi c2fe13857c Merge branch 'main' of https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 2026-07-09 12:06:27 +02:00
Hitonabi 5016ceef06 Merge branch 'wartung/ui-politur' (Autonomer Merge fuer UI-Politur und Graph-Fix) 2026-07-09 12:06:24 +02:00
Hitonabi bbed8211f8 Wartung: GraphView useMemoryGraph-Fix und Auftragsbuch-Status-Reaktivitaet bei ahead > 0 2026-07-09 12:04:56 +02:00
Hitonabi 51c0c868b2 Wartung: Cockpit-Restrukturierung (zweispaltiges Grid, ToolListCard, ungenutzte Imports entfernt) 2026-07-09 12:03:11 +02:00
Auftragsbuch b703361a1d Auftragsbuch: 'wartung/ui-politur' angenommen (Ein-Klick-Gate) 2026-07-09 12:00:39 +02:00
Hitonabi 0dc9caa955 Merge branch 'main' in wartung/ui-politur (Konflikte in frontend/dist aufgeloest und neu gebaut) 2026-07-09 12:00:24 +02:00
Hitonabi eb3caf6476 Wartung: UI/UX-Politur fuer MC2.0 (Sidebar-Gruppierung, Kachel-Livedaten, Memory-Splitscreen und Mobile-Touch) 2026-07-09 11:58:16 +02:00
Auftragsbuch 36c5cd1632 Auftragsbuch: 'wartung/verbinden-desktop-fokus' angenommen (Ein-Klick-Gate) 2026-07-09 11:27:30 +02:00
Hitonabi 47ec64f61b Verbinden-Tab: Hermes Desktop als Hauptweg, Legacy-Tools in den Ausklapper
Konsequenz aus dem Zed-Aus: Der Tab führte alle Anbindungen gleichrangig,
obwohl der rohe /v1-Zugang am PC kaum noch reale Nutzer hat. Neu:
- "Hermes Desktop anbinden" als prominente eigene Karte (Anleitung + Status).
- Kilo Code / Claude Code / Gedächtnis-MCP in den zugeklappten Ausklapper
  "Sonstige Tools (roher /v1-Zugang + Gedächtnis-MCP)" — alles weiter da,
  nur nicht mehr im Vordergrund. Drei-Leitungen-Hero bleibt.
- Header-Text entsprechend.
Browser-verifiziert gegen die Box (Karte, Ausklapper zu/auf, Leitungen grün);
tsc + Build grün, dist mit-committet, package-lock unangetastet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:26:43 +02:00
Auftragsbuch 3572667e08 Auftragsbuch: 'wartung/pc-pfad-cache-skill' angenommen (Ein-Klick-Gate) 2026-07-09 11:20:19 +02:00
Auftragsbuch 13810b6e74 Auftragsbuch: 'wartung/orchestrator-feinschliff' angenommen (Ein-Klick-Gate) 2026-07-09 11:20:18 +02:00
Hitonabi 15d5249458 Orchestrator-Feinschliff: drei belegte Fehlerbilder aus den Läufen vom 09.07.
1. "Auf der Box gibt es KEIN Node" war FALSCH — zwei Läufe haben dort gebaut.
   Neu: im Worktree bauen + dist mit-committen (AGENTS.md-Pflicht), danach
   package-lock.json zurücksetzen, wenn npm install es nur als Nebeneffekt
   anfasste (3 Läufe in Folge hatten lockfile-Dreck).
2. Schreibziel-Pflicht: jedes write_file/patch-Ziel muss unter /tmp/orch-<slug>/
   liegen; nie in den Live-Checkout, nie fremde Slug-Verzeichnisse recyceln
   (Doku-Lauf schrieb in ~/mission-control-v2, weil das alte Work-Dir
   wiederverwendet wurde).
3. Grosse Artefakte nie im Klartext in die Antwort — der Doku-Lauf starb am
   Output-Limit vor Kritiker-Gate und Vorschlag. Antwort = Pfade + Kurzfassung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:13:15 +02:00
Hitonabi 91e1f1ec9a Skill pc-pfad-cache: Traum-Entwurf aufbereitet (Cache + pc_shell-Tool-Suche)
Übernimmt den unproponierten Entwurf aus /tmp/orch-pc-pfad-cache (Users
Orchestrator-Läufe 09.07. früh) mit einem konstruktiven Fix: der Entwurf rief
pc_shell als Shell-Befehl auf — das ist aber ein Agent-Tool, kein Binary, der
Fallback konnte nie funktionieren (der untracked .py-Rest war ein angefangener
Reparaturversuch via MCP-stdio, unnötig kompliziert). Neues Design:
pc_path_lookup.sh = reiner YAML-Cache (lookup/merken/liste, exit 3 = Miss);
die PC-Suche bei Cache-Miss macht der Agent selbst mit seinem pc_shell-TOOL
und merkt den Fund. SKILL.md im Hausstandard, deploy.sh installiert Skill +
Skript. Getestet auf der Box: merken/lookup-Roundtrip inkl. Umlaut-
Normalisierung (Ä→ae, dabei tr-Umlaut-Falle gefixt), Miss=exit 3, bash -n grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:11:33 +02:00
Auftragsbuch 974e81fe49 Auftragsbuch: 'orchestrator/desktop-gateway' angenommen (Ein-Klick-Gate) 2026-07-09 11:05:05 +02:00
Hitonabi 19ab42da1a feat: Desktop-Gateway als dritte Leitung im Verbinden-Tab
- Backend: check_health() prüft jetzt auch Desktop-Gateway (GET /api/status :9119)
- Frontend: ConnectHealth-Typ erweitert, dritte Status-Zeile mit emerald-Theme
- Frontend-Build: neu gebaut mit Vite (Hash-Dateinamen aktualisiert)
2026-07-09 10:28:02 +02:00
Auftragsbuch 616518e2fc Auftragsbuch: 'wartung/doku-hermes-desktop' angenommen (Ein-Klick-Gate) 2026-07-09 10:10:59 +02:00
Hitonabi 592057c53f Doku an die Hermes-Desktop-Realität angepasst (Umbau 09.07.2026)
- BEDIENUNG.md: Bereichs-Liste auf die echten 10 Sidebar-Tabs gebracht
  (Auftragsbuch/Wissen/Chronik/Konsole fehlten komplett), Verbinden-Abschnitt
  auf Hermes Desktop/Kilo/Claude Code umgestellt (Zed/Roo abgeschafft),
  Verweis "Arbeiten = Desktop-App, MC2 = Maschinenraum".
- DISASTER_RECOVERY.md: hermes-terminal-Zeile durch Desktop-Gateway
  (hermes-builtin-ui) + Token-Drop-in + Token-Kopie ersetzt (übernimmt die
  gerettete Orchestrator-Änderung), nesquena-WebUI-Zeile durch
  Hermes-Desktop-Eintrag (PC) ersetzt, Wizard-Checkliste angepasst.
- HERMES_SETUP.md: §3 (nesquena-webui-Install, obsolet) durch "Hermes
  Desktop (PC) anbinden" ersetzt: Token-Drop-in auf der Box,
  connection.json am PC, MC2-Proxy-URL, cli-Toolset-Hinweis.

Hintergrund: Der Orchestrator-Lauf zu diesem Auftrag delegierte erstmals
echt (worker.sh plan/build), scheiterte aber an falschem Schreibziel
(Live-Checkout statt Worktree) + Output-Limit — Rest von Hand zu Ende
geführt, gerettete Änderung übernommen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:08:43 +02:00
Auftragsbuch c0a97b1004 Auftragsbuch: 'wartung/hermes-terminal-unit-aufraeumen' angenommen (Ein-Klick-Gate) 2026-07-09 09:39:57 +02:00
Auftragsbuch e078e37a87 Auftragsbuch: 'orchestrator/hermes-desktop' angenommen (Ein-Klick-Gate) 2026-07-09 09:39:55 +02:00
Auftragsbuch 6e3696563b Auftragsbuch: 'wartung/orchestrator-selbstbau-verbot' angenommen (Ein-Klick-Gate) 2026-07-09 09:39:54 +02:00
Hitonabi b4a54304a4 Orchestrator-Härtung: "Task zu klein" ist kein Freibrief
Vorfall 09.07.2026 (Session 20260709_090725_9eebf3): Der Manager übersprang
Planung, Delegation UND das Zwei-Kritiker-Gate mit der Ansage "Overkill für
so einen kleinen Task" und baute selbst — transparent, aber ein Bruch des
Pflicht-Gates (ein Agent, der sein eigenes Werk durchwinkt). Neue Leitplanke:
kein Ermessensspielraum; der einzige erlaubte Ausweg bei zu kleinen Aufträgen
ist Abbruch ohne Code mit Verweis auf Werkstatt/Desktop. Code-liefernde Läufe
enthalten IMMER worker.sh-Belege und beide Kritiker-Verdikte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:33:31 +02:00
Auftragsbuch 041ac70a80 package-lock.json wiederhergestellt (npm-install-Nebeneffekt, gehoert nicht zum Patch) 2026-07-09 09:24:00 +02:00
Hitonabi 5f81ae0731 Connect-Tab: Zed durch Hermes Desktop ersetzen
- Backend: zed-Eintrag → hermes_desktop mit Anleitung (Gateway-URL + Token-Pfad)
- Frontend: Default-Tab auf Hermes Desktop, Hero-Text aktualisiert
- AGENTS.md: Zed-Erwähnung entfernt
- dist: Frontend-Build aktualisiert
2026-07-09 09:14:25 +02:00
Hitonabi a3068c1b08 Aufräumen: deploy/hermes-terminal.service aus dem Repo
Der Dienst ist seit der MC2-Diät stillgelegt (deploy.sh disable --now +
Unit-Löschung, auf der Box verifiziert inactive). Die Repo-Datei blieb nur
einen Deploy-Zyklus als Schutz gegen die deploy.sh-Selbst-Reset-Falle
(das alte deploy.sh kopierte sie noch beim Annahme-Deploy). Der Zyklus ist
durch — die Datei kann weg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:01:40 +02:00
Auftragsbuch 531a10f8e5 Auftragsbuch: 'wartung/mc2-diaet-desktop' angenommen (Ein-Klick-Gate) 2026-07-09 08:58:21 +02:00
Auftragsbuch e504037770 Auftragsbuch: 'wartung/traum-skill-hausstandard' angenommen (Ein-Klick-Gate) 2026-07-09 08:58:19 +02:00
Hitonabi 12b7e00f7d Traum: Skill-Kandidaten im /learn-Hausstandard entwerfen
Der nächtliche Traum beschreibt Skill-Kandidaten künftig gleich als
SKILL.md-Gerüst (Frontmatter name/description<=60/version + Wann nutzen /
Schritte mit echten Hermes-Tools / Belege) statt als Freitext — die
Werkstatt übernimmt beim "Beauftragen" ein fertiges Gerüst. Propose-only
bleibt unverändert (kein skill_manage im Traum, Fremdblick-Gate davor).
Schließt das offene Traum-Increment "echte Skill-Extraktion" ab, indem es
den /learn-Autorenstandard von Hermes v0.18 in den Kreislauf holt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:55:23 +02:00
Hitonabi cb9de01feb Merge wartung/role-metadata-texte: gemeinsame dist-Basis
dist-Konflikt (Hash-Assets beider Builds) durch frischen Build aufgelöst —
damit sind BEIDE Auftragsbuch-Karten in beliebiger Reihenfolge Ein-Klick-fähig
(role-metadata zuerst = sauber; diaet enthält die Rollen-Texte mit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:52:03 +02:00
Hitonabi d9920fba8e MC2-Diät: Hermes-Fenster-Rolle an Hermes Desktop abgegeben
Der "Hermes GUI"-Tab (iframe auf /hermes-ui/) und das Hermes-Terminal-ttyd
(:7681) entfallen — die Agent-Oberfläche ist jetzt die Hermes-Desktop-App,
die über den BESTEHENDEN MC2-Proxy /hermes-ui/ (WS inklusive) an die Box
andockt. hermes-builtin-ui (:9119) bleibt als Desktop-Gateway erhalten und
wird im Dienste-Drawer + /api/system/services erstklassig geführt (statt
hermes-terminal); Wartungs-Allowlist kann ihn jetzt neu starten.

- Frontend: Tab/View raus (nav.ts, App.tsx, TerminalView.tsx gelöscht),
  AgentView + (Legacy-)AgentStatusCard zeigen Desktop-Gateway statt ttyd,
  Dienste-Zeile hermes-terminal → hermes-builtin-ui, Versions-Zeile
  "Hermes UI" (nesquena-Karteileiche ~/hermes-webui) entfernt.
- Backend: terminal_url/terminal_reachable + HERMES_TERMINAL_* raus,
  console.py proxyt nur noch die Box-Konsole, /api/system/services führt
  hermes-builtin-ui, USER_SERVICES getauscht, hermes-webui-GitInfo raus.
- deploy.sh: legt hermes-terminal + hermes-webui idempotent still
  (disable --now + Unit-Löschung). deploy/hermes-terminal.service bleibt
  EINEN Zyklus im Repo (das laufende alte deploy.sh kopiert es noch —
  Selbst-Reset-Falle); Aufräum-Commit folgt nach dem nächsten Deploy.
- frontend/dist mit-committet (Box baut nicht selbst).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:49:40 +02:00
Hitonabi 6704de3a69 build(frontend): rebuild assets nach ROLE_METADATA-Änderung 2026-07-09 08:21:40 +02:00
Hitonabi ba238ee7bd feat(frontend): Rollen 'kritiker' und 'reranker' mit deutschen Beschreibungen
- kritiker: 'Kritisches Prüf-Hirn' (Scale-Icon)
- reranker: 'Gedächtnis-Sortierer' (Search-Icon)
- Neue Icons Scale und Search aus lucide-react importiert
2026-07-09 08:17:56 +02:00
Auftragsbuch 00000548b4 Auftragsbuch: 'wartung/annahme-probe' angenommen (Ein-Klick-Gate) 2026-07-08 21:45:59 +02:00
Hitonabi 54be1fb1f6 Zeitmaschine: kompaktes Snapshot-Datum lesbar anzeigen + Auftragsbuch-Doku
fmtSnap kannte nur Zeitstempel mit Trennzeichen (2026-07-08T...), backup.sh
schreibt aber kompakt (20260708-181136) — Datum erschien roh. Regex tolerant
gemacht. Dazu docs/AUFTRAGSBUCH.md (Funktionsweise + Leitplanken); dieser Branch
ist zugleich die erste echte E2E-Annahme-Probe des Auftragsbuchs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:43:38 +02:00
Hitonabi 0249b2600c Merge wartung/produkt-ausbau: Auftragsbuch + Chronik/Zeitmaschine + Wissens-Wiki + Morgenlage + Erinnerungen 2026-07-08 18:42:53 +02:00
Hitonabi 1985a692bf UI: Auftragsbuch, Chronik+Zeitmaschine, Wissens-Wiki, Morgenlage, Erinnerungen
Neue Views: Auftragsbuch (Karten mit Diff/Kritiker-Kontext, Annehmen/Ablehnen mit
Fangnetz-Dialog), Chronik (Tages-Timeline aus dem Briefkasten + Zeitmaschine mit
Ein-Klick-Restore), Wissen (Vault als klickbares Wiki, [[Links]] springen).
Cockpit: Auftragsbuch-Kachel + Braucht-dich-Eintrag, Morgenlage-Karte (juengstes
Chef-Verdikt), Anstehendes (Erinnerungen anlegen/loeschen), Wissen/Chronik-Kacheln.
Gedaechtnis: Dubletten-Dialog zeigt jetzt konkret, was bleibt und was wegfaellt.
dist mitgebaut (tsc+vite gruen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:42:22 +02:00
Hitonabi 4dc5ca7343 Auftragsbuch/Chronik/Wissen/Zeitmaschine: Backend + Annahme-Runner (Ein-Klick-Gate)
Der letzte Meter des propose-only-Kreislaufs: Vorschlags-Branches (Werkstatt/
Orchestrator) und Traum-Skill-Kandidaten werden als API sichtbar; Annehmen laeuft
als detached systemd-Unit (Merge im Worktree -> Push main -> Deploy -> Health ->
Auto-Revert bei Rot). Dazu: Chronik-Endpoint (Announce-Store als Timeline),
Wissens-Vault-Reader (read-only, Traversal-Guard), Zeitmaschine (Snapshots +
detached Restore) und Morgenlage-Spiegel im Chef-Gutachter-Feed (priority=silent).
python-multipart explizit in requirements (voice braucht es, war implizit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:42:03 +02:00
Hitonabi 8b6dbd4aa1 Backup: Known-Good-Manifest (pip freeze, Engine-Build, Modell-Liste) im Tarball
Die requirements sind teils ungepinnt — bei einer Neuinstallation sagt
known-good/, welche Versionen nachweislich zusammen liefen: pip freeze je
venv (backend/mem0/voice), llama.cpp+llama-swap-Version, MC2+Hermes-Git-
Staende, OS/Kernel, GGUF-Bestand. Best-effort, restore.sh unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:02:17 +02:00
Hitonabi 2d95b0a334 Fix: Hermes-Update meldet Fehlschlag jetzt ehrlich (rc-Maskierung entfernt)
Befund 08.07.: 'hermes update' scheiterte real an einem verwaisten
.git/index.lock (git stash exit 1), aber der Job wurde GRUEN — das ';'
vor dem Dienst-Neustart (noetig, damit die gestoppte UI auch im
Fehlerfall wieder hochkommt) schluckte den Update-Fehler, und der
Postcheck bestand mit dem ALTEN Agenten. User sah 'done', Badge blieb
(125 Commits Rueckstand). Neu: RC wird festgehalten, Dienste starten
IMMER wieder, aber der Job uebernimmt den echten Update-RC und
schreibt eine Klartext-Zeile ins Log. Lock auf der Box entfernt
(Verwaisung von gestern 21:22, wie beim MC2-Repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:46:39 +02:00
Hitonabi f2d357c5d8 Update-all-Button: alle ausstehenden System-Updates in EINEM Job
Neuer POST /api/maintenance/update-all kettet die AUSSTEHENDEN Updates
sequenziell (Engine -> Router -> Hermes -> OS) in einem maintenance-Job.
Bewusst reine Wiederverwendung: jeder Teil ist exakt der Befehl des
Einzel-Updates inkl. dessen Backup/Postcheck/Selbst-Rollback; &&-Kette
stoppt beim ersten Fehler, Banner-Zeilen im Log zeigen den Schritt.
OS zuletzt (breitester Eingriff, braucht als einziges das Box-Passwort;
fehlt es, laufen die sudo-freien Teile trotzdem und OS wird uebersprungen).
Hermes-Befehlskette in _hermes_update_cmd() extrahiert (DRY).

UI (SystemDrawer/Updates): Button 'Alle aktualisieren (N)' neben der
Update-Suche, nur sichtbar wenn etwas aussteht, gesperrt waehrend ein
Wartungs-Job laeuft, mit Bestaetigungs-Dialog der die Kette benennt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:36:52 +02:00
Hitonabi 49807ace7b Frontend-Rundumschlag: Health-Logik + Statuszeile an neue LLM-Landschaft angepasst
(1) useLucyHealth: ruhende Augen sind seit der Warm-Set-Diaet 07.07. der
NORMALZUSTAND (vision on-demand, Lucy waermt selbst) -> kein Dauer-warn
mehr im Cockpit ('Kleinigkeit an der Box' ohne Grund). Geladen = 'sieht
gerade zu', ruhend = ok mit ehrlicher Erklaerung + Weck-Knopf.
(2) Cockpit-Statuszeile SAGT jetzt, was der Hinweis ist (erster warn-Check
mit Label+Detail statt generischem 'Nichts Schlimmes — nur ein Hinweis';
User-Feedback 08.07.). useLucyHealth exportiert dafuer die warns-Liste.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:53:23 +02:00
Hitonabi 1b35f70dc6 UI: kritiker + reranker als erstklassige Rollen sichtbar
Die zwei neuen Rollen aus dem Rollen-Audit tauchen jetzt ueberall
ordentlich auf statt als namenlose Eintraege:
- ROLE_IDS (llamaswap + sources) um kritiker/reranker erweitert
- roleMeta.ts: 'Kritiker' (Scale, cyan — die unbestechliche Zweitmeinung)
  + 'Gedaechtnis-Sortierer' (ListOrdered, lime — Feinsortierung hinter
  dem Gedaechtnis, direkt nach embed einsortiert)
- werkbank/roleColors.ts: Speicherleisten-Farben passend dazu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:25:20 +02:00
Hitonabi 88ec8b9121 Rollen-Ausbau: kritiker (GLM-4.7-Flash) + Gedaechtnis-Reranker (Qwen3-Reranker-0.6B)
Ergebnis des Rollen-Audits 07.07. (User: 'das beste, aber sinnvollste'):

(1) KRITIKER: fremdblick-Prosa + Chef-Gutachter-Vertretung liefen auf der
9B-Vision-GLM, obwohl es reine Text-Jobs sind. Neu: GLM-4.7-Flash
(30B-A3B, MIT, Unsloth UD-Q4_K_XL, 17,5 GB, on-demand ttl 600, Alias
'kritiker'). Gleicher Fremd-Vendor wie bisher -> die 'andere Brille'
des Kritiker-Konzepts bleibt. 4.6V-Flash bleibt scout (Bild-Jobs).

(2) RERANKER (die eine echte Rollen-Luecke): Mem0-Suche war reine
Vektor-Aehnlichkeit. Neu: Qwen3-Reranker-0.6B (q8_0, Mungert-GGUF —
Community-Konvertierungen liefern bekannt Nullscores) via llama-swap
/v1/rerank ordnet die Top-20 Kandidaten nach echter Relevanz um.
NUR die Reihenfolge aendert sich: score bleibt Vektor-Score (Hermes-
Plugin-Filter RECALL_MIN_SCORE bleibt kalibriert), rerank_score kommt
als neues Feld dazu; jeder Fehler -> lautlos Vektor-Reihenfolge.
Env: MC_RERANK_ENABLED/_URL/_MODEL/_TIMEOUT/_CANDIDATES. In brains
(verdraengungssicher, ~0,7 GB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:21:57 +02:00
Hitonabi f88ed4e5df LLM-Landschaft: Vision raus aus dem Warm-Set (on-demand, ttl 900) + Template=Live-Abgleich
User-Entscheid 07.07.: 'Gehirn + Embedding reichen komplett aus' — die Augen
(VL-30B, ~19 GB) werden nur gebraucht, wenn Lucy auf dem Desktop an ist oder
die IDE-Lane sie ruft. Warm-Set = nur noch Qwen3.6 + embed; damit passt der
60-GB-Chef-Gutachter (gpt-oss) wieder neben das Hirn.

- llama-swap.config.yaml: VL-30B aus brains raus, ttl 0 -> 900 (15 min
  Nachlauf); Kommentare entstaubt. AUSSERDEM Template<->Live-Drift beendet:
  Template hatte coder-lite wiederbelebt (live seit 05.07. entfernt) und
  KV q4_k (lief NIE live) — beides auf Live-Wahrheit (q8_0, kein coder-lite)
  zurueckgesetzt; Qualitaet vor ein paar GB, RAM-Engpass ist mit der Diaet weg.
- warmup.sh: Default 'fast vision' -> 'fast' (Root-Kopie unter
  /usr/local/bin braucht spaeter einmal sudo cp — bis dahin waermt ein
  llama-swap-Restart vision einmalig, ttl 900 raeumt es wieder ab)
- warmer.py: Docstring auf neues Warm-Set angepasst
- Lucy-Seite (eigenes Repo): App waermt die Augen beim Start + alle 10 min,
  solange sie laeuft — Augen-Lebenszyklus == Lucy-Lebenszyklus

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:53:06 +02:00
Hitonabi d68ee60182 Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt naechtlich ueber die autonome Arbeit
User-Idee 06.07.: das grosse Modell NICHT im heissen Pfad, sondern als
naechtlicher Gutachter (04:30) — Kaltladen ist dann egal, Lucy bleibt
tagsueber schnell. Feed sammelt 24h-Beweise (Vorschlags-Branches, main-
Commits, Vault-Notizen, Journal-Warnungen, Insights), der Richter laeuft
als Ein-Schuss-Completion gegen llama-swap (fremdblick-Architektur).
Richter-Kette: gpt-oss-120b -> GLM-4.6V-Flash als gekennzeichnete
Vertretung (gpt-oss wirft neben dem Warm-Set weiterhin 'exited
prematurely', E2E 07.07. verifiziert). Bote = Cron-Agent mit striktem
Treu-Wiedergeben-Mandat (chef-gutachter-prompt.md), Ausfall wird ehrlich
gemeldet statt verschwiegen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:26:24 +02:00
Hitonabi f65f9ca2d0 Hermes-Kontext-Ehrlichkeit + Leistungs-Graphen zurueck im Cockpit
(1) /v1/models meldet jetzt den ECHTEN Kontext pro Slot: --parallel teilt
den Server-Kontext hart auf (131072 / 2 Slots = 65536). Vorher budgetierte
Hermes gegen 131k -> Kompaktierung feuerte nie, llama-server kappte bei
~52k-Sessions Prompt/Antwort -> abgerissene Tool-Calls, Retry-Schleifen,
gefuehlte Abstuerze (journalctl 07.07 17:11, 152 msgs / ~51.700 Tokens).
Dazu auf der Box: context.engine 'default' (existiert nicht, warnte jeden
Turn) -> 'compressor' (der echte eingebaute Name).

(2) Cockpit: neue Sektion 'Leistung' mit den drei bestehenden Live-Karten
(System-Status, Token-Durchsatz, Latenz je Turn) — waren nach UI v3 nur
noch in der alten Zentrale, User will sie auf der Startseite. Browser-
verifiziert gegen die Box (rendert live, keine Konsolen-Fehler).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:18:08 +02:00
Hitonabi 092dcc2ada fix(hermes-ui): switch from headless 'hermes serve' to 'hermes dashboard --no-open' 2026-07-07 17:27:49 +02:00
Hitonabi 989b6de850 fix(maintenance): cleanly stop, rebuild and restart hermes-builtin-ui during agent updates 2026-07-07 17:25:35 +02:00
Hitonabi 6f77dbb9d9 chore(config): remove missing Qwen3.5-122B and Qwen3-VL-8B, promote gpt-oss-120b 2026-07-07 17:18:31 +02:00
Hitonabi edf1614e66 feat(perf): Optimize KV-Cache to Q4_K and fix Hermes Context bounds 2026-07-07 17:11:50 +02:00
root 97be0f1d00 feat: lower memory dedupe threshold for more aggressive cleaning 2026-07-07 16:45:30 +02:00
Hitonabi c92f238d2d refactor: architektur-optimierungen (systemd, jq, httpx, commit-hook) 2026-07-06 20:38:18 +02:00
Hitonabi 3fba487963 feat: orchestrator skill nutzt gpt-oss-120b zur initialen planung 2026-07-06 20:28:45 +02:00
Hitonabi f49e5ecf9d fix: relative imports in app.py wiederhergestellt 2026-07-06 20:17:17 +02:00
Hitonabi cb3a5adaf9 Merge origin/main: fix absolute imports and add reset-failed for hermes-builtin-ui 2026-07-06 20:10:33 +02:00
Hitonabi 87c479323c chore: remove invalid allowScripts field from package.json 2026-07-06 20:06:54 +02:00
Hitonabi b95530ee70 fix: absolute imports and type annotations in app.py
- replace implicit relative imports with absolute ones (from backend.*)
- add type hints for lifespan tasks list and middleware
- fix unused bool return value in task.cancel() call
2026-07-06 20:06:40 +02:00
Hitonabi 42e45b4b50 Hermes-GUI: eingebaute hermes serve-Weboberflaeche same-origin in MC2 einbetten (ersetzt CLI-Terminal-Tab)
Statt des ttyd-CLI-Terminals zeigt der Tab jetzt die reiche eingebaute Hermes-Web-GUI
(hermes serve/dashboard: Threads, sichtbare Tool-Calls, Sessions, Config) - same-origin,
ohne offenen Port, ohne Login (Loopback :9119 = LAN-Trust wie Terminal/Konsole).

- backend/routers/hermes_ui.py: generischer Reverse-Proxy /hermes-ui/* (alle Methoden + beliebige
  WebSockets) -> 127.0.0.1:9119; injiziert window.__HERMES_BASE_PATH__=/hermes-ui in die index.html,
  damit die SPA ihre API/WS-Calls unter dem Praefix absetzt (sonst Kollision mit MC2s /api).
- deploy/hermes-builtin-ui.service: hermes serve --skip-build auf Loopback :9119.
- deploy/deploy.sh: baut das GUI-Bundle mit Vite-base=/hermes-ui/ (Temp->Swap, ueberlebt Hermes-
  Updates) + installiert/startet den Dienst.
- backend/app.py + config.py + services/agent.py: Router registriert, Upstream/Pfad + Status-Feld.
- frontend: Terminal-Tab -> "Hermes GUI" (TerminalView iframe /hermes-ui/, nav-Label, vite-Dev-Proxy).

E2E im Browser verifiziert: GUI rendert eingebettet, /hermes-ui/api/* liefern echte Daten
(289 Sessions, verbundene Plattformen), Assets + Plugins laden, Basis-Pfad korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:49:39 +02:00
Hitonabi 3a6e3c5432 Hermes-GUI: eingebaute hermes serve-Weboberflaeche same-origin in MC2 einbetten (ersetzt CLI-Terminal-Tab)
Statt des ttyd-CLI-Terminals zeigt der Tab jetzt die reiche eingebaute Hermes-Web-GUI
(hermes serve/dashboard: Threads, sichtbare Tool-Calls, Sessions, Config) - same-origin,
ohne offenen Port, ohne Login (Loopback :9119 = LAN-Trust wie Terminal/Konsole).

- backend/routers/hermes_ui.py: generischer Reverse-Proxy /hermes-ui/* (alle Methoden + beliebige
  WebSockets) -> 127.0.0.1:9119; injiziert window.__HERMES_BASE_PATH__=/hermes-ui in die index.html,
  damit die SPA ihre API/WS-Calls unter dem Praefix absetzt (sonst Kollision mit MC2s /api).
- deploy/hermes-builtin-ui.service: hermes serve --skip-build auf Loopback :9119.
- deploy/deploy.sh: baut das GUI-Bundle mit Vite-base=/hermes-ui/ (Temp->Swap, ueberlebt Hermes-
  Updates) + installiert/startet den Dienst.
- backend/app.py + config.py + services/agent.py: Router registriert, Upstream/Pfad + Status-Feld.
- frontend: Terminal-Tab -> "Hermes GUI" (TerminalView iframe /hermes-ui/, nav-Label, vite-Dev-Proxy).

E2E im Browser verifiziert: GUI rendert eingebettet, /hermes-ui/api/* liefern echte Daten
(289 Sessions, verbundene Plattformen), Assets + Plugins laden, Basis-Pfad korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:48:12 +02:00
Hitonabi 001ebb7742 Orchestrator-Skill: Werkstatt zum Fliessband verallgemeinert (zerlegen -> delegieren -> Zwei-Kritiker-Gate -> Vorschlag)
- deploy/worker.sh: Ein-Schuss-Arbeitsauftrag an ein Worker-Modell (Gegenstueck zu fremdblick.sh),
  Fence-Strip + Aufruf-Log (Delegations-Nachweis); v1 nur Coder-Next (heavy stirbt neben dem Warm-Set).
- deploy/skills/orchestrator/SKILL.md: Manager-Drehbuch, propose-only, Warm-Set-Schutz,
  Delegations-Pflicht + Evidenz, stage-vor-Diff, Zwei-Kritiker-Gate (Coder-Next Kompetenz + GLM Fremd-Blick).
- deploy/deploy.sh: worker.sh + orchestrator-Skill mit ausrollen.

E2E gegen die Box verifiziert (worker.log-belegte Delegation, Self-Gate + Zwei-Kritiker-Gate, propose-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:04:52 +02:00
Hitonabi 26deb1cec1 fix: coder_lite Default auf leeren String statt .strip() — verhindert falschen Router-Verweis auf nicht-existentes coder-lite-Modell 2026-07-06 11:00:24 +02:00
Hitonabi 93f447327b Fix: /v1/models auf geteilten Client (Regression aus 12c387a)
Beim Entfernen des httpx-Imports uebersehen: der /models-Endpoint nutzte httpx
noch direkt -> NameError/500. Jetzt zieht auch /models den geteilten
app.state.gw_client (request-Param ergaenzt). Live verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:24:18 +02:00
Hitonabi 12c387a6de Antigravity-Review-Fixes: Traversal-Guard, Client-Pooling, Cache-Lock, Doku
Vier verifizierte Befunde aus dem externen Review (Gemini 3.1 Pro):
- app.py: SPA-Fallback gegen Path-Traversal gehaertet (resolve + is_relative_to,
  liefert nur noch Dateien INNERHALB von frontend/dist aus).
- app.py/gateway_proxy.py: geteilter httpx.AsyncClient im lifespan statt neuer
  Client pro /v1-Anfrage (Keep-Alive/Pooling, spart Sockets unter parallelen Agent-Stroemen).
- system.py: check_versions_cached() mit threading.Lock + Double-Check gegen Scan-Stampede.
- AGENTS.md: Zeitzonen-Drift korrigiert (Box laeuft Europe/Berlin, nicht UTC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:21:32 +02:00
Hitonabi ff1b9a085f coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle
connect.py empfiehlt IDEs jetzt die realen Box-Modelle Coder (bauen) /
Planer (denken) / Augen (Bilder, images) direkt statt der "coding"-Lane
— deckt sich mit dem tatsaechlichen Zed-Setup. Zed-Snippet mit vollen
7-Feld-Capabilities + Provider "bosgame"; Kilo/Claude-Code auf model=coder.
coder-lite (Qwen3-Coder-30B) parallel aus der llama-swap-Config der Box
entfernt (war ungenutzt: coding-Lane nutzt ohne Policy immer den starken coder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:41:38 +02:00
Hitonabi 42f0497253 Engine-Update-Fix: neueste Release MIT Vulkan-Asset waehlen (404 vermeiden)
Ursache des fehlgeschlagenen Engine-Updates: die allerneueste llama.cpp-Release traegt
oft noch KEINE CI-Binaries (0 Assets). update-engine.sh lud aber stur von releases/latest
→ 404 „Download fehlgeschlagen".

Fix:
- update-engine.sh: nimmt die neueste Release, die wirklich ein ubuntu-vulkan-x64.tar.gz
  traegt, und nutzt dessen echte browser_download_url (robust gegen Namensaenderungen).
- maintenance.py: _engine_update_available + engine_update_details pruefen jetzt ebenfalls
  gegen die neueste ASSET-tragende Release → das Badge luegt nicht mehr („verfuegbar", aber
  Download 404). Verifiziert: aufgeloeste URL liefert HTTP 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:52:41 +02:00
Hitonabi a45bd586b6 Update-Passwort-Hinweis zeigt auf Einstellungen (Box-Zugang-Karte entfernt)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:47:19 +02:00
Hitonabi 032b9a8ee9 Konsole: Box-Zugang-Karte entfernt (redundant)
Der Login läuft jetzt über den su-Passwort-Prompt im Terminal selbst; die Passwort-
Verwaltung bleibt in System-Wartung → Einstellungen. Die doppelte „Box-Zugang"-Karte
auf der Konsole-Seite ist damit ueberfluessig und wird entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:45:48 +02:00
Hitonabi eff23643e6 Hermes-Terminal same-origin + Passwort-Login; Engine/OS-Update-Fixes
Terminals (wie die Box-Konsole):
- hermes-terminal bindet jetzt NUR Loopback (--interface lo --base-path /hermes-terminal)
  und wird von MC2 same-origin durchgereicht (routers/console.py generalisiert auf beide
  ttyd-Instanzen). Kein eigener Firewall-Port mehr noetig.
- Beide Terminals starten die Shell/CLI ueber `su - hitonabi` → fragen beim Oeffnen das
  Box-Passwort ab (PAM gegen das echte Konto, nichts gespeichert). „Login mit sudo-PW".
- agent_status.terminal_url = /hermes-terminal/ (+ reachable via Loopback-Check).

Engine-Update (llama.cpp) — Fix „nicht moeglich":
- update-engine.sh/update-swap.sh sind per sudoers NOPASSWD freigegeben → das fruehere
  `sudo true`-Passwort-Gate hat sie faelschlich blockiert (wenn kein/falsches Box-PW). Gate
  entfernt → Engine-/Router-Update laufen jetzt passwortlos.

OS-Update (apt) — Fix „nicht moeglich":
- DEBIAN_FRONTEND wird jetzt INNERHALB `sudo bash -c '…'` gesetzt statt `sudo VAR=… cmd`
  (sonst lehnt sudos env-Policy die Variable ab und das Upgrade bricht ab).
- Frontend verschluckt password_required/incorrect_password nicht mehr still, sondern zeigt
  einen klaren Hinweis (Box-Passwort in „Box-Zugang" setzen/pruefen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:18:55 +02:00
Hitonabi 00fc7d6d81 Box-Konsole hinter MC2-Reverse-Proxy (kein Firewall-Port noetig)
Problem: ttyd auf :7682 war von aussen per ufw geblockt (nur 7681/9001 offen), also
im Browser Timeout — und ufw oeffnen braucht sudo, das MC2 hier nicht passwortlos hat.

Fix: ttyd bindet jetzt NUR an Loopback (--interface lo, --base-path /console) und wird
von MC2 ueber den ohnehin offenen Port 9001 same-origin durchgereicht:
- routers/console.py: HTTP-Passthrough (index/token) + WebSocket-Bridge (tty-Subprotokoll
  auf beiden Seiten) → /console/ + /console/ws.
- app.py: console.router VOR dem SPA-Catch-all eingehaengt.
- config: BOX_CONSOLE_UPSTREAM (127.0.0.1:7682) + BOX_CONSOLE_PATH (/console/);
  agent_status liefert box_console_url=/console/ + reachable=Upstream-Check.
- deploy/box-console.service: --interface lo --base-path /console.
- vite: /console (ws:true) fuer die Dev-Vorschau geproxyt.

Kein Firewall-/sudo-Eingriff noetig; Konsole laeuft same-origin zum Dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:58:40 +02:00
Hitonabi 18afa37412 UI v3: Cockpit-Startseite + Modelle-Werkbank als Default, neue Box-Konsole
Kompletter Frontend-Umbau auf das abgenommene v3-Konzept, dazu zwei neue Funktionen.

Frontend (Neuordnung bestehender IST-Views, kein Backend-Umbau):
- Cockpit ist die neue Startseite: ruhige Bereichs-Kacheln (je 1 Live-Zahl +
  Status-Punkt), ehrliche Status-Zeile "Box gesund" + Speicher-Pille, "Braucht dich"
  (kritische Probleme mit 1-Klick-Reparatur + bereitliegende Updates).
- Modelle-Werkbank: Maschinenraum-Speicherleiste als Hero (Arbeitsspeicher-Balken,
  nach Rolle eingefaerbt + frei), darunter Master/Detail. Eingebettet als Haupt-Tab
  im Modell-Manager ("Werkbank"), "Modelle finden" + JobsBar bleiben.
- Sidebar/Nav neu strukturiert; alle Aktionen ueber die bestehenden /api-Endpoints.

Neue Features:
- Box-Konsole: zweites ttyd-Web-Terminal mit echter Login-Shell auf :7682 (direkter,
  SSH-artiger Box-Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie hermes-terminal).
  Neuer Dienst deploy/box-console.service + agent_status-Felder box_console_url/-reachable.
- Box-Zugang: das Host-Sudo-Passwort laesst sich jetzt direkt in der Konsole-Seite
  setzen/aendern/loeschen (lokal im Browser), statt nur versteckt im System-Drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:41:19 +02:00
Hitonabi 854393ce5f Werkstatt-Reviewer: Fremd-Kritik ueber fremdblick.sh statt kaputter heavy-Delegation
Das in Haertung 1b eingefuehrte Fremd-Modell-Review delegierte per delegation.model
an heavy (gpt-oss) — das aber als Kritiker DOPPELT disqualifiziert ist: Hermes'
MINIMUM_CONTEXT_LENGTH=64000 screent den 32k-heavy als delegate_task-Ziel raus, UND
heavy (60 GB) stirbt beim Laden neben dem VL-30B-Warmset (Health-Check-Timeout).
Die Fremd-Pruefung konnte damit im Werkstatt-Alltag STILL ausfallen (Durchwink-Gefahr,
genau das, was 1b verhindern sollte).

- deploy/skills/wartung/SKILL.md Schritt 4: statt delegate_task nun
  `FREMDBLICK_MODE=code fremdblick.sh` (Qwen3-Coder-Next, 128k, anderes Modell als der
  Qwen3.6-Worker, laedt klein). REPRODUZIERT-ODER-ABGELEHNT bleibt (im Raster verankert),
  3-Wege-Urteil ABGELEHNT/FREIGABE-MIT-VORBEHALT/FREIGABE, Fallback bei Ausfall = UNGEPRUEFT.
- deploy/fremdblick.sh: FREMDBLICK_MODE=code (Code-Review-Raster + Coder-Next) neben dem
  Default prose-Raster (Dreaming, unveraendert).

E2E gegen die Box verifiziert: kaputter Patch (falsche Bedingung) -> ABGELEHNT mit
konkreter Reproduktion; sauberer Fix (Leerlisten-Guard) -> FREIGABE. Kritiker diskriminiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:00:32 +02:00
Hitonabi f6029e25fc Dreaming (Phase 2): naechtlicher Traum-Cron + Wissens-Vault + Fremdblick-Helfer
Der Continual-Learning-Kreislauf als Capstone: idle-Zeit -> Session-Review ->
navigierbare Markdown-Notizen (Wissens-Vault, eigenes git-Repo) -> Vorschlag mit
Fremd-Modell-Kritiker, Mensch als Gate.

- deploy/dreaming-prompt.md: versionierter Traum-Auftrag. Reflektieren (insights/
  sessions/journey/curator) -> Notizen entwerfen -> Fremdblick PFLICHT-GATE (wartet
  auf Urteil) -> bereinigen+INDEX -> committen -> zuletzt berichten (Lucys Stimme).
  Propose-only: einziger Schreib-Ort ist der Vault; alles andere = Vorschlag.
- deploy/dreaming-feed.sh: bootstrappt ~/wissens-vault (idempotent) + sammelt
  Schlaf-Daten. INDEX zwischen Markern (kein Feed-Bleed in die Datei).
- deploy/fremdblick.sh: Ein-Schuss-Zweitmeinung von einem ANDEREN Modell
  (GLM-4.6V-Flash, anderer Vendor). Umgeht Hermes' 64K-Delegations-Floor UND das
  60-GB-Ladeproblem von gpt-oss/heavy (kollidiert mit VL-30B-Warm-Set).
- deploy.sh: kopiert feed + fremdblick nach ~/.hermes/scripts (chmod +x).

E2E gegen die Box verifiziert (cron-Kontext): Kritiker gatet + diskriminiert
(2 Overclaims als TRAEGT-NICHT abgelehnt, Zahlen korrigiert), Vault sauber
committed, Warm-Set unberuehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:41:59 +02:00
Hitonabi 51552ae836 Haertung 1b: Fremd-Modell-Kritiker erzwingen + Anti-Durchwink-Regel
Gesellenpruefungs-Lehre (03.07.): Reviewer winkte kaputten Patch durch.
- wartung/SKILL.md Reviewer-Schritt: FREMD-MODELL-PFLICHT explizit
  (Worker=hermes/Qwen3.6-35B != Reviewer=heavy/gpt-oss-120b via
  delegation.model; per-Aufruf-Modell gibt delegate_task nicht her ->
  Trennung aktiv pruefen, sonst als offene Kritik ausweisen), eigenes
  Raster statt Autor-Erzaehlung, harte Regel REPRODUZIERT-ODER-ABGELEHNT
  (Freispruch nur mit belegtem Vorher/Nachher, sonst = Kritik).
- selbstkritik-prompt.md: Fremdblick vor dem Absenden (delegierte
  Kritiker-Runde auf anderem Modell zerpflueckt Belege).

Akzeptanz verifiziert gegen die Box: bewusst kaputter Patch (Kommentar
behauptet Fix, Logik fixt nicht) -> gpt-oss-120b ABGELEHNT mit konkreter
Reproduktion. Kein Deploy in diesem Commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:36:22 +02:00
Hitonabi 78448220b2 Observability 1a: Per-Turn-Latenz-Trace + Latenz-Karte
voice_metrics.py: neben den rollenden Stats jetzt ein echter Per-Turn-Trace
(TurnTrace + Ringpuffer der letzten 60 Turns). Balken-Stufen zeitlich disjunkt
(stt · vision · hirn · gen); hirn misst ab mark_brain_start() VOR dem Hermes-
Request, damit die Vision-Zeit nicht doppelt gezaehlt wird. STT (davor) und
Mem0-Retrieve (Rueckruf waehrend) werden per park()/_take_* best-effort dem Turn
zugeordnet (Ein-Nutzer-Geraet, kein Turn-ID noetig). Mem0 = Unter-Detail INNERHALB
hirn (nicht addieren) -> ehrlich, kein Doppelzaehlen.

voice.py: TurnTrace in voice_chat's gen() (commit garantiert 1x via finally, auch
bei Fehler/Abbruch). STT-Endpoint parkt seine Dauer. NEU: GET /api/voice/metrics
(schliesst die Luecke - selbstkritik-feed.sh curlte das, existierte nie -> 404) +
GET /api/voice/trace?limit=N.

memory.py: GET /api/memory?q=... (= Hermes' Mem0-Prefetch) misst + parkt die
Retrieve-Zeit.

Frontend: LatencyCard (gestapelte Balken je Turn, "Taeter" = groesste Stufe,
Fehler-Turns rot, Tooltip mit "davon Mem0 X s"), Query useVoiceTrace, in der
Zentrale unter "Stack & Telemetrie". Lokal gegen Seed-Server verifiziert: 30,3-s-
Haenger -> Hirn-Balken 94% + "davon Mem0 26,1 s".

Grenzen (ehrlich, als Fussnote in der Karte): Tool-Runden im Hermes-LLM-Loop haben
keinen Callback an MC2 -> stecken in "Antwort". Reine Telegram-Text-Turns laufen an
MC2 vorbei und erscheinen hier nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:21:52 +02:00
Hitonabi 58021331e1 Warm-Set haerten: brains-Mitglieder bekommen automatisch ttl:0
set_group() setzte persistent:true (Verdraengungsschutz), aber NICHT ttl:0. persistent
schuetzt nur gegen Verdraengung durch andere Modelle, nicht gegen ttl-Selbstentladen →
ein Mitglied mit ttl>0 faellt trotz brains-Mitgliedschaft nach Leerlauf aus dem Warm-Set
(live: VL-30B mit ttl 300 entlud sich alle 5 Min). Jetzt erzwingt set_group bei persist=True
ttl:0 fuer jedes Mitglied → der Fehler kann beim Warm-Set-Umbau nie wieder passieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:29:00 +02:00
Hitonabi ab63b05b9c Repo-Schnappschuss: VL-30B ttl 300->0 (Warm-Set-Drift schliessen)
Live war VL-30B (Augen) schon auf ttl:0 gesetzt, der Repo-Schnappschuss
deploy/llama-swap.config.yaml stand aber noch auf ttl:300 -> bei einer
Neu-Provisionierung aus dem Repo waere der Warm-Set-Auskuehl-Bug
zurueckgekommen (persistent schuetzt nicht gegen ttl-Selbstentladen).
Kommentar auf den Live-Stand (Augen/vision, brains-Mitglied) aktualisiert.
Reiner Snapshot-Fix, kein Live-Effekt (Deploy fasst llama-swap-Config nicht an).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:19:10 +02:00
Hitonabi 8e4aca9f5c Discover: Katalog auf Juli-2026-Stand + Badge-Alias-Fix
Katalog-Daten (models_catalog.json) waren teil-stale (Stand 27.06.):
- vision: Qwen3-VL-30B-A3B-Instruct ergaenzt (neue Augen, MoE 30B-A3B) — sonst
  empfahl der "Modelle finden"-Tab weiter das aeltere VL-8B (Downgrade).
- scout: gemma-4-26B/31B (laengst entfernt) durch das live genutzte GLM-4.6V-Flash
  ersetzt.
- version 2026-06-27 -> 2026-07-03.
Bewusst NICHT aufgenommen: Qwen3.6-27B dense — im 3-Wege-Bench (tg 12,7 vs 60-91
der MoE-Coder) als Coder-Worker unterlegen, keine Empfehlung.

Badge-Fix (Discover.tsx): installedModel matchte nur das primaere role-Feld; ein
Modell kann eine Kategorie aber ueber einen Alias bedienen (Qwen3.6 = role "hermes"
+ Alias "fast") -> Fast-Karte zeigte faelschlich "Frei". Jetzt Match per Rolle ODER
Alias. Live gegen die Box verifiziert: alle 5 Rollen-Karten "Aktiviert".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:46:32 +02:00
Hitonabi 494037dee5 MC2 Final (D16 f): Voice-Metrik-Reste raus + README auf Final-Stand (C15)
f) Lucy/Voice-Reste aus dem MC2-Web-UI entfernt (kein "Sprechen"-Tab mehr):
   VoiceLatencyCard + Sprach-Latenz-Plumbing (queries/api-Typen) und der
   Backend-Leseendpoint /voice/metrics raus. Der Proaktivität-/Alarm-Kanal
   (/voice/announce, /alarm) und die STT/TTS/chat-Proxys bleiben unberührt.
C15) README auf den Final-/Autonomie-Stand aktualisiert (Mem0 statt SQLite-MCP,
   live+eingefroren, Lucy als eigenes Repo, Fangnetz-Updates/Werkstatt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:23:24 +02:00
Hitonabi 8a2db70f3c MC2 Final (D16 c+e): System-Logs mit Fehler-Farben/Filter + Auto-Dedupe
c) System-Logs ausgebaut: neue LogConsole-Komponente färbt Fehler (rot) und
   Warnungen (amber) ein, "Nur Probleme"-Filter mit Zähler, Zeilen-Suche;
   voice-service in die Dienst-Liste aufgenommen (war nur backend-seitig).
e) Mem0-Dubletten automatisch: deterministischer Auto-Dedupe-Loop (täglich,
   apply=True, Schwelle 0.9 > manueller 0.85 da ohne Review) als Backend-
   Hintergrund-Task — kein Memory-Bloat mehr ohne Zutun. Knopf bleibt on-demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:12:11 +02:00
Hitonabi 5ee3f8f73d D16d-Fix: volles Warm-Set nachwärmen statt nur das Hirn
Auto-Rewarm (Nudge + Idle-Tick) lädt jetzt das GANZE Warm-Set über
deploy/warmup.sh nach (fast+vision via chat, embed via /v1/embeddings,
Agent-Prompt-Prefill) statt nur einen Brain-Ping. Erkennt TEIL-Kälte
(Mitglied fehlt in /running), nicht nur den komplett leeren Zustand —
genau der Fall nach einem watch-config-Reload/Deploy (Augen+Gedächtnis
fielen raus, Hirn blieb warm). deploy.sh ruft am Ende warmup.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:57:31 +02:00
Hitonabi ce55752249 Doku (B11d): AGENTS.md — Projekt-Geschmack fuer die Coding-Lane
Zed + Kilo Code lesen AGENTS.md nativ. Destilliert die Projekt-Wahrheiten, die man
sonst schmerzhaft lernt: Deutsch fuer alles User-Sichtbare (Hermes-Prompts bleiben
englisch), frontend/dist wird committet (kein Node-Build auf der Box), Deploy macht
reset --hard origin/main (nie direkt auf main), Box=UTC/MC_LOCAL_TZ=Berlin,
py_compile-Gate, Restart-Allowlist nur in services.maintenance, Hermes nie forken,
Security nie ohne User-Ja, Gitea-Push mit Retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:45:34 +02:00
Hitonabi d0688a7b64 Wartung (B10): Restart-Allowlist vereinheitlichen
Der ServicesCard-"Anzeige- statt Unit-Name"-Bug ist behoben (Frontend nutzt
durchgehend x.unit). Der echte Rest-Fehler lag in der ZWEITEN Restart-Wahrheit:
/api/system/restart (system.py) + das MCP-Tool restart_service hatten eine
veraltete Allowlist ohne llama-swap (Engine) und hermes-terminal, dafuer mit
Geist-Eintrag hermes-webui. Ein Engine-Neustart per Sprache/MCP schlug daher mit
"nicht erlaubt" fehl.

Fix: /api/system/restart delegiert jetzt an services.maintenance.restart_service
(EINE Allowlist-Wahrheit, kennt System- via sudo -n UND User-Dienste, wird auch
von der UI genutzt). MCP-Tool-Docstring auf die echten Dienste korrigiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:45:34 +02:00
Hitonabi 4e5a7eed35 MC2 Final (D16 a/b/d): verständliche Updates + ehrliche Speicher-Zahlen
a) Update-Meldungen: generischer Release-Summarizer in Lucys Stimme mit
   strukturiertem Aktions-Verdikt ("Musst du etwas tun? NEIN/JA") für
   Hermes, Engine (llama.cpp) und llama-swap; Fangnetz-Hinweis verheiratet
   Breaking-Change-Sorge mit dem Postcheck.
b) OS ehrlich: zurückgestellte Pakete (Phasen-Rollout / kept back) werden
   ausgewiesen statt scheinbar zu hängen.
d) Ehrliche Speicher-Zahlen: KV-Cache aus echten GGUF-Architektur-Daten
   (Layer × KV-Köpfe × head_dim) + KV-Quant aus dem cmd statt params-blinder
   Schätzung — footprint_gb als eine Zahlensprache (Zentrale, Modell-Manager,
   fits-Check auf warmset+largest). Auto-Rewarm-Nudge nach Config-Reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:40:59 +02:00
Hitonabi 612ce127fa Autonomie 0d+0g: Lucy-Alarm-Weg, taegliche Selbst-Smokes, Selbstreparatur nach rotem Update
- /api/alarm: Lucy-unabhaengiger Telegram-Weg (fuer den PC-Lucy-Watchdog, wenn die App haengt).
- self-smoke.sh + mc2-selfsmoke.timer (taeglich 07:15): Gateway/Tools/Voice aktiv durchspielen,
  Meldung nur bei Rot. Ergaenzt den passiven Health-Waechter um echte Funktion.
- self-repair.sh + autoupdate.sh: bei rotem Hermes-Update zieht die Box Config-Brueche selbst
  (nur eindeutige, nicht-sicherheitsrelevante Keys; Backup -> YAML-Check -> Gehirn-Check, sonst
  zurueck). Greift es nicht, haengt eine LLM-Diagnose an die Rollback-Meldung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:27:31 +02:00
Hitonabi 357285e798 C12: Off-Box-Backup-Spiegel auf den Proxmox-Host (rsync)
backup.sh spiegelt nach dem lokalen Tarball die Backups per rsync aufs
Proxmox (root@192.168.178.108:/var/lib/vz/mc2-backups) -- Retention via
--delete mitgezogen, chmod 600 erhalten, best-effort (Fehlschlag = lokal
gilt trotzdem). restore.sh kennt den Spiegel: --list zeigt Off-Box,
--pull-offsite holt alles zurueck, und fehlt ein Backup lokal, wird es
automatisch vom Proxmox gezogen (Platte-tot-Fall). Nebenbei latenten
Abbruch in list_backups bei leerem Ordner gefixt (set -e + leeres Glob).
Auth: dedizierter, gehaerteter Key ~/.ssh/mc2_offsite. Live E2E verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:26:04 +02:00
Hitonabi b21794a750 Werkstatt haerten (0k): Reviewer muss Fehlerfall nachstellen, Gate prueft Live-Checkout
Zwei Befunde aus der Gesellenpruefung (03.07.):
- Reviewer-Subagent war zu milde -> Skill fordert jetzt: konkreten Fehlerfall
  nachstellen + aktiv nach Versagen suchen, Urteil konkret ausschreiben.
- "Live unberuehrt" war nur curl-geprueft -> Gate verlangt jetzt zusaetzlich
  git status --porcelain -uno == leer im Live-Checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:04:57 +02:00
Hitonabi 97344f1ad2 Werkstatt: Hermes-Update-Zusammenfassung bricht nicht mehr mitten im Satz ab
_summarize_hermes_commits nutzt jetzt finish_reason: bei "length" (Token-Limit
erreicht) wird der unvollstaendige letzte Stichpunkt entfernt, bei "stop" bleibt
die Antwort unveraendert. max_tokens 380 -> 550 als Puffer.

Erster echter Werkstatt-Kreislauf (Gesellenpruefung), Runde 2 nach Review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:55:53 +02:00
Hitonabi a682e3c4e7 Selbstkritik-Feed: Journal-Syntax, doppelte Nullen, Allowlist-blinde Messung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:04:42 +02:00
Hitonabi 8b0e14db7c Selbstkritik-Cron (Faden 11b): der Radar-Zwilling nach innen
- selbstkritik-prompt.md: Auftrag (1-3 belegte Verbesserungen, Lucys Stimme,
  Leitplanken: nur vorschlagen, Hermes-Quellcode tabu, Security nur benennen)
- selbstkritik-feed.sh: Live-Daten (Voice-Latenzen, Token-Stats, Prompt-Größen
  je Lane als Diät-Kontrolle, Journal-Warnmuster, Tool-Loops, Curator, notify-
  Fallbacks); deploy.sh kopiert ihn nach ~/.hermes/scripts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:02:12 +02:00
Hitonabi c8e408cdd8 Prompt-Diät (Faden 7): schlanker Voice-MCP-Server
mcp_voice.py = Sprach-Lane-Teilmenge: reminder_create/list/delete + box_status.
Die api_server-Plattform (Lucy) bekommt per platform_toolsets.api_server nur
diesen statt der vollen mission-control-stack-Control-Plane (26 Tools) —
CLI/Telegram/Werkstatt bleiben unangetastet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:54:13 +02:00
Hitonabi fa8f776910 Freie Hand (A3): Medien-/Lautstärke-Steuerung für den PC
- executor.py: /media (play_pause/next/prev/stop) + /volume (up/down/mute,
  steps 1-25) via pyautogui Media-Keys — wirkt wie Multimedia-Tastatur
- mcp_pc.py: Tools pc_media + pc_volume für Hermes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:29:21 +02:00
Hitonabi 2b124d5b0a Erinnerungen & Routinen (A3): Wecker in MC2 + Hermes-Tools
- services/reminders.py: persistenter Store (/srv/models/mc2-reminders.json),
  Wecker-Loop feuert in Briefkasten (Lucy spricht) + Telegram; repeat
  daily/weekdays/weekly; naive Zeiten = Commander-TZ (Europe/Berlin, Box ist UTC);
  verpasste Einmal-Erinnerungen feuern verspätet nach (ehrlich markiert)
- routers/reminders.py: GET/POST/DELETE /api/reminders
- mcp_mc.py: reminder_create/list/delete (Guards, ISO aus Sofort-Kontext)
- announce.notify_telegram als gemeinsamer Helper (sentry nutzt ihn jetzt)
- requirements: tzdata (zoneinfo-Fallback, Windows-Dev)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:02:30 +02:00
Hitonabi 019bd6d18b Lucy-Proaktivität (A3): Melde-Briefkasten + Health-Wächter
- services/announce.py: persistenter Briefkasten (/srv/models/mc2-announce.json),
  POST /api/voice/announce + GET /api/voice/announcements (Cursor-Polling)
- services/sentry.py: Health-Wächter (Engine/Hirn/Hermes/Mem0/Voice/Platte),
  flankenerkannt (Alarm nach 3 Fehl-Ticks, Entwarnung, 6h-Erinnerung),
  meldet in Briefkasten + Telegram; Hirn-Verdrängung durch IDE-Last = kein Alarm
- notify.sh spiegelt jede Telegram-Meldung in den Briefkasten (Updates/Radar
  erreichen damit auch die Desktop-Lucy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:45:07 +02:00
Hitonabi a23f4c0652 Warmup kaut jetzt auch den Hermes-Agenten-Prompt vor (Erster-Call-Fix)
Gemessen 03.07.: erster User-Call nach Modell-Reload 37 s (Prefill des
~70-KB-Hermes-Prompts, 50 KB davon Tool-Schemas), mit warmem Prompt-Cache
~6 s. warmup.sh schickt nach dem Modell-Laden einen Wegwerf-Turn an den
api_server (:8642, wartet auf /health) — der -cram-Cache ist damit gefuellt,
bevor der User zum ersten Mal fragt. Strukturelle Prompt-Diaet = Faden 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:32:28 +02:00
Hitonabi a024ea1b2f Zentrale: "Lucys Augen"-Check ergaenzt (Vision-Modell im Blick)
Nutzer-Fund: Die Faehigkeiten-Liste zeigte Hirn/Gedaechtnis/Gateway/Agent/
Stimme, aber nicht die Augen — obwohl das Vision-Modell seit heute zum
Immer-bereit-Set gehoert. Neuer Check (nicht kritisch): gruen wenn das
vision-Modell geladen ist, sonst Warnung mit 1-Klick-"Augen aufwecken".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:14:40 +02:00
Hitonabi d2ab662146 Immer-bereit-Set ehrlich gemacht: vision ttl 0, Warmup inkl. vision, Texte
Nutzer-Fund (Screenshots 03.07.): drei UI-Diskrepanzen im Modell-Manager.
1) Augen (VL-8B) hatten ttl 300 + fehlten im Warmup — standen also entgegen
   dem UI-Versprechen NICHT immer bereit. Jetzt ttl 0 (Box live + Snapshot)
   und Warmup-Default "fast vision".
2) Warn-Text beschrieb das alte Verdraengungs-Verhalten (seit persistent-Fix
   falsch) — jetzt: Set bleibt geladen, bei Ueberlauf scheitert das grosse
   Modell. 3) "Reserviert" als Vorsichts-Schaetzung/Obergrenze gekennzeichnet
   (68,8 GB Schaetzer vs. 25 GB real — Schaetzer-Umbau ist eigener Faden).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:10:44 +02:00
Hitonabi 50867b2781 Gateway: Deutsch-Direktive fuer IDE-Traffic + web_search-Tool im Web-MCP
1) /v1-Gateway haengt an jede Chat-Anfrage (ausser hermes/Lucy — SOUL.md
   regelt das selbst) eine System-Direktive an: Antworten auf Deutsch,
   Code/Bezeichner unveraendert. Abschaltbar via MC_GATEWAY_LANG_DIRECTIVE="".
2) mcp_web.py: neues Tool web_search (ddgs/DuckDuckGo, kein Key), Ergebnis
   injection-geschuetzt via wrap_untrusted. Damit koennen IDE-Agents (Zed
   via context_servers) und Hermes im Web suchen; Debug-Log geht auf stderr,
   stdout bleibt MCP-sauber (verifiziert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:55:22 +02:00
Hitonabi a2091b4f5f Warmhalte-Fix: llama-swap-Key heißt persistent, nicht persist
Live gefunden (Zed-Start warf Lucys Hirn raus): llama-swap ignoriert
unbekannte Group-Keys stillschweigend — der Verdrängungsschutz der
brains-Gruppe war seit jeher wirkungslos. set_group() schreibt jetzt
beide Keys (persistent für llama-swap, persist für MC2-API/UI),
budget.py rechnet die echte Ko-Residenz (on-demand reserviert das
Warm-Set statt 0), Warn-Texte beschreiben Überlauf statt Verdrängung.
Box-Config live gefixt + verifiziert: Coder und Qwen3.6 gleichzeitig ready.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:46:23 +02:00
Hitonabi dd3d66156b Werkstatt-Skill: Box pusht ihre Branches jetzt selbst zu Gitea (scoped Token)
Token mc2-box-werkstatt (write:repository) auf der Box hinterlegt (User-Freigabe 02.07.),
Push verifiziert. Leitplanke bleibt: NIE nach main pushen, Merge+Deploy macht der PC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
Hitonabi 18f36f52ce B9-Merge fertig: api.ts-Typ (unit) + frontend/dist gebaut
tsc bestaetigte exakt die vom Werkstatt-Gate angekuendigte Luecke (4x TS2339 unit)
-> ServicesResp.services um unit ergaenzt, Build gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:24:35 +02:00
Hitonabi 02fac7f55f Merge Werkstatt-Fix B9: ServicesCard nutzt systemd-Unit-Namen (Box-KI, Gesellenprüfung)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:23:37 +02:00
Hitonabi ff6c1daf6c Werkstatt: ServicesCard-Neustart-Bug fixieren
Das Frontend schickte den Anzeigenamen (z.B. 'Engine (llama-swap)')
an die Restart-API, die aber systemd-Unit-Namen (z.B. 'llama-swap')
erwartet. Backend gibt jetzt ein 'unit'-Feld mit; Frontend nutzt es
für restart(), disabled und key.
2026-07-02 19:00:26 +00:00
246 changed files with 29508 additions and 16877 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"mc2-memory": {
"command": "python",
"args": [
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
]
},
"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"
]
}
}
}
+27
View File
@@ -0,0 +1,27 @@
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
# Build-Output oder Binärdateien.
# Abhängigkeiten & Build-Output (kein Review-Ziel)
backend/.venv/
frontend/node_modules/
frontend/dist/
**/__pycache__/
*.pyc
.git/
# Große / binäre / sensible Dateien
*.vrm
*.gguf
*.onnx
*.safetensors
*.wav
*.env
.env
credentials*
*.key
*.pem
# Lokale Scratch-/Recon-Skripte
box_recon*
gemma_swap*
+9
View File
@@ -23,6 +23,15 @@
"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
} }
] ]
} }
+100
View File
@@ -0,0 +1,100 @@
{
"permissions": {
"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 push:*)",
"Bash(git rev-list *)",
"Bash(ssh hitonabi@192.168.178.151:*)",
"Bash(grep -E \"\\\\.py$|^d\")",
"Bash(grep -rn \"http://\\\\|https://\\\\|/srv/\\\\|/opt/\\\\|/etc/\" services/*.py routers/*.py)",
"Bash(awk '/^\\(async \\)?def /{in_func=1; func=$0; line=NR} in_func {count++} /^\\(async \\)?def / && NR!=line {if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"; count=0; func=$0; line=NR} END{if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"}' routers/*.py services/*.py)",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(\"\\(/api|/v1\\)[^\"`]*' frontend/src/)",
"Bash(sed -E 's/api\\(<[^>]+>\\)?\\\\\\(\"//')",
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(`[^`]*`' frontend/src/)",
"Bash(python -c ' *)",
"Bash(echo \"tsc exit: $?\")",
"WebSearch",
"WebFetch(domain:lobehub.com)",
"WebFetch(domain:docs.litellm.ai)",
"WebFetch(domain:github.com)",
"WebFetch(domain:runaihome.com)",
"WebFetch(domain:docs.getbifrost.ai)",
"WebFetch(domain:thefrontierlab.ai)",
"WebFetch(domain:www.glukhov.org)",
"WebFetch(domain:cognio.so)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 'curl -s http://127.0.0.1:8080/running; echo; echo \"--- after a quick hermes ping, whats running ---\"; curl -s http://127.0.0.1:8080/running')",
"Bash(git checkout *)",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 \"sed -n '46,75p' /etc/llama-swap/config.yaml\")",
"Bash(ssh hitonabi@192.168.178.151 \"node --version 2>/dev/null || echo 'no-node'; docker --version 2>/dev/null || echo 'no-docker'; python3 --version; systemctl --user list-units --type=service --state=running 2>/dev/null | head -10\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user cat hermes-gateway.service 2>/dev/null; echo '---'; ls ~/hermes-gateway/ 2>/dev/null || ls ~/mission-control-v2/deploy/ | grep gateway\")",
"Bash(ssh hitonabi@192.168.178.151 \"ls ~/.hermes/ && echo '---' && ls ~/.hermes/hermes-agent/ 2>/dev/null && echo '---' && cat ~/.hermes/config/config.yaml 2>/dev/null || cat ~/.hermes/config.yaml 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"journalctl --user -u hermes-gateway.service --no-pager -n 20\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/config.yaml | grep -A5 'api_server\\\\|api_key\\\\|gateway_api\\\\|secret' | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'gateway_api_key\\\\|api_server\\\\|HERMES_API_KEY\\\\|8642' ~/.hermes/config.yaml ~/.config/environment.d/ 2>/dev/null | head -20; echo '---'; cat ~/.config/environment.d/*.conf 2>/dev/null | grep -i hermes | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'hermes.*gateway\\\\|gateway.*url\\\\|8642\\\\|GATEWAY' ~/mission-control-v2/backend/ 2>/dev/null | grep -v '.pyc' | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"grep -A20 'def get_agent_status\\\\|def check\\\\|HERMES_API' ~/mission-control-v2/backend/services/agent.py | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json 2>/dev/null | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(d.get\\(\\\\\"api_key\\\\\"\\) or d.get\\(\\\\\"key\\\\\"\\) or \\\\\"no key field\\\\\"\\)' 2>/dev/null; echo '---'; ls ~/.hermes/auth* ~/.hermes/pairing/ 2>/dev/null\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway status 2>&1 | head -20; echo '---'; curl -s http://127.0.0.1:8642/health 2>/dev/null || curl -s http://127.0.0.1:8642/ 2>/dev/null | head -5\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\(\\\\\"platforms\\\\\", {}\\), indent=2\\)\\)'\")",
"Bash(ssh hitonabi@192.168.178.151 \"bash ~/mission-control-v2/deploy/deploy.sh\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway connect --help 2>&1 | head -30\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway enroll --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway setup --help 2>&1\")",
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main --help 2>&1 | head -40\")",
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/.env 2>/dev/null; echo '---'; ~/ .hermes/hermes-agent/venv/bin/python -m hermes_cli.main config --help 2>&1 | head -20\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_BOT_TOKEN=|TELEGRAM_BOT_TOKEN=8908266336:AAElPDmdEJXZ5cs0gUzbAnm4a9glRY18Zac|' ~/.hermes/.env && grep TELEGRAM_BOT_TOKEN ~/.hermes/.env\")",
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user restart hermes-gateway && sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 15\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 4 && journalctl --user -u hermes-gateway --no-pager -n 20 --since '1 minute ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 25 --since '2 minutes ago'\")",
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_ALLOWED_USERS=.*|TELEGRAM_ALLOWED_USERS=6150562984|' ~/.hermes/.env && grep TELEGRAM_ALLOWED ~/.hermes/.env\")",
"Bash(mkdir -p \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-stack-state.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-hermes-setup.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-mc2-architecture.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-pending-tasks.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== ENGINE VERSION ===\"; /usr/local/bin/llama-server --version 2>&1 | head -3; echo \"=== LLAMA-SWAP VERSION ===\"; \\(llama-swap --version 2>&1 || /usr/local/bin/llama-swap --version 2>&1 || echo \"no --version\"\\) | head -3; echo \"=== RUNNING MODELS ===\"; curl -s http://127.0.0.1:8080/running 2>&1 | head -c 2000; echo; echo \"=== FREE MEM ===\"; free -g | head -3')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG ===\"; cat ~/.hermes/config.yaml 2>&1 | head -120; echo \"=== HERMES DIR ===\"; ls -la ~/.hermes/ 2>&1 | head -40')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG \\(rest\\) ===\"; sed -n \"120,400p\" ~/.hermes/config.yaml 2>&1; echo \"=== TOOLS COUNT \\(api/all\\) ===\"; cd ~/.hermes 2>/dev/null; \\(hermes tools list 2>&1 | tail -40\\) || echo \"hermes CLI not on PATH\"')",
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 ' *)",
"Bash(xargs cat)",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== llama-server version ===\"; /usr/local/bin/llama-server --version 2>&1 | head -5; echo \"=== running models ===\"; curl -s http://127.0.0.1:8080/running 2>/dev/null | head -c 2000; echo; echo \"=== free ===\"; free -g')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== config.yaml ===\"; cat /etc/llama-swap/config.yaml; echo; echo \"=== models on disk ===\"; du -sh /srv/models/* 2>/dev/null | sort -h')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== Qwen3.6 MTP dir ===\"; ls -la /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/ 2>/dev/null; echo \"=== drafts dir ===\"; ls -la /srv/models/drafts/ 2>/dev/null; echo \"=== disk free ===\"; df -h /srv 2>/dev/null; echo \"=== gemma remnant ===\"; ls -la /srv/models/gemma-4-26B-A4B-it-GGUF/ 2>/dev/null')",
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"Bash(ssh -o ConnectTimeout=12 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
"WebFetch(domain:unsloth.ai)",
"WebFetch(domain:huggingface.co)",
"Bash(xargs ls -la)",
"Bash(xargs wc -l)",
"PowerShell(ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== engine ==='; /opt/llamacpp-vulkan/llama-server --version 2>&1 | head -3; echo; echo '=== llama-swap version ==='; /opt/llama-swap/llama-swap --version 2>/dev/null || llama-swap --version 2>/dev/null || ls -la /opt/llama-swap 2>/dev/null | head -5; echo; echo '=== running models ==='; curl -s http://127.0.0.1:8080/running; echo; echo '=== services ==='; for s in llama-swap mc2-backend hermes-api hermes-webui mem0-service voice-service; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo '=== uname/mem ==='; uname -r; free -g | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== ports ==='; ss -tlnp 2>/dev/null | grep -E ':\\(9001|8642|8650|8765|8787|7681|8080|8130\\)'; echo; echo '=== wer serviert 9001? ==='; systemctl list-units --all --type=service --no-pager --no-legend | grep -iE 'gateway|backend|control|api'; echo; echo '=== mem0 venv ==='; systemctl cat mem0-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'; echo; echo '=== voice venv ==='; systemctl cat voice-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; cat /proc/11257/cmdline 2>/dev/null | tr '\\\\0' ' '; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo; echo '=== mc2 backend unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend'; echo; echo '=== mem0 pip ==='; MEMPY=/proc/7277/exe; readlink /proc/7277/cwd; readlink /proc/7277/exe; V=\\(dirname \\(readlink /proc/7277/exe\\)\\); echo; /srv/mc2/mem0-venv/bin/pip show mem0ai 2>/dev/null | head -2\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; tr '\\\\0' ' ' < /proc/11257/cmdline; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo '=== mc2 unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend|llama|hermes|mem0|voice'; echo '=== mem0 exe ==='; readlink /proc/7277/exe; readlink /proc/7277/cwd\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== mission-control.service ==='; systemctl is-enabled mission-control 2>&1; systemctl is-active mission-control 2>&1; grep -E 'ExecStart|WorkingDirectory' /etc/systemd/system/mission-control.service; echo; echo '=== mem0ai version ==='; ls /home/hitonabi/mission-control-v2/mem0_service/ | head; for p in /home/hitonabi/mission-control-v2/mem0_service/.venv/bin/pip /home/hitonabi/mission-control-v2/mem0_service/venv/bin/pip; do [ -x \\\\\"\\\\$p\\\\\" ] && \\\\\"\\\\$p\\\\\" show mem0ai chromadb 2>/dev/null | grep -E 'Name|Version'; done\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"tr '\\\\0' '\\\\n' < /proc/7277/environ | grep -E 'VIRTUAL_ENV|PATH=' | head -3; tr '\\\\0' ' ' < /proc/7277/cmdline; echo; /home/hitonabi/.local/share/uv/python/cpython-3.12.13-linux-x86_64-gnu/bin/python3.12 -c 'import mem0; print\\(mem0.__version__\\)' 2>&1 | tail -1\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/pip show mem0ai 2>/dev/null | grep -E 'Name|Version'; /home/hitonabi/.mem0/venv/bin/pip show chromadb 2>/dev/null | grep Version\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"ls /home/hitonabi/.mem0/venv/bin/ | head -8; /home/hitonabi/.mem0/venv/bin/python -m pip show mem0ai chromadb 2>&1 | grep -E 'Name:|Version:'\")",
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/python -c 'import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)' 2>&1\")",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 '/home/hitonabi/.mem0/venv/bin/python -c \"import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)\"')",
"WebFetch(domain:docs.mem0.ai)",
"PowerShell(Write-Output '=== Lucy-Prozesse lokal ==='; Get-Process | Where-Object {$_.ProcessName -match 'electron|python|node'} | Select-Object ProcessName, Id, WorkingSet64 | Format-Table; Write-Output '=== Port 8130 \\(Pocket-TTS\\) ==='; Get-NetTCPConnection -LocalPort 8130 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcess; Write-Output '=== GPU ==='; Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-Table)",
"PowerShell($p = 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts\\\\ptts-venv\\\\Scripts\\\\python.exe'; if \\(Test-Path $p\\) { & $p -c \"import importlib.metadata as m; [print\\(n, m.version\\(n\\)\\) for n in ['pocket-tts','torch','onnxruntime','fastapi','numpy'] if True]\" 2>&1 } else { Write-Output 'ptts-venv nicht gefunden'; Get-ChildItem 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts' -Directory | Select-Object Name })",
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== /v1/models ===\"; curl -s http://127.0.0.1:9001/v1/models | head -c 600; echo; echo \"=== voice metrics ===\"; curl -s http://127.0.0.1:9001/api/voice/metrics | head -c 1200')",
"Bash(ssh -o ConnectTimeout=15 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -o /dev/null -w \"TTFB_chat_lane: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=30 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 500')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== routing policy ===\"; curl -s http://127.0.0.1:9001/api/routing | head -c 800; echo; echo \"=== aliases in llama-swap config ===\"; grep -B1 -A3 \"aliases:\" /etc/llama-swap/config.yaml | head -40; echo \"=== direkt hermes ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 400; echo; echo \"=== direkt an llama-swap :8080 ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:8080/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
"WebFetch(domain:www.hermes-ai.net)",
"WebFetch(domain:releases.electronjs.org)",
"WebFetch(domain:dev.to)",
"WebFetch(domain:kyuz0.github.io)",
"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:*)"
]
}
}
+9
View File
@@ -7,6 +7,15 @@
*.service text eol=lf *.service text eol=lf
*.timer text eol=lf *.timer text eol=lf
# Agent-Hooks laufen auf der Box (Shebang!) — .py-Hooks MÜSSEN LF behalten,
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
deploy/agent-hooks/*.py text eol=lf
deploy/*.py text eol=lf
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
deploy/**/*.py text eol=lf
# Windows-Batch-Wrapper bleiben CRLF. # Windows-Batch-Wrapper bleiben CRLF.
*.cmd text eol=crlf *.cmd text eol=crlf
*.bat text eol=crlf *.bat text eol=crlf
+76
View File
@@ -0,0 +1,76 @@
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
#
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
#
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
shell: bash
run: |
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
set -u +e
rot=0
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
ruff check . || rot=1
echo "== Python: Import/Syntax (compileall) =="
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
if [ -f frontend/package.json ]; then
if [ ! -f frontend/package-lock.json ]; then
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
rot=1
else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
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
+4
View File
@@ -20,3 +20,7 @@ frontend/dist/avatar.vrm
box_recon* box_recon*
gemma_swap* gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
frontend/dist/
+61
View File
@@ -0,0 +1,61 @@
# AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist
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) ·
**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.
- **Inference-Backend:** Primär Vulkan/RADV, experimentell ROCm.
**WICHTIG:** Alle alten Dienste wie `mem0`, `governor` oder `eigenleben` sind komplett gelöscht. Hermes ist das EINZIGE "Gehirn" und läuft autonom. Es nutzt keine alte Mem0-DB, sondern sein eigenes System.
## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle)
- **`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
**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
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.
## 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
- **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
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
keine zweite Allowlist in einem Router duplizieren.
- **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 =
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
+118 -42
View File
@@ -1,68 +1,144 @@
# Mission Control 2.0 # Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5. Greenfield-Neuaufbau — Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan). 122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
**Schichten:** Engine (llama-swap, **Vulkan/RADV** auf Strix Halo) · **Builtin-Routing-Gateway** > **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
in MC2 (`model: auto`, kein externer LiteLLM-Dienst — scheitert auf Python 3.14) ·
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phasen 05 ✅ · MC2 **live auf der Box** (:9001) · Modelle/Hermes-Wiring + Cutover offen ## Die drei Bahnen
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md). | Bahn | Was sie tut | Wo |
|---|---|---|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
- **Phase 0** — FastAPI-Skeleton + React/shadcn-Shell (Cmd+K, Dark, PWA). Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
- **Phase 1** — Compute-Module (fit/caps/sources, portiert), **Discover** (live HF + Fit + Caps), Bau-Sitzung und wird nie unterbrochen.
**Engine-Write** (register + `groups`/Ko-Residenz + vocab-geprüfte Spec-Drafts),
**Builtin-Gateway** (`model: auto` + Fallbacks), Frontend **Modelle & Routing** (Caps-Chips, Fit, Discover, Routing-View).
- **Phase 2** — System-Status (CPU/RAM/GPU/Disk), Wartung (restart/self-update, sudo-frei), ## Ports & Dienste
**Connect** (saubere IDE-Snippets → Gateway `model:auto`, LAN-IP-Override).
- **Phase 3** — Geteiltes **Gedächtnis** (SQLite/WAL, 5 Kategorien, Dedupe-Kurator) + **MCP-Server**
(`mcp/mcp_memory.py` shared, `mcp/mcp_mc.py` Stack-Management für Hermes), MemoryView.
- **Phase 4** — Hermes-**Agent-Status** (`/api/agent/status`, AgentView mit Tiles + „Hermes öffnen"), | Port | Dienst | Erreichbar |
`deploy/hermes-webui.service`, **Box-Runbook** [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) |---|---|---|
(hermes-webui, Brain=`auto`, Tools/MCP-Verdrahtung). Box-Ausführung steht noch aus. | `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
- **Phase 5** — **Backup** (Memory + Configs), **Services-Health** + Observability-Links, | `8080` | **llama-swap** — Modell-Router | LAN |
**Theme-Toggle** (Hell/Dunkel). Box-Deploy/-Wiring + Cutover (Phase 6) brauchen die Box. | `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 |
API: `health · models · discover · fit · models/register · groups · routing · system/* · connect · Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
memory/* · agent/status` (Details in `docs/STATUS.md`). MCP: `mcp/` (siehe `mcp/requirements.txt`). `mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
Box-Runbooks: `docs/HERMES_SETUP.md` + `deploy/` (Units, deploy.sh, backup.sh). Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
## Der Weg einer Idee
```
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 (25.07.2026, Vulkan, Q4):
| Rolle | Modell | Tempo | Aufgabe |
|---|---|---|---|
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. 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/* · auftragsbuch/* · 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):** # Frontend (Dev, proxyt /api → :9000)
```bash cd frontend && npm install && npm run dev # http://localhost:5173
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):** # Frontend (Build → wird vom Backend ausgeliefert)
```bash
cd frontend && npm run build # → frontend/dist cd frontend && npm run build # → frontend/dist
``` ```
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
## 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/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
| [`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 |
+50
View File
@@ -0,0 +1,50 @@
# Savepoint — Mission Control 2.0 (MC2)
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
## Was das ist (in einem Satz)
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,
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
Begleiterin „Lucy".
## Architektur (Kurzform — Details in AGENTS.md/docs/)
- **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**
(Box hat kein Node; Backend liefert die gebauten Assets aus).
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
## Aktueller Zustand
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
sauber gehalten und eingefroren, nicht neu erfunden.
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
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)
- **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)
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
`Europe/Berlin` umgestellt.
- 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)
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
## Letzter Sicherungspunkt
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
+95 -20
View File
@@ -9,18 +9,40 @@ 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
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 routers import (
agent,
auftragsbuch,
chronik,
connect,
console,
events,
gateway_proxy,
health,
hermes_ui,
ideen,
maintenance,
models,
routing,
skills,
system,
voice,
wissen,
zeitmaschine,
)
from routers import reminders as reminders_router
from services import metrics_history, reminders, sentry, warmer
from starlette.requests import Request from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system, voice
from services import warmer
# 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__)).
logging.basicConfig( logging.basicConfig(
@@ -29,17 +51,35 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn.""" """Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
task = asyncio.create_task(warmer.rewarm_loop()) if warmer.ENABLED else None + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
if task: tasks: list[asyncio.Task[Any]] = []
log.info("Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL) if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
log.info(
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
warmer.INTERVAL,
)
if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
# 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).
app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
)
try: try:
yield yield
finally: finally:
if task: for task in tasks:
task.cancel() _ = task.cancel()
await app.state.gw_client.aclose()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan) app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
@@ -54,7 +94,9 @@ app.add_middleware(
@app.middleware("http") @app.middleware("http")
async def no_cache(request: Request, call_next): async def no_cache(
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
) -> httpx.Response:
resp = await call_next(request) resp = await call_next(request)
if request.url.path.startswith("/api"): if request.url.path.startswith("/api"):
resp.headers["Cache-Control"] = "no-cache" resp.headers["Cache-Control"] = "no-cache"
@@ -65,28 +107,61 @@ 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(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) app.include_router(
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) voice.router
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
app.include_router(
reminders_router.router
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
if V1_UPSTREAM:
from routers import gateway_forward
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
else:
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(chronik.router) # Timeline der autonomen Taten (Announce-Store)
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(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
app.include_router(skills.router) # Skills & Jobs Dashboard
app.include_router(
console.router
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
app.include_router(
hermes_ui.router
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
if FRONTEND_DIST.exists(): if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
_DIST_ROOT = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
def spa(full_path: str): def spa(full_path: str):
# Falls die Datei direkt in FRONTEND_DIST liegt (z.B. manifest.webmanifest, favicon.ico), liefere sie aus # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
target = FRONTEND_DIST / full_path # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
if target.is_file(): try:
target = (FRONTEND_DIST / full_path).resolve()
if target.is_relative_to(_DIST_ROOT) and target.is_file():
return FileResponse(target) return FileResponse(target)
except (ValueError, OSError):
pass
index = FRONTEND_DIST / "index.html" index = FRONTEND_DIST / "index.html"
if index.exists(): if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
return FileResponse(index, headers={"Cache-Control": "no-cache, must-revalidate"}) return FileResponse(
index, headers={"Cache-Control": "no-cache, must-revalidate"}
)
return {"detail": "frontend not built"} return {"detail": "frontend not built"}
+21 -3
View File
@@ -61,6 +61,11 @@ HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer # MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr. # LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/") GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) ------------------------------- # --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`). # Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
@@ -94,9 +99,22 @@ 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), analog Mem0-Sidecar. 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("/")
# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI (Ersatz für AnythingLLM-Chat). # Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# Wird in MC2 per iframe eingebettet (Terminal-Seite). Siehe deploy/hermes-terminal.service. # (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
HERMES_TERMINAL_URL = os.environ.get("MC_HERMES_TERMINAL_URL", "http://192.168.178.151:7681").rstrip("/") # rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# 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
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
# /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
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks. # GitHub-Repo für Update-Checks.
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")))
+63
View File
@@ -0,0 +1,63 @@
"""
MC2-Gateway — der /v1-Datenpfad als EIGENER Prozess (UMBAU v3, P1).
Befund der Live-Inspektion 15.07.2026: Jeder LLM-Aufruf der Box (Lucys Haupt-Hirn,
Nacht-Crons, Worker-Delegation, Vision, Decomposer/Specifier/Curator) lief durch den
Steuerpult-Prozess auf :9001 — den am häufigsten neu gestarteten Dienst des Stacks.
Dieser Einstieg hebt denselben Gateway-Router (routers/gateway_proxy.py) UNVERÄNDERT
in einen bewusst winzigen, langweiligen Prozess: Unit mc2-gateway.service, Loopback
:9010, Restart=always. Das Steuerpult darf beliebig neu starten — die Wirbelsäule steht.
Bewusst NICHT hier: weitere Router, Hintergrund-Loops, CORS, Frontend-Auslieferung.
LAN-Clients (IDE-Lane) erreichen /v1 weiter über MC2 :9001, das roh hierher
durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
"""
import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import httpx
from config import LLAMA_SWAP_URL, VERSION
from fastapi import FastAPI, Request
from routers import gateway_proxy
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Gleiche Client-Parameter wie zuvor in app.py: Keep-Alive/Pooling statt neuer
# Client pro Anfrage (Sockets/TIME_WAIT unter parallelen Agent-Strömen).
app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
)
log.info("mc2-gateway bereit (Engine: %s)", LLAMA_SWAP_URL)
try:
yield
finally:
await app.state.gw_client.aclose()
app = FastAPI(title="MC2 Gateway", version=VERSION, lifespan=lifespan)
app.include_router(gateway_proxy.router)
@app.get("/gw/health")
async def health(request: Request):
"""Eigener Health-Pfad (nicht /api/health — das gehört dem Steuerpult):
beweist Prozess UND Engine-Erreichbarkeit, für deploy.sh/stack-postcheck.sh."""
engine = False
try:
r = await request.app.state.gw_client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=5.0)
engine = r.status_code == 200
except httpx.HTTPError:
pass
return {"status": "ok", "service": "mc2-gateway", "version": VERSION,
"engine_reachable": engine}
+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}...")
+18 -7
View File
@@ -1,6 +1,6 @@
{ {
"_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-06-27", "version": "2026-07-03",
"models": [ "models": [
{ {
"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",
@@ -35,6 +35,11 @@
"moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
}, },
{
"role": "vision", "name": "Qwen3-VL-30B-A3B-Instruct", "repo": "Qwen/Qwen3-VL-30B-A3B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true
},
{ {
"role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF", "role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8, "family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8,
@@ -47,14 +52,20 @@
}, },
{ {
"role": "scout", "name": "gemma-4-26B-A4B-it", "repo": "google/gemma-4-26B-A4B-it-GGUF", "role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "gemma", "generation": 4.0, "total_params_b": 26, "active_params_b": 4, "family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "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": "scout", "name": "gemma-4-31B-it", "repo": "google/gemma-4-31B-it-GGUF", "role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
"family": "gemma", "generation": 4.0, "total_params_b": 31, "active_params_b": 31, "family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
"moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true "moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
} }
] ]
} }
+2
View File
@@ -1,7 +1,9 @@
fastapi>=0.115 fastapi>=0.115
python-multipart>=0.0.9
uvicorn[standard]>=0.30 uvicorn[standard]>=0.30
httpx>=0.27 httpx>=0.27
ruamel.yaml>=0.18 ruamel.yaml>=0.18
psutil>=5.9 psutil>=5.9
huggingface_hub>=0.27 huggingface_hub>=0.27
mcp>=1.2.0 mcp>=1.2.0
tzdata>=2024.1
+1 -4
View File
@@ -1,10 +1,7 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht).""" """Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import HTTPException
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
+63
View File
@@ -0,0 +1,63 @@
"""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
+28
View File
@@ -0,0 +1,28 @@
"""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,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
from pydantic import BaseModel
from services import announce
router = APIRouter(prefix="/api")
class ChronikItem(BaseModel):
id: int
ts: float
subject: str
text: str
source: str
priority: str
class ChronikResponse(BaseModel):
items: list[ChronikItem]
@router.get("/chronik", response_model=ChronikResponse)
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
-1
View File
@@ -1,7 +1,6 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP).""" """Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets, check_health from services.connect import DEFAULT_HOST, build_snippets, check_health
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
+87
View File
@@ -0,0 +1,87 @@
"""
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
"""
import asyncio
import logging
import httpx
import websockets
from config import BOX_CONSOLE_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
log = logging.getLogger(__name__)
router = APIRouter()
def _register(base: str, upstream: str) -> None:
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None:
await ws.accept(subprotocol="tty")
try:
async with websockets.connect(
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
async def _proxy_http(request: Request, path: str = "") -> Response:
url = f"{upstream}/{base}/{path}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.request(request.method, url, content=await request.body())
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type"))
except httpx.HTTPError as exc:
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
media_type="text/plain")
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
name=f"{base}_path")
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
_register("console", BOX_CONSOLE_UPSTREAM)
+100
View File
@@ -0,0 +1,100 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
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),
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
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
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
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
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
selbst und bis dahin pollt die UI wie bisher).
"""
import asyncio
import json
import logging
from pathlib import Path
from config import MODELS_DIR
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
def _mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except OSError:
return 0.0
def _fingerprints() -> dict[str, object]:
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
fp: dict[str, object] = {}
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
from services import announce
fp["chronik"] = announce.list_after(None)["latest"]
except Exception:
pass
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
from services import ideen
d = ideen.list_queue()
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
except Exception:
pass
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
from services import llamaswap
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception:
pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
return fp
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
async def strom():
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints()
yield ": verbunden\n\n"
seit_ping = 0.0
while True:
if await request.is_disconnected():
return
await asyncio.sleep(TICK_S)
seit_ping += TICK_S
neu = _fingerprints()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu)
if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
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",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+66
View File
@@ -0,0 +1,66 @@
"""
Dünner /v1-Roh-Weiterleiter (UMBAU v3, P1).
Wenn MC_V1_UPSTREAM gesetzt ist (Unit-Env), reicht das Steuerpult /v1 unangefasst an
den eigenständigen mc2-gateway-Prozess (Loopback :9010) durch — LAN-Clients wie die
IDE-Lane erreichen den Gateway sonst nicht. Hier passiert BEWUSST nichts: kein
Routing, keine Bild-Weiche, keine Token-Zählung — all das macht genau einmal der
Gateway-Prozess (routers/gateway_proxy.py). Rollback = Env-Zeile aus der Unit
entfernen → app.py bindet wieder den lokalen Gateway ein.
"""
import logging
from config import V1_UPSTREAM
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1")
# Hop-by-hop-Header dürfen nicht blind weitergereicht werden; content-length wird von
# httpx (Request) bzw. Starlette-Chunking (Response) neu bestimmt.
_HOP_HEADERS = {
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
"upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer",
}
def _clean(headers) -> dict:
return {k: v for k, v in headers.items() if k.lower() not in _HOP_HEADERS}
@router.api_route("/{path:path}", methods=["GET", "POST"])
async def forward(path: str, request: Request):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (app.py lifespan)
url = f"{V1_UPSTREAM}/v1/{path}"
if request.url.query:
url = f"{url}?{request.url.query}"
body = await request.body()
req = client.build_request(
request.method, url, content=body or None, headers=_clean(request.headers),
timeout=None,
)
try:
upstream = await client.send(req, stream=True)
except Exception as exc: # Gateway-Prozess weg → ehrlicher 502 statt Hänger
log.warning("/v1-Weiterleitung an %s fehlgeschlagen: %s", V1_UPSTREAM, exc)
return JSONResponse(
{"error": {"message": f"mc2-gateway ({V1_UPSTREAM}) nicht erreichbar: {exc}",
"type": "gateway_unavailable"}},
status_code=502,
)
async def gen():
try:
async for chunk in upstream.aiter_raw():
yield chunk
finally:
await upstream.aclose()
# Streaming-Passthrough für BEIDE Fälle (SSE + normale JSON-Antwort): Starlette
# chunkt selbst, Status/Header (inkl. x-mc-routed-to) kommen vom Gateway.
return StreamingResponse(
gen(), status_code=upstream.status_code, headers=_clean(upstream.headers)
)
+191 -22
View File
@@ -1,26 +1,147 @@
import logging
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 services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from config import LLAMA_SWAP_URL from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
from services.gateway_stream import record_stream_chunk, record_usage
from services.router_logic import LANES, choose_for_lane
from services.routing_policy import load_policy from services.routing_policy import load_policy
log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1") router = APIRouter(prefix="/v1")
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet. # Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"} # (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
_LANG_DIRECTIVE = os.environ.get(
"MC_GATEWAY_LANG_DIRECTIVE",
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
_BILD_BESCHREIB_PROMPT = os.environ.get(
"MC_CODER_IMAGE_PROMPT",
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
def _apply_no_think_marker(body: dict) -> None:
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
if not _NO_THINK_MARKER:
return
found = False
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str) and _NO_THINK_MARKER in c:
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
found = True
elif isinstance(c, list):
for part in c:
if (isinstance(part, dict) and isinstance(part.get("text"), str)
and _NO_THINK_MARKER in part["text"]):
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
found = True
if found and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
def _ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
return "coder" in (alias or "").lower()
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
Vision-Umleitung als Fallback.
"""
fundstellen: list[tuple[dict, int, dict]] = []
for m in body.get("messages") or []:
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
continue
for i, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
fundstellen.append((m, i, part))
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
frage = {
"model": vision_alias,
"stream": False,
"max_tokens": 700,
"messages": [{"role": "user",
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
}
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
text = ""
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=240.0) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s%.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
if not text.strip():
return False
msg["content"][idx] = {"type": "text", "text": (
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
f"{text.strip()}]")}
return True
def _inject_language(body: dict, alias: str) -> None:
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
Persona (SOUL.md) regelt die Sprache selbst."""
if not _LANG_DIRECTIVE or alias == "hermes":
return
msgs = body.get("messages")
if not isinstance(msgs, list):
return
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
if first_sys is None:
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
elif isinstance(first_sys.get("content"), str):
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
elif isinstance(first_sys.get("content"), list):
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
@router.get("/models") @router.get("/models")
async def models(): async def models(request: Request):
async with httpx.AsyncClient(timeout=10) as c: client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
r = await c.get(f"{LLAMA_SWAP_URL}/v1/models") r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
data = r.json() data = r.json()
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können. # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router", # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES] # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
@@ -28,14 +149,25 @@ async def models():
# Clients fragen aber genau damit an → als eigene Einträge einblenden. # Clients fragen aber genau damit an → als eigene Einträge einblenden.
ctx_map: dict[str, int] = {} ctx_map: dict[str, int] = {}
alias_entries: list[dict] = [] alias_entries: list[dict] = []
aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt
try: try:
from services import llamaswap from services import llamaswap
for m in llamaswap.list_models(): for m in llamaswap.list_models():
ctx = m.get("ctx") ctx = m.get("ctx")
# --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die
# Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot:
# Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene
# Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens).
slots = m.get("parallel_slots") or 1
if ctx and slots > 1:
ctx = ctx // slots
if ctx: if ctx:
for api_id in m.get("api_ids", []): for api_id in m.get("api_ids", []):
ctx_map[api_id] = ctx ctx_map[api_id] = ctx
for alias in m.get("aliases", []): aliases = m.get("aliases", [])
if aliases:
aliased_raw.add(m["name"])
for alias in aliases:
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
"description": f"Alias für {m['name']}"} "description": f"Alias für {m['name']}"}
if ctx: if ctx:
@@ -47,12 +179,16 @@ async def models():
for entry in data["data"]: for entry in data["data"]:
if (ctx := ctx_map.get(entry.get("id"))): if (ctx := ctx_map.get(entry.get("id"))):
entry.setdefault("context_length", ctx) entry.setdefault("context_length", ctx)
data["data"] = lanes + alias_entries + data["data"] # Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der
# Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt.
raw = [e for e in data["data"] if e.get("id") not in aliased_raw]
data["data"] = alias_entries + raw
return JSONResponse(data, status_code=r.status_code) return JSONResponse(data, status_code=r.status_code)
async def _proxy(path: str, request: Request): async def _proxy(path: str, request: Request):
body = await request.json() body = await request.json()
_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"):
lane = requested.lower() lane = requested.lower()
@@ -62,25 +198,58 @@ async def _proxy(path: str, request: Request):
else: else:
alias = requested alias = requested
routed = {"x-mc-routed-to": requested} routed = {"x-mc-routed-to": requested}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
pol = load_policy() pol = load_policy()
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
vision_alias = pol.get("vision")
if vision_alias and alias not in VISION_CAPABLE and has_image(body):
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
routed = {"x-mc-routed-to": alias,
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
"x-mc-lane": routed.get("x-mc-lane", "-")}
else:
alias = vision_alias
body["model"] = alias
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
"x-mc-lane": routed.get("x-mc-lane", "-")}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body: if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}" url = f"{LLAMA_SWAP_URL}{path}"
if body.get("stream"): if body.get("stream"):
req = client.build_request("POST", url, json=body, timeout=None)
r = await client.send(req, stream=True)
if r.status_code != 200:
await r.aread()
try:
resp_json = r.json()
except Exception:
resp_json = {"error": {"message": r.text, "type": "upstream_error"}}
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
async def gen(): async def gen():
async with httpx.AsyncClient(timeout=None) as c: try:
async with c.stream("POST", url, json=body) as r:
async for chunk in r.aiter_raw(): async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias) record_stream_chunk(chunk, alias, body.get("max_tokens"))
yield chunk yield chunk
finally:
await r.aclose()
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
async with httpx.AsyncClient(timeout=600) as c: r = await client.post(url, json=body, timeout=600.0)
r = await c.post(url, json=body)
resp_json = r.json() resp_json = r.json()
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias) if isinstance(resp_json, dict):
record_usage(resp_json.get("usage"), alias)
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
for c in resp_json.get("choices") or []):
warn_truncation(alias, body.get("max_tokens"))
return JSONResponse(resp_json, status_code=r.status_code, headers=routed) return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
+11 -3
View File
@@ -1,14 +1,22 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0.""" """Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
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")
@router.get("/health") class HealthResponse(BaseModel):
status: str
version: str
engine_reachable: bool
gateway_reachable: bool
brain: dict
@router.get("/health", response_model=HealthResponse)
def health() -> dict: def health() -> dict:
return { return {
"status": "ok", "status": "ok",
+134
View File
@@ -0,0 +1,134 @@
"""
Same-origin Reverse-Proxy für die EINGEBAUTE Hermes-Web-GUI (`hermes serve`/`dashboard`).
Anders als das ttyd-Terminal (reiner Byte-Durchreicher, siehe routers/console.py) ist die
Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRERE WebSockets
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter.
"""
import asyncio
import logging
import re
import httpx
import websockets
from config import HERMES_BUILTIN_UI_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import RedirectResponse, Response
log = logging.getLogger(__name__)
router = APIRouter()
_BASE = "hermes-ui"
# Hop-by-hop- + Kodierungs-Header, die ein Proxy nicht 1:1 weiterreichen darf (Länge/Encoding
# rechnet Starlette neu; httpx liefert bereits dekodierten Body).
_DROP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers",
"transfer-encoding", "upgrade", "content-encoding", "content-length", "host",
}
@router.websocket(f"/{_BASE}/{{path:path}}")
async def _proxy_ws(ws: WebSocket, path: str) -> None:
"""Beliebige WebSocket unter /hermes-ui/* (z. B. /hermes-ui/api/ws) an :9119 durchreichen."""
ws_upstream = HERMES_BUILTIN_UI_UPSTREAM.replace("http://", "ws://").replace("https://", "wss://")
query = ws.url.query
target = f"{ws_upstream}/{path}" + (f"?{query}" if query else "")
await ws.accept()
try:
async with websockets.connect(
target, open_timeout=10, max_size=None, ping_interval=None,
) as up:
async def c2u() -> None:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
return
if (t := msg.get("text")) is not None:
await up.send(t)
elif (b := msg.get("bytes")) is not None:
await up.send(b)
async def u2c() -> None:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(bytes(m))
else:
await ws.send_text(m)
_done, pending = await asyncio.wait(
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
except Exception:
log.debug("hermes-ui ws-proxy (%s) beendet/fehlgeschlagen", path, exc_info=True)
finally:
try:
await ws.close()
except Exception:
pass
# Die SPA liest ihren API/WS-Basis-Pfad zur Laufzeit aus `window.__HERMES_BASE_PATH__` (nf() im
# Bundle). `hermes serve` injiziert diese Zuweisung selbst ans Ende von <head> (Wert leer, weil es
# auf Root läuft) — dessen Wert überschreiben wir auf /hermes-ui, sonst setzt die GUI ihre /api-Calls
# auf MC2s eigenen /api-Namensraum (SPA-Fallback = HTML) ab. Die Assets liegen bereits per
# Vite-base=/hermes-ui/ richtig. MC2 macht die Ersetzung → überlebt Hermes-Updates, ohne die gebaute
# index.html patchen zu müssen. Fällt die Server-Injektion je weg, setzen wir sie ersatzweise selbst.
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
async def _proxy_http(request: Request, path: str = "") -> Response:
upstream = f"{HERMES_BUILTIN_UI_UPSTREAM}/{path}"
fwd_headers = {k: v for k, v in request.headers.items() if k.lower() not in _DROP}
# Upstream unkomprimiert anfordern: sonst greift die HTML-Injektion nicht und die
# content-encoding-Header würden nicht zum (bereits dekodierten) Body passen.
fwd_headers["accept-encoding"] = "identity"
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as c:
r = await c.request(
request.method, upstream,
headers=fwd_headers, params=request.query_params, content=await request.body(),
)
except httpx.HTTPError as exc:
log.debug("hermes-ui http-proxy (%s) nicht erreichbar: %s", path, exc)
return Response(
content=b"Hermes-WebUI (hermes dashboard) nicht erreichbar.",
status_code=502, media_type="text/plain",
)
body = r.content
if "text/html" in r.headers.get("content-type", "").lower():
if _BASE_PATH_RE.search(body):
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
elif b"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
return Response(content=body, status_code=r.status_code, headers=resp_headers)
@router.get(f"/{_BASE}")
async def _root_redirect() -> RedirectResponse:
# Ohne Schrägstrich → auf /hermes-ui/ umleiten (die SPA erwartet den Präfix mit Slash).
return RedirectResponse(url=f"/{_BASE}/")
router.add_api_route(
f"/{_BASE}/{{path:path}}", _proxy_http,
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
name="hermes_ui_proxy",
)
+154
View File
@@ -0,0 +1,154 @@
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import ideen
router = APIRouter(prefix="/api")
class IdeeIn(BaseModel):
titel: str
notiz: str = ""
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
class TaskIn(BaseModel):
id: str
class AntwortIn(BaseModel):
id: 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")
def list_queue() -> dict:
return ideen.list_queue()
@router.post("/ideen")
def add_idea(body: IdeeIn) -> dict:
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
ziel=body.ziel, art=body.art)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
return res
@router.post("/ideen/antwort")
def answer(body: AntwortIn) -> dict:
res = ideen.answer_task(body.id, body.antwort)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
return res
class PrioIn(BaseModel):
id: str
richtung: str # hoch | runter
@router.post("/ideen/stopp")
def stop(body: TaskIn) -> dict:
res = ideen.stop_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
return res
@router.post("/ideen/retry")
def retry(body: TaskIn) -> dict:
res = ideen.retry_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
return res
@router.post("/ideen/prio")
def prio(body: PrioIn) -> dict:
res = ideen.prio_task(body.id, body.richtung)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
return res
class WeiterIn(BaseModel):
id: str
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
@router.post("/ideen/weiterfuehren")
def weiterfuehren(body: WeiterIn) -> dict:
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
return res
class NachschaerfenIn(BaseModel):
id: str
hinweis: str
@router.post("/ideen/nachschaerfen")
def nachschaerfen(body: NachschaerfenIn) -> dict:
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
return res
@router.post("/ideen/archivieren")
def archive(body: TaskIn) -> dict:
res = ideen.archive_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
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")
def get_log(id: str) -> dict:
return ideen.log_of(id)
@router.get("/ideen/{id}/ergebnis")
def get_ergebnis(id: str) -> dict:
return ideen.ergebnis_of(id)
@router.get("/ideen/{id}/konzept")
def get_konzept(id: str) -> dict:
return ideen.konzept_of(id)
+6 -2
View File
@@ -1,8 +1,7 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.""" """Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
from fastapi import APIRouter, HTTPException, Header from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import maintenance from services import maintenance
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -65,6 +64,11 @@ def hermes_update() -> dict:
return maintenance.hermes_update_job() return maintenance.hermes_update_job()
@router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict:
return maintenance.update_all_job(body.sudo_password)
@router.post("/maintenance/reboot") @router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict: def reboot(body: SudoReq) -> dict:
return maintenance.reboot(body.sudo_password) return maintenance.reboot(body.sudo_password)
-81
View File
@@ -1,81 +0,0 @@
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
router = APIRouter(prefix="/api")
class MemIn(BaseModel):
content: str
category: str = "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]:
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}
+33 -8
View File
@@ -1,10 +1,9 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups.""" """Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
import psutil import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import budget, discover, hf, jobengine, llamaswap from services import budget, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for from services.fit import evaluate_fit, max_ctx_for
@@ -114,24 +113,41 @@ def install(req: InstallReq) -> dict:
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein). # SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"] ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config. # Sofort registrieren (robust gegen -watch-config) — aber OHNE den Rollen-Alias
# umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt;
# bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.).
try: try:
model_id = llamaswap.register_model( model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja) model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja,
set_alias=False)
except PermissionError as exc: except PermissionError as exc:
raise HTTPException(500, str(exc)) raise HTTPException(500, str(exc))
# Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
role = (req.role or "").strip().lower()
def _apply_role() -> None:
if role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
else:
llamaswap.set_role(model_id, role)
# 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 req.hf_token:
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = req.hf_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)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path, return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])} "total_bytes": info["total_bytes"], "files": len(info["files"])}
@@ -161,9 +177,18 @@ def recommend_role(role: str) -> dict:
@router.post("/models/{model_id}/role") @router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict: def set_model_role(model_id: str, body: RoleReq) -> dict:
new_role = (body.role or "").strip().lower()
# Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle
# hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick.
# Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um).
prot = llamaswap.protected_alias_of(model_id)
if prot and new_role != prot:
raise HTTPException(400,
f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst "
f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.")
# Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe + # Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe +
# ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes. # ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes.
if (body.role or "").strip().lower() == "hermes": if new_role == "hermes":
from services.agent import set_agent_brain from services.agent import set_agent_brain
res = set_agent_brain(model_id) res = set_agent_brain(model_id)
if not res.get("ok"): if not res.get("ok"):
+49
View File
@@ -0,0 +1,49 @@
"""Erinnerungen & Routinen (A3) — dünner REST-Layer über services/reminders.py.
Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete);
LAN-only wie alle MC2-Endpoints."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import reminders
router = APIRouter(prefix="/api")
class ReminderIn(BaseModel):
text: str # was angesagt werden soll
when: str # ISO-8601; ohne Offset = Commander-Zeitzone (Europe/Berlin)
repeat: str = "" # '' einmalig | daily | weekdays | weekly
class ReminderOut(BaseModel):
id: int
text: str
next_ts: float
repeat: str
created_ts: float
when_local: str
class ReminderListResponse(BaseModel):
items: list[ReminderOut]
@router.get("/reminders", response_model=ReminderListResponse)
def list_reminders() -> dict:
return {"items": reminders.list_all()}
@router.post("/reminders")
def create_reminder(body: ReminderIn) -> dict:
try:
return {"ok": True, "item": reminders.create(body.text, body.when, body.repeat)}
except ValueError as exc:
raise HTTPException(400, str(exc))
@router.delete("/reminders/{reminder_id}")
def delete_reminder(reminder_id: int) -> dict:
try:
return {"ok": True, "deleted": reminders.delete(reminder_id)}
except KeyError as exc:
raise HTTPException(404, str(exc.args[0]))
-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
+83
View File
@@ -0,0 +1,83 @@
import os
import subprocess
import psutil
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/api")
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,
"description": description or "Keine Beschreibung verfügbar.",
"running": 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
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus", "chat"]
try:
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{req.skill_name.replace('/', '_')}.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a") as f:
f.write(f"\n--- Starting Skill: {req.skill_name} ---\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)}
+68 -21
View File
@@ -9,13 +9,12 @@ import logging
import os import os
import subprocess import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import httpx import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL from fastapi import APIRouter
from pydantic import BaseModel
from services import backup as backup_svc from services import backup as backup_svc
from services import maintenance
from services.agent import agent_status from services.agent import agent_status
from services.gateway import gateway_reachable from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models from services.llamaswap import engine_reachable, list_models
@@ -27,8 +26,6 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
# Nur diese User-Dienste dürfen neugestartet werden.
ALLOWED_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-webui", "mem0-service", "voice-service"}
# Quelle für Self-Update (auf der Box ~/mission-control-v2). # Quelle für Self-Update (auf der Box ~/mission-control-v2).
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")) SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
@@ -38,6 +35,13 @@ def status() -> dict:
return system_status() return system_status()
@router.get("/system/history")
def history(minutes: int = 60) -> dict:
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
from services import metrics_history
return metrics_history.history(minutes)
def _mem0_reachable() -> bool: def _mem0_reachable() -> bool:
try: try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200 return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
@@ -52,24 +56,64 @@ def _voice_reachable() -> bool:
return False return False
def _user_unit_active(unit: str) -> bool:
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
Auf Windows/Dev schlägt das harmlos fehl (False)."""
if os.name != "posix":
return False
try:
r = subprocess.run(["systemctl", "--user", "is-active", unit],
capture_output=True, text=True, timeout=3)
return r.stdout.strip() == "active"
except Exception:
return False
@router.get("/system/services") @router.get("/system/services")
def services() -> dict: def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige).""" """Aggregierte Erreichbarkeit aller Stack-Dienste die EINE Quelle für Cockpit-Kachel,
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
a = agent_status() a = agent_status()
gw_url = f"{GATEWAY_URL}/v1" gw_url = f"{GATEWAY_URL}/v1"
rows = [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
"ok": engine_reachable(), "scope": "system"},
]
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
if V1_UPSTREAM:
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
"ok": gateway_reachable(), "scope": "user"})
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
"ok": True, "scope": "user"})
else:
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
"ok": gateway_reachable(), "scope": "user"})
rows += [
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
"ok": a["gateway_reachable"], "scope": "user"},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
"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,
"ok": _voice_reachable(), "scope": "user"},
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
]
return { return {
"services": [ "services": rows,
{"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-Terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]},
{"name": "Mem0 (Gedächtnis)", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
{"name": "Voice (STT/TTS)", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
],
"links": { "links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui", "engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url, "gateway": gw_url,
"hermes_terminal": a["terminal_url"], "hermes_ui": a["hermes_ui_url"],
}, },
} }
@@ -89,7 +133,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode, return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"ok": False, "code": -1, "out": "", "err": str(exc)} return {"ok": False, "code": -1, "out": "", "err": str(exc)}
@@ -99,9 +143,12 @@ class RestartReq(BaseModel):
@router.post("/system/restart") @router.post("/system/restart")
def restart(req: RestartReq) -> dict: def restart(req: RestartReq) -> dict:
if req.service not in ALLOWED_SERVICES: """Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit):
raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.") der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von
return _run(["systemctl", "--user", "restart", req.service]) der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste
(ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
Sprache/MCP schlug fehl."""
return maintenance.restart_service(req.service)
@router.post("/system/self-update") @router.post("/system/self-update")
+108 -23
View File
@@ -11,18 +11,26 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
import logging import logging
import os import os
import time
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services.voice_metrics import Timer, get_metrics, record_stage # Per-Stage-Latenz (C2)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). # Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys import sys as _sys
import time
import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
TurnTrace,
get_metrics,
get_trace,
park,
record_stage,
)
_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)
@@ -90,11 +98,53 @@ class ChatIn(BaseModel):
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
@router.get("/voice/metrics") class AnnounceIn(BaseModel):
def voice_metrics() -> dict: text: str # die Meldung (wird von Lucy gesprochen)
"""Per-Stage-Latenz (STT/Vision/Chat-TTFB/TTS) — rollende Statistik, macht die Voice-Pipeline subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
messbar (C2). Anzeige im Frontend-Overhaul (E).""" source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
return get_metrics() priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
class AlarmIn(BaseModel):
text: str # die Alarm-Meldung
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
@router.post("/alarm")
def alarm(body: AlarmIn) -> dict:
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
wie alle MC2-Endpoints."""
text = (body.text or "").strip()
if not text:
raise HTTPException(400, "Leere Meldung.")
subject = (body.subject or "[Alarm]").strip()
try:
item = announce.add(text, subject, body.source or "alarm", "normal")
except ValueError as exc:
raise HTTPException(400, str(exc))
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
return {"ok": True, "item": item}
@router.post("/voice/announce")
def voice_announce(body: AnnounceIn) -> dict:
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
try:
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
except ValueError as exc:
raise HTTPException(400, str(exc))
@router.get("/voice/announcements")
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
return announce.list_after(after, limit)
@router.get("/voice/health") @router.get("/voice/health")
@@ -105,18 +155,32 @@ 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
@router.get("/voice/metrics")
def voice_metrics() -> dict:
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
return get_metrics()
@router.get("/voice/trace")
def voice_trace(limit: int = 20) -> dict:
"""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 —
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
return {"turns": get_trace(limit)}
@router.get("/voice/voices") @router.get("/voice/voices")
def voice_voices() -> dict: def voice_voices() -> dict:
try: try:
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}")
@@ -129,8 +193,11 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
with Timer("stt"): _t0 = time.perf_counter()
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
_ms = (time.perf_counter() - _t0) * 1000.0
record_stage("stt", _ms)
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -178,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)}
@@ -222,18 +289,31 @@ 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():
t0 = time.perf_counter() # Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
trace = TurnTrace(session_id=body.session_id, kind="voice")
first = True first = True
first_content = True first_content = True
committed = False
def _commit() -> None:
nonlocal committed
if not committed:
committed = True
trace.commit()
try:
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und # Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt # der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt. # dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text user_text = body.text
imgs = [u for u in (body.images or []) if u] imgs = [u for u in (body.images or []) if u]
trace.had_images = bool(imgs)
if imgs: if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
with Timer("vision"): _tv = time.perf_counter()
desc = await _describe_images(imgs, body.text) desc = await _describe_images(imgs, body.text)
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
if desc: if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM") safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}" user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
@@ -241,6 +321,7 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
if body.system: if body.system:
messages.append({"role": "system", "content": body.system}) messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text}) messages.append({"role": "user", "content": user_text})
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
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).
@@ -254,19 +335,23 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
) as r: ) as r:
if r.status_code != 200: if r.status_code != 200:
detail = (await r.aread()).decode("utf-8", "replace")[:500] detail = (await r.aread()).decode("utf-8", "replace")[:500]
trace.error = f"Hermes {r.status_code}"
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return return
async for chunk in r.aiter_raw(): async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead) if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0) trace.note_ttfb()
first = False first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + LLM-TTFT) — # Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + 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:
record_stage("chat_first_content", (time.perf_counter() - t0) * 1000.0) trace.note_first_content()
first_content = False first_content = False
yield chunk yield chunk
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
trace.error = "verbindung"
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
finally:
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
+151
View File
@@ -0,0 +1,151 @@
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
import os
import re
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api")
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
def _available() -> bool:
return VAULT.is_dir()
def _title_of(p: Path) -> str:
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
try:
with p.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if s:
return s.lstrip("# ").strip()[:160]
except OSError:
pass
return p.stem
class VaultFile(BaseModel):
path: str
name: str
dir: str
title: str
mtime: float
neu: bool
class WissenListResponse(BaseModel):
available: bool
files: list[VaultFile]
class WissenFileResponse(BaseModel):
path: str
content: str
mtime: float
@router.get("/wissen", response_model=WissenListResponse)
def list_vault() -> dict:
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
if not _available():
return {"available": False, "files": []}
files = []
now = time.time()
for p in sorted(VAULT.rglob("*.md")):
if ".git" in p.parts or "traeume" in p.parts:
continue
rel = p.relative_to(VAULT).as_posix()
st = p.stat()
files.append({
"path": rel,
"name": p.stem,
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
"title": _title_of(p),
"mtime": st.st_mtime,
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
})
files.sort(key=lambda f: f["mtime"], reverse=True)
return {"available": True, "files": files}
@router.get("/wissen/datei", response_model=WissenFileResponse)
def read_file(pfad: str) -> dict:
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
if not _available():
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
try:
target = (VAULT / pfad).resolve()
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
raise HTTPException(404, "Notiz nicht gefunden.")
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
"mtime": target.stat().st_mtime}
except HTTPException:
raise
except (ValueError, OSError):
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}
+70
View File
@@ -0,0 +1,70 @@
"""Zeitmaschine — Snapshots ansehen und per Klick zu einem Stand zurückkehren.
Die Maschinerie existiert komplett (deploy/backup.sh Timer 03:30, Off-Box-Kopie,
deploy/restore.sh mit Pre-Restore-Sicherung); hier kommt nur der letzte Meter dazu:
Liste + Inhalt in der UI und ein Restore-Start als EIGENE systemd-Unit — restore.sh
stoppt mission-control-2 selbst, als Kind des Backends stürbe der Lauf mittendrin."""
import os
import re
import subprocess
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import backup as backup_svc
router = APIRouter(prefix="/api")
_REPO_ROOT = Path(__file__).resolve().parents[2]
RESTORE_SH = _REPO_ROOT / "deploy" / "restore.sh"
_FILE_RX = re.compile(r"^mc2-state-[0-9T:_-]+\.tar\.gz$")
class RestoreIn(BaseModel):
file: str
@router.get("/zeitmaschine")
def list_snapshots() -> dict:
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
@router.get("/zeitmaschine/inhalt")
def snapshot_contents(file: str) -> dict:
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
if not _FILE_RX.match(file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
p = backup_svc.BACKUP_DIR / file
if not p.is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
return {"file": file, "components": backup_svc.snapshot_components(p)}
@router.post("/zeitmaschine/restore")
def restore(body: RestoreIn) -> dict:
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
Sicherheits-Backup des aktuellen Zustands — der Schritt ist also reversibel."""
if os.name != "posix":
raise HTTPException(400, "Wiederherstellen geht nur auf der Box.")
if not _FILE_RX.match(body.file):
raise HTTPException(400, "Ungültiger Snapshot-Name.")
if not (backup_svc.BACKUP_DIR / body.file).is_file():
raise HTTPException(404, "Snapshot nicht gefunden.")
try:
from services import announce
announce.add(f"Zeitmaschine: Wiederherstellung von {body.file} gestartet — "
"die Dienste starten gleich neu.", "[Zeitmaschine]", "zeitmaschine", "silent")
except Exception:
pass
unit = f"mc2-restore-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(RESTORE_SH), "--yes", body.file],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
raise HTTPException(500, f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}")
return {"ok": True, "unit": unit}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Parse systemd timeout errors for mission-control-2.service from journalctl.
Captures lines containing "State 'stop-sigterm' timed out" and appends them
to ~/logs/mc2-timeout.log with ISO timestamps.
Permission errors are handled gracefully.
Uses user journal (systemctl --user) to query logs.
"""
import subprocess
from datetime import datetime, timezone
from pathlib import Path
LOG_DIR = Path.home() / "logs"
LOG_FILE = LOG_DIR / "mc2-timeout.log"
SERVICE_NAME = "mission-control-2.service"
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
JOURNALCTL_LIMIT = 1000
def ensure_log_dir() -> None:
"""Create log directory if it doesn't exist."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
def get_timeout_entries() -> list[str]:
"""Run journalctl --user and return lines matching the timeout pattern."""
cmd = [
"journalctl",
"--user",
"-u", SERVICE_NAME,
"--no-pager",
"-n", str(JOURNALCTL_LIMIT),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
print("journalctl timed out")
return []
except PermissionError:
print("Permission denied: journalctl requires access to user logs")
return []
if result.returncode != 0:
if "Permission denied" in result.stderr:
print("Permission denied: journalctl requires access to user logs")
else:
print(f"journalctl failed: {result.stderr.strip()}")
return []
return [
line
for line in result.stdout.splitlines()
if SEARCH_PATTERN in line
]
def write_to_log(entries: list[str]) -> int:
"""Append entries to log file with ISO timestamps. Returns count written."""
ensure_log_dir()
timestamp = datetime.now(timezone.utc).isoformat()
written = 0
with LOG_FILE.open("a", encoding="utf-8") as f:
for entry in entries:
f.write(f"[{timestamp}] {entry}\n")
written += 1
return written
def main() -> None:
entries = get_timeout_entries()
if not entries:
print("No timeout entries found.")
return
count = write_to_log(entries)
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
if __name__ == "__main__":
main()
+30 -14
View File
@@ -9,9 +9,15 @@ import os
import re import re
import httpx import httpx
import psutil from config import (
BOX_CONSOLE_PATH,
from config import HERMES_TERMINAL_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL BOX_CONSOLE_UPSTREAM,
HERMES_API_URL,
HERMES_BUILTIN_UI_PATH,
HERMES_BUILTIN_UI_UPSTREAM,
HERMES_HOME,
PC_EXECUTOR_URL,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -65,7 +71,7 @@ def hermes_brain_info() -> dict:
groups = llamaswap.list_groups() groups = llamaswap.list_groups()
persist = set() persist = set()
for g in groups.values(): for g in groups.values():
if isinstance(g, dict) and g.get("persist"): if isinstance(g, dict) and (g.get("persist") or g.get("persistent")):
persist.update(g.get("members") or []) persist.update(g.get("members") or [])
cur_name = cur["name"] if cur else None cur_name = cur["name"] if cur else None
@@ -75,15 +81,16 @@ def hermes_brain_info() -> dict:
if m["name"] in persist and m["name"] != cur_name) if m["name"] in persist and m["name"] != cur_name)
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0) largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
gtt = gtt_budget_gb() gtt = gtt_budget_gb()
# Brain muss immer resident sein → passt Brain + größtes on-demand zusammen? # Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
# (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.) # resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
budget = { budget = {
"gtt_gb": gtt, "gtt_gb": gtt,
"brain_gb": round(brain_gb, 1), "brain_gb": round(brain_gb, 1),
"warm_projected_gb": round(warm, 1), "warm_projected_gb": round(warm, 1),
"largest_ondemand_gb": round(largest_od, 1), "largest_ondemand_gb": round(largest_od, 1),
"fits": (brain_gb + largest_od) <= gtt, "fits": (warm + largest_od) <= gtt,
"free_after_gb": round(gtt - brain_gb - largest_od, 1), "free_after_gb": round(gtt - warm - largest_od, 1),
} }
except Exception: except Exception:
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True) log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
@@ -138,10 +145,16 @@ def agent_status() -> dict:
return { return {
"gateway_url": HERMES_API_URL, "gateway_url": HERMES_API_URL,
# Interaktives Web-Terminal (ttyd → `hermes chat`), eingebettet in MC2. # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
"terminal_url": HERMES_TERMINAL_URL, "box_console_url": BOX_CONSOLE_PATH,
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
"gateway_reachable": _reach(HERMES_API_URL, "/health"), "gateway_reachable": _reach(HERMES_API_URL, "/health"),
"terminal_reachable": _reach(HERMES_TERMINAL_URL, "/"), # Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
"home_exists": home.exists(), "home_exists": home.exists(),
"brain_model": brain_model, "brain_model": brain_model,
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
@@ -152,6 +165,8 @@ def agent_status() -> dict:
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")), "telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(), "mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"), "pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
"pc_executor_url": PC_EXECUTOR_URL,
} }
@@ -183,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)
@@ -197,7 +212,8 @@ def set_agent_brain(model_id: str) -> dict:
if b and not b.get("fits", True): if b and not b.get("fits", True):
warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-" warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-"
f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget " f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget "
f"(~{b.get('gtt_gb')} GB) — heavy/coder würden das Hirn verdrängen.") f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden "
f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).")
except Exception: except Exception:
log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True) log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True)
return {"ok": True, "old": old, "new": model_id, "warning": warning} return {"ok": True, "old": old, "new": model_id, "warning": warning}
@@ -241,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)
+130
View File
@@ -0,0 +1,130 @@
"""
Melde-Briefkasten der Box (Lucy-Proaktivität, Faden A3).
Alles, was die Box dem Commander aktiv sagen will (Health-Wächter, Auto-Updates,
Radar, Hermes-cron via notify.sh), landet als Eintrag hier. Die Lucy-Desktop-App
pollt `/api/voice/announcements` und SPRICHT neue Einträge von sich aus.
Persistenz als JSON neben den Modellen (übersteht Deploys/Neustarts, wie der
Discover-Cache). Bewusst klein: fortlaufende IDs als Cursor, Ring der letzten
MAX_ITEMS Einträge, ein Lock für die FastAPI-Threadpool-Worker.
"""
import json
import logging
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
import httpx
from config import MODELS_DIR
log = logging.getLogger(__name__)
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
# (mc2-steward), darf er den Store NICHT direkt schreiben — Datei + In-Memory-Cache
# gehören exklusiv dem Steuerpult. Gesetzt (Unit-Env, z. B. http://127.0.0.1:9001)
# liefert add() die Meldung stattdessen per HTTP am /api/voice/announce-Endpunkt ab.
ANNOUNCE_HTTP = os.environ.get("MC_ANNOUNCE_HTTP", "").rstrip("/")
_lock = threading.Lock()
_state: dict | None = None # {"next_id": int, "items": [...]}
def _load() -> dict:
global _state
if _state is None:
try:
_state = json.loads(STORE_PATH.read_text(encoding="utf-8"))
assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list)
except Exception:
_state = {"next_id": 1, "items": []}
return _state
def _save(state: dict) -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
# Briefkasten darf den Absender nie blockieren — dann eben nur in-memory.
log.warning("announce: Store %s nicht schreibbar", STORE_PATH, exc_info=True)
def add(text: str, subject: str = "", source: str = "", priority: str = "normal") -> dict:
"""Eintrag anhängen. priority: 'normal' (sprechen) | 'silent' (nur Verlauf/Panel)."""
text = (text or "").strip()
if not text:
raise ValueError("Leere Meldung.")
if ANNOUNCE_HTTP:
# Fremdprozess-Modus (mc2-steward): per HTTP beim Steuerpult abliefern.
payload = {"text": text[:4000], "subject": (subject or "").strip()[:120],
"source": (source or "").strip()[:60], "priority": priority}
try:
with httpx.Client(timeout=5.0) as c:
r = c.post(f"{ANNOUNCE_HTTP}/api/voice/announce", json=payload)
if r.status_code == 200:
return (r.json() or {}).get("item") or payload
except Exception:
pass
# MC2 down/Endpunkt weg: Alarm ist nicht verloren — der Telegram-Direktweg des
# Aufrufers (notify.sh) läuft separat; hier bleibt nur der Log-Nachweis.
log.warning("announce: HTTP-Abgabe an %s fehlgeschlagen — nur im Log: [%s] %.120s",
ANNOUNCE_HTTP, subject or "-", text)
return payload
with _lock:
state = _load()
item = {
"id": state["next_id"],
"ts": time.time(),
"subject": (subject or "").strip()[:120],
"text": text[:4000],
"source": (source or "").strip()[:60],
"priority": priority if priority in ("normal", "silent") else "normal",
}
state["next_id"] += 1
state["items"].append(item)
del state["items"][:-MAX_ITEMS]
_save(state)
log.info("announce #%s [%s] %s: %.80s", item["id"], item["source"] or "-", item["subject"] or "-", text)
return item
def notify_telegram(subject: str, text: str) -> None:
"""Best-effort auch auf Telegram (User ist evtl. nicht am PC). MC_NOTIFY_NO_ANNOUNCE=1
verhindert, dass notify.sh die Meldung ZURÜCK in den Briefkasten spiegelt — der Absender
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op."""
if not (os.name == "posix" and shutil.which("bash")):
return
try:
subprocess.run(["bash", NOTIFY_SH, "-s", subject, text],
timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"})
except Exception:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
def list_recent(limit: int = 150) -> list[dict]:
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
with _lock:
state = _load()
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
with _lock:
state = _load()
latest = state["next_id"] - 1
items = [] if after is None else [i for i in state["items"] if i["id"] > after][: max(1, min(limit, 100))]
return {"latest": latest, "items": items}
+525
View File
@@ -0,0 +1,525 @@
"""
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
import urllib.request
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
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
def _gitea_creds() -> tuple | None:
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
try:
import subprocess
p = subprocess.run(
["git", "credential", "fill"],
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
capture_output=True,
check=True
)
out = p.stdout.decode(errors="replace")
u_match = re.search(r"username=(.+)", out)
p_match = re.search(r"password=(.+)", out)
if u_match and p_match:
return u_match.group(1).strip(), p_match.group(1).strip()
except Exception:
pass
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
try:
cred = Path("~/.git-credentials").expanduser()
for line in cred.read_text(encoding="utf-8").splitlines():
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
if m: return m.group(1), m.group(2)
except Exception:
pass
return None
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
# Nur auf der Box (wo creds liegen) sinnvoll
creds = _gitea_creds()
if not creds: return None
_user, token = creds
cache_key = (repo, branch, head_sha)
cached = _CI_CACHE.get(cache_key)
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
return cached["status"]
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
try:
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=5) as r:
data = json.load(r)
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
if runs:
run = runs[0]
if run.get("head_sha", "").startswith(head_sha[:7]):
st = run.get("status")
if st:
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
return st
except Exception as e:
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
return None
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
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else 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,
"ci_status": ci_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}
+6 -1
View File
@@ -27,6 +27,11 @@ def _latest() -> Path | None:
return snaps[0] if snaps else None return snaps[0] if snaps else None
def snapshot_components(tarball: Path) -> list[str]:
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
return _components(tarball)
def _components(tarball: Path) -> list[str]: def _components(tarball: Path) -> list[str]:
"""Top-Level-Einträge im Tarball (zur Anzeige im UI).""" """Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
try: try:
@@ -46,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()
+84 -22
View File
@@ -1,11 +1,13 @@
""" """
Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit. Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit.
Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, Ein-Gruppen- Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB):
Residenz, GTT ~124 GB): • Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident —
• Das Agent-Hirn (Rolle `hermes`) ist IMMER resident. seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist`
• Weitere persist-Mitglieder (fast/vision) dürfen verdrängt werden, wenn ein großes in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last
on-demand-Modell lädt. verdrängte damals die ganze Gruppe).
• Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss
deren Footprint mit einplanen.
Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss — Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss —
und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein). und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein).
@@ -52,15 +54,45 @@ def params_of_model(model: dict) -> float:
return max(float(caps.get("params_b") or 0), pb_size, 7.0) return max(float(caps.get("params_b") or 0), pb_size, 7.0)
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
return ck, cv
def _real_kv_gb(model: dict, ctx: int) -> float | None:
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
from services import gguf_meta
path = model.get("gguf_path")
if not path:
return None
meta = gguf_meta.arch_meta(path)
if not meta:
return None
ck, cv = _cache_types(model.get("cmd") or "")
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
def footprint_gb(model: dict) -> float: def footprint_gb(model: dict) -> float:
"""Loaded-Footprint eines Modells = Gewichte + kalibrierter KV-Anteil (bei seinem """Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
aktuellen ctx).""" KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
quant = model.get("quant") or "Q4_K_M" quant = model.get("quant") or "Q4_K_M"
ctx = int(model.get("ctx") or 32768) ctx = int(model.get("ctx") or 32768)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
pb = params_of_model(model) pb = params_of_model(model)
weights = max(pb * bpp, size_gb) weights = max(pb * bpp, size_gb)
kv = _real_kv_gb(model, ctx)
if kv is None:
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
return weights + max(kv, 0.0) return weights + max(kv, 0.0)
@@ -77,9 +109,10 @@ def params_b_for(name: str) -> float:
def _coresident_members(groups: dict) -> set: def _coresident_members(groups: dict) -> set:
"""Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen """Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen
(Ko-Residenz, z.B. brains = Hirn+embed+vision). Ein Modell AUSSERHALB dieser Gruppen (Ko-Residenz, z.B. brains = Hirn+embed+vision). Seit dem `persistent`-Fix (03.07.2026)
ist on-demand und verdrängt beim Laden die GANZE Gruppe — llama-swap swappt Gruppen überlebt die Gruppe auch on-demand-Last: Coder/heavy laden DANEBEN, nicht an ihre
(live verifiziert: heavy laden → brains-Gruppe komplett raus, heavy läuft allein).""" Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere
Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key."""
out: set = set() out: set = set()
for g in (groups or {}).values(): for g in (groups or {}).values():
if isinstance(g, dict) and g.get("swap") is False: if isinstance(g, dict) and g.get("swap") is False:
@@ -89,14 +122,14 @@ def _coresident_members(groups: dict) -> set:
def reserved_gb(role: str | None) -> dict: def reserved_gb(role: str | None) -> dict:
"""Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der """Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der
VERIFIZIERTEN llama-swap-Gruppen-Swap-Semantik (NICHT der früheren Annahme „Hirn bleibt seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente
immer"). Nur die ko-residente `swap:false`-Gruppe läuft gemeinsam; ein on-demand-Modell `swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben.
verdrängt die Gruppe und läuft ALLEIN mit dem vollen GTT.
- Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN - Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN
Gruppen-Mitgliedern → reserviert deren Summe. Gruppen-Mitgliedern → reserviert deren Summe.
- Modell AUSSERHALB (heavy/coder/coder-lite/fast/scout): läuft allein (Gruppe wird beim - Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe →
Laden rausgeswappt) → reserviert NICHTS, darf den vollen GTT für Kontext nutzen. reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die
Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte).
""" """
from services import llamaswap from services import llamaswap
models = llamaswap.list_models() models = llamaswap.list_models()
@@ -115,8 +148,9 @@ def reserved_gb(role: str | None) -> dict:
others = sum(footprint_gb(m) for m in models others = sum(footprint_gb(m) for m in models
if m["name"] in cores and m["name"] != holder_name) if m["name"] in cores and m["name"] != holder_name)
return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb} return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb}
# on-demand: verdrängt die Ko-Residenz-Gruppe → läuft allein, voller GTT für Kontext. # on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren.
return {"reserved_gb": 0.0, "mode": "ondemand-alone", "brain_gb": brain_gb} warm = sum(footprint_gb(m) for m in models if m["name"] in cores)
return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb}
def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict: def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict:
@@ -135,9 +169,37 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
} }
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
from services.fit import _NICE_CTX
if cap:
raw_ctx = min(raw_ctx, cap)
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def setup_aware_ctx_for_model(model: dict) -> dict: def setup_aware_ctx_for_model(model: dict) -> dict:
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell (aus seiner Rolle, """Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
Params & Quant). Für den 'Auto'-Button an der Modellkarte.""" Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
return setup_aware_ctx(params_of_model(model), Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
model.get("quant") or "Q4_K_M", from services import gguf_meta
role=model.get("role")) quant = model.get("quant") or "Q4_K_M"
path = model.get("gguf_path")
meta = gguf_meta.arch_meta(path) if path else None
if not meta:
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
gtt = gtt_budget_gb()
r = reserved_gb(model.get("role"))
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
weights = max(params_of_model(model) * bpp, size_gb)
ck, cv = _cache_types(model.get("cmd") or "")
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
"budget_gb": round(budget, 1), "mode": r["mode"]}
+66 -41
View File
@@ -1,7 +1,7 @@
""" """
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
(Lanes `coding`/`chat`, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001). (reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
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,18 +10,33 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
import json import json
import httpx import httpx
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
DEFAULT_HOST = "192.168.178.151" DEFAULT_HOST = "192.168.178.151"
# IDEs bekommen NUR die 'coding'-Lane zu sehen — die Lane routet intern selbst auf das # IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
# passende Modell (Coder/Heavy/…). Ein einziger Eintrag, kein manuelles Modell-Wählen mehr. # Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
IDE_MODEL = "coding" PRIMARY_MODEL = "coder"
def _caps(tools: bool = True, images: bool = False) -> dict:
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
return {"tools": tools, "images": images, "parallel_tool_calls": False,
"prompt_cache_key": False, "chat_completions": True,
"interleaved_reasoning": False, "max_tokens_parameter": False}
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
IDE_MODELS = [
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
]
def _gw(host: str) -> str: def _gw(host: str) -> str:
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit). # Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
return f"http://{host}:{PORT}/v1" return f"http://{host}:{PORT}/v1"
@@ -37,27 +52,25 @@ def build_snippets(host: str = DEFAULT_HOST,
"apiProvider": "openai", "apiProvider": "openai",
"openAiBaseUrl": gw, "openAiBaseUrl": gw,
"openAiApiKey": "local", "openAiApiKey": "local",
"openAiModelId": "coding", "openAiModelId": PRIMARY_MODEL,
}, indent=2) }, indent=2)
# Zed = Rust-nativ, ~16x leichter als VS Code, Agent Panel mit parallelen Agenten, # Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
# Windows stabil seit Okt 2025 — das Haupt-Tool für den User (Anti-Bloat + klickbar). # Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
zed = json.dumps({ hermes_desktop = (
"language_models": { f"# Hermes Desktop — Remote-IDE auf der Box\n"\
"openai_compatible": { f"#\n"\
"bosgame": { f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
"api_url": gw, f"# (MC2-Proxy auf dem Gateway)\n"\
"available_models": [ f"#\n"\
{"name": IDE_MODEL, "display_name": "Box / coding", "max_tokens": 131072, f"# 2. Session-Token: auf der Box liegen in\n"\
"capabilities": {"tools": True}} f"# ~/.hermes/desktop-gateway-token\n"\
], f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
} f"#\n"\
} f"# 3. Token im Browser-Login einfügen — fertig.\n"\
}, f"#\n"\
"agent": { f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
"default_model": {"provider": "openai_compatible", "model": IDE_MODEL} )
}
}, indent=2)
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und # Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer # bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
@@ -67,12 +80,12 @@ def build_snippets(host: str = DEFAULT_HOST,
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n" f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
f"# • claude-code-router (leichtgewichtig, npm)\n" f"# • claude-code-router (leichtgewichtig, npm)\n"
f"# • oder LiteLLM mit /v1/messages-Bridge\n" f"# • oder LiteLLM mit /v1/messages-Bridge\n"
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coding, apiKey=local.\n" f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n" f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
f"\n" f"\n"
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n' f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
f'export ANTHROPIC_AUTH_TOKEN="local"\n' f'export ANTHROPIC_AUTH_TOKEN="local"\n'
f'export ANTHROPIC_MODEL="coding"' f'export ANTHROPIC_MODEL="coder"'
) )
memory_mcp = json.dumps({ memory_mcp = json.dumps({
@@ -89,18 +102,19 @@ def build_snippets(host: str = DEFAULT_HOST,
"host": host, "host": host,
"gateway_url": gw, "gateway_url": gw,
"mc_url": mc_url, "mc_url": mc_url,
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 03.07.2026): Zed (Haupt-Tool: # Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
# Rust-nativ/leichtgewichtig, Agent Panel, parallele Agenten, Windows stabil seit 10/2025), # (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
# Kilo Code (VS-Code-Fallback mit Orchestrator-Modus), Claude Code (starke Sessions). # Claude Code (starke Sessions).
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal # Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie. # oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
"tools": { "tools": {
"zed": {"label": "Zed (empfohlen)", "lang": "json", "snippet": zed, "hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
"note": "Leichtgewicht-Editor (Rust, ~16x weniger RAM als VS Code). settings.json → " "note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
"language_models.openai_compatible; Agent Panel nutzt dann die Box. Lane 'coding' routet intern."}, "Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo, "kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. " "note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
"API-Profile je Modus: Code→coding · Architect/Orchestrator→heavy · Ask/Debug→chat."}, "API-Profile je Modus: Code→coder · Architect/Orchestrator→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; Gedächtnis-Anbindung via Memory-MCP unten."},
}, },
@@ -111,18 +125,20 @@ def build_snippets(host: str = DEFAULT_HOST,
def check_health() -> dict: def check_health() -> dict:
"""Live-Erreichbarkeit der beiden Leitungen, aus Sicht der Box: """Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
Leitung 1 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0).""" Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
sonst llama-swap direkt), Leitung 2 = Gedächtnis-Sidecar (Mem0),
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
gateway = {"ok": False, "detail": "nicht erreichbar"} gateway = {"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"{LLAMA_SWAP_URL}/v1/models") r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
if r.status_code == 200: if r.status_code == 200:
n = len(r.json().get("data", [])) n = len(r.json().get("data", []))
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"}
@@ -131,7 +147,16 @@ def check_health() -> dict:
r = c.get(f"{MEM0_SERVICE_URL}/health") r = c.get(f"{MEM0_SERVICE_URL}/health")
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200 memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"}) else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001 except Exception:
pass pass
return {"gateway": gateway, "memory": memory} desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception:
pass
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
+16 -6
View File
@@ -1,10 +1,12 @@
""" """
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar. V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
""" """
from config import PORT import httpx
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
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
p = load_policy() p = load_policy()
coding_default = p["coder_lite"] or p["coder"] coding_default = p["coder_lite"] or p["coder"]
return { return {
"mode": "builtin", "mode": "gateway (eigener Prozess)" if V1_UPSTREAM else "builtin",
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)", "endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias). # Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
"lanes": [ "lanes": [
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
def gateway_reachable() -> bool: def gateway_reachable() -> bool:
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft. """Erreichbarkeit des ECHTEN Denkpfads. Mit V1_UPSTREAM ist das der eigenständige
mc2-gateway-Prozess (:9010) — vorher meldete diese Funktion nur die Engine, d. h.
ein toter Gateway blieb in Health/Diensten/Cockpit unsichtbar (Review 15.07.)."""
if V1_UPSTREAM:
try:
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
except Exception:
return False
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
return engine_reachable() return engine_reachable()
+38 -1
View File
@@ -7,11 +7,46 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
import json import json
import logging import logging
import time
from services.token_stats import increment_tokens from services.token_stats import increment_tokens
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Kontext-Limit-Warnung (User-Wunsch 15.07.: „es sollte eine Warnung geben — Kontext ist
# nicht unendlich"): finish_reason=length heißt, eine Antwort ist am Token-/Kontext-Budget
# ABGERISSEN — bei Nacht-Workern stirbt damit still die halbe Arbeit (4 Worker am 15.07.).
# Statt still: Eintrag in den Briefkasten (silent → Chronik/Panel, kein Sprach-Spam),
# je Modell höchstens alle 10 min. Läuft im mc2-gateway-Prozess → announce liefert per
# MC_ANNOUNCE_HTTP beim Steuerpult ab (Unit-Env), nie direkt in die Store-Datei.
_trunc_last: dict[str, float] = {}
_TRUNC_EVERY = 600.0 # s
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
_PING_MAXTOK = 16
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
return
now = time.time()
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
return
_trunc_last[model] = now
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
try:
from services import announce
announce.add(
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
"[Kontext-Limit]", "gateway", "silent")
except Exception:
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
def record_usage(usage: dict | None, model: str) -> None: def record_usage(usage: dict | None, model: str) -> None:
"""Ein usage-Objekt verbuchen (no-op bei None/leer).""" """Ein usage-Objekt verbuchen (no-op bei None/leer)."""
@@ -23,9 +58,11 @@ def record_usage(usage: dict | None, model: str) -> None:
increment_tokens(prompt, completion, model=model) increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None: def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden """Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht.""" geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
warn_truncation(model, max_tokens)
if b'"usage"' not in chunk: if b'"usage"' not in chunk:
return return
text = chunk.decode("utf-8", errors="ignore") text = chunk.decode("utf-8", errors="ignore")
+105
View File
@@ -49,6 +49,14 @@ class _Reader:
n = self.u64() n = self.u64()
return self.read(n).decode("utf-8", "replace") return self.read(n).decode("utf-8", "replace")
def scalar(self, vtype: int):
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
fmt = _SCALAR_FMT.get(vtype)
if not fmt:
self.skip_value(vtype)
return None
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
def skip_value(self, vtype: int) -> None: def skip_value(self, vtype: int) -> None:
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt """Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
weiterzuschieben). Arrays werden elementweise konsumiert.""" weiterzuschieben). Arrays werden elementweise konsumiert."""
@@ -159,3 +167,100 @@ def compatible(target_path: str, draft_path: str) -> bool | None:
if a is None or b is None: if a is None or b is None:
return None return None
return a == b return a == b
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
_ARCH_WANT = (
".block_count", ".attention.head_count_kv", ".attention.head_count",
".attention.key_length", ".attention.value_length", ".embedding_length",
".context_length",
)
def _read_arch_meta(path: str) -> dict | None:
try:
with open(path, "rb") as fh:
r = _Reader(fh)
if r.read(4) != b"GGUF":
return None
r.u32() # version
r.u64() # tensor_count
kv_count = r.u64()
raw: dict = {}
arch = None
for _ in range(kv_count):
key = r.gstr()
vtype = r.u32()
if key == "general.architecture" and vtype == _T_STRING:
arch = r.gstr()
continue
if key == "tokenizer.ggml.tokens":
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
if suf is not None and vtype in _SCALAR_SIZE:
raw[suf] = r.scalar(vtype)
else:
r.skip_value(vtype)
n_layers = raw.get(".block_count")
n_head = raw.get(".attention.head_count")
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
n_embd = raw.get(".embedding_length")
hd_k = raw.get(".attention.key_length") \
or (int(n_embd / n_head) if (n_embd and n_head) else None)
hd_v = raw.get(".attention.value_length") or hd_k
if not (n_layers and n_head_kv and hd_k and hd_v):
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
except (OSError, EOFError, ValueError, struct.error):
return None
@lru_cache(maxsize=128)
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
return _read_arch_meta(path)
def arch_meta(path: str) -> dict | None:
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
oder unvollständig."""
import os
try:
st = os.stat(path)
except OSError:
return None
return _arch_cached(path, st.st_mtime, st.st_size)
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
_KV_BPE = {
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
}
_GIB = 1024 ** 3
def _bpe(cache_type: str | None) -> float:
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
k = per_tok * meta["head_dim_k"] * _bpe(ck)
v = per_tok * meta["head_dim_v"] * _bpe(cv)
return (k + v) / _GIB
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
return kv_cache_gb(meta, 1, ck, cv)
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -90,7 +90,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
log_str = "\n".join(job["log"]) log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str: if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True job["sudo_failed"] = True
except Exception as exc: # noqa: BLE001 except Exception as exc:
_append_log(job, f"[mc] Fehler: {exc}") _append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed" job["state"] = "failed"
job["returncode"] = -1 job["returncode"] = -1
@@ -101,7 +101,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
if cb and job["state"] == "done": if cb and job["state"] == "done":
try: try:
cb() cb()
except Exception as exc: # noqa: BLE001 except Exception as exc:
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}") _append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
@@ -136,7 +136,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
j["rate_bps"] = rate j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate) j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur prev_t, prev_b = now, cur
except Exception: # noqa: BLE001 except Exception:
pass pass
time.sleep(1.0) time.sleep(1.0)
j = JOBS.get(job_id) j = JOBS.get(job_id)
@@ -182,7 +182,7 @@ def cancel_job(job_id: str) -> bool:
if proc is not None: if proc is not None:
try: try:
proc.terminate() proc.terminate()
except Exception: # noqa: BLE001 except Exception:
pass pass
else: else:
job["state"] = "canceled" job["state"] = "canceled"
+78 -18
View File
@@ -11,19 +11,24 @@ 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__)
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS, # Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
# maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident # maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident
# in der `brains`-Gruppe); UI-Label „Hirn". # in der `brains`-Gruppe); UI-Label „Hirn".
ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes"} ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes", "kritiker", "reranker"}
_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)") _CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)") _PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
@@ -143,19 +148,36 @@ 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"
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
PROTECTED_ALIASES = {"hermes", "embed"}
def protected_alias_of(model_id: str) -> str | None:
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
spec = (read_config().get("models") or {}).get(model_id)
if isinstance(spec, dict):
for a in spec.get("aliases") or []:
if str(a).lower() in PROTECTED_ALIASES:
return str(a).lower()
return None
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None: def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen """Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
anderen Modellen entfernen). role=None/leer entfernt den Alias.""" entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
models = cfg.get("models") or {} models = cfg.get("models") or {}
role = (role or "").strip().lower() role = (role or "").strip().lower()
if role: if role:
@@ -169,8 +191,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
spec.pop("aliases", None) spec.pop("aliases", None)
spec = models.get(model_id) spec = models.get(model_id)
if isinstance(spec, dict): if isinstance(spec, dict):
if role and role != model_id.lower(): keep = [a for a in (spec.get("aliases") or [])
spec["aliases"] = [role] if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
new = keep + ([role] if role and role != model_id.lower() else [])
if new:
spec["aliases"] = new
else: else:
spec.pop("aliases", None) spec.pop("aliases", None)
@@ -197,6 +222,13 @@ def write_config(cfg: dict) -> None:
with tmp.open("w", encoding="utf-8") as f: with tmp.open("w", encoding="utf-8") as f:
r_yaml.dump(cfg, f) r_yaml.dump(cfg, f)
os.replace(tmp, CONFIG_PATH) os.replace(tmp, CONFIG_PATH)
# llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn.
# Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt.
try:
from services import warmer
warmer.nudge()
except Exception:
pass
except PermissionError as exc: except PermissionError as exc:
raise PermissionError( raise PermissionError(
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. " f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
@@ -207,9 +239,12 @@ def write_config(cfg: dict) -> None:
def register_model(model_path: str, role: str | None = None, ctx: int = 8192, def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None, ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str: jinja: bool = False, set_alias: bool = True) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die """Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).""" Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
minutenlang auf eine Datei, die noch gar nicht existiert)."""
cfg = read_config() cfg = read_config()
model_id = model_id_from_path(model_path) model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx)) cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
@@ -235,10 +270,18 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd: if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
cmd += spec_draft_flags(model_path) cmd += spec_draft_flags(model_path)
cfg.setdefault("models", {})[model_id] = { # Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
old_aliases = (cfg.get("models") or {}).get(model_id, {})
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
entry: dict = {
"cmd": LiteralScalarString(cmd + "\n"), "cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL, "ttl": ttl if ttl is not None else DEFAULT_TTL,
} }
if old_aliases:
entry["aliases"] = old_aliases
cfg.setdefault("models", {})[model_id] = entry
if set_alias:
set_role_alias(cfg, model_id, role) set_role_alias(cfg, model_id, role)
write_config(cfg) write_config(cfg)
return model_id return model_id
@@ -392,10 +435,27 @@ def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None: def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True → GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
Mitglieder werden nie automatisch entladen.""" Mitglieder werden nie von anderen Gruppen verdrängt.
llama-swap kennt dafür AUSSCHLIESSLICH den Key `persistent` — `persist` wird von ihm
stillschweigend ignoriert (so verlor das Hirn seinen Verdrängungsschutz; live gefunden
03.07.2026: Coder-Last warf die brains-Gruppe raus). `persist` wird zusätzlich weiter
geschrieben, weil MC2-API/UI (routers/models.py, agent.py, Frontend) diesen Key lesen."""
cfg = read_config() cfg = read_config()
groups = cfg.setdefault("groups", {}) groups = cfg.setdefault("groups", {})
groups[group] = {"swap": swap, "persist": persist, "members": list(members)} groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
"members": list(members)}
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
# ttl-Selbstentladen — ein Mitglied mit ttl>0 fällt trotz Gruppen-Mitgliedschaft
# nach Leerlauf aus dem Warm-Set (live gefunden 03.07.2026: VL-30B mit ttl 300
# entlud sich alle 5 Min). So kann dieser Fehler beim Warm-Set-Umbau nie wieder passieren.
if persist:
models = cfg.get("models") or {}
for mid in members:
spec = models.get(mid)
if isinstance(spec, dict):
spec["ttl"] = 0
write_config(cfg) write_config(cfg)
+250 -59
View File
@@ -19,7 +19,8 @@ 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", "hermes-gateway", "hermes-terminal", "mem0-service", "voice-service"} USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "mem0-service", "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__), "..", ".."))
@@ -30,6 +31,24 @@ ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp") ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp")
_engine_cache = {"ts": 0.0, "avail": False} _engine_cache = {"ts": 0.0, "avail": False}
# Neueste Release, die WIRKLICH ein Vulkan-x64-Binary trägt. Die allerneueste Release hat
# 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
# 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.IGNORECASE)
def _latest_engine_asset_release() -> dict | None:
try:
rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15",
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
for rel in (rels if isinstance(rels, list) else []):
if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])):
return rel
except Exception:
pass
return None
# Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes # Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes
# Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt. # Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt.
SWAP_UPDATE_CMD = os.environ.get( SWAP_UPDATE_CMD = os.environ.get(
@@ -86,14 +105,13 @@ def _engine_update_available() -> bool:
return _engine_cache["avail"] return _engine_cache["avail"]
avail = False avail = False
try: try:
rel = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases/latest", rel = _latest_engine_asset_release() or {}
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", "")) tag = str(rel.get("tag_name", ""))
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
installed = _installed_engine_build() installed = _installed_engine_build()
if latest is not None and installed is not None: if latest is not None and installed is not None:
avail = latest > installed # präziser Build-Nummer-Vergleich avail = latest > installed # präziser Build-Nummer-Vergleich
else: # Fallback: Release-Datum vs. Engine-mtime elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp() pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400 avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
except Exception: except Exception:
@@ -271,7 +289,7 @@ def model_upgrades() -> list[dict]:
continue continue
base = rec.split("/")[-1].lower() base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base stem = base.removesuffix("-gguf")
if base in cmds or (stem and stem in cmds): if base in cmds or (stem and stem in cmds):
continue # schon installiert continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec}) out.append({"role": role, "title": c["title"], "repo": rec})
@@ -298,8 +316,42 @@ def updates() -> dict:
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ── # ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
def _os_held_back() -> list[dict]:
"""Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund.
Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen:
• Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'.
• zurückgehalten wegen neuer Abhängigkeiten → 'kept back'.
Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl,
wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen."""
held: list[dict] = []
sections = {
"The following upgrades have been deferred due to phasing:": "phasing",
"The following packages have been kept back:": "kept_back",
}
try:
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
capture_output=True, text=True, timeout=25)
reason: str | None = None
for line in (out.stdout or "").splitlines():
hdr = sections.get(line.strip())
if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln
reason = hdr
continue
if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts
for name in line.split():
held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None
except Exception:
pass
held.sort(key=lambda p: p["name"])
return held
def os_update_details() -> dict: def os_update_details() -> dict:
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat).""" """Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche
Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back)."""
out_pkgs: list[dict] = [] out_pkgs: list[dict] = []
try: try:
# LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift # LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift
@@ -314,8 +366,9 @@ def os_update_details() -> dict:
out_pkgs.append({"name": m.group(1), "candidate": m.group(2), out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
"current": m.group(3).strip()}) "current": m.group(3).strip()})
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,
except Exception as exc: # noqa: BLE001 "held_back": _os_held_back()}
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)}
@@ -325,8 +378,7 @@ def engine_update_details() -> dict:
"latest_build": None, "latest_tag": None, "name": None, "latest_build": None, "latest_tag": None, "name": None,
"url": None, "body": None} "url": None, "body": None}
try: try:
rel = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases/latest", rel = _latest_engine_asset_release() or {}
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
tag = str(rel.get("tag_name", "")) tag = str(rel.get("tag_name", ""))
info["latest_tag"] = tag info["latest_tag"] = tag
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
@@ -334,7 +386,14 @@ def engine_update_details() -> dict:
info["url"] = rel.get("html_url") info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip() body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None info["body"] = body[:2000] if body else None
except Exception as exc: # noqa: BLE001 # Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call,
# wenn das Modal bei aktuellem Stand geöffnet wird).
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc:
info["error"] = str(exc) info["error"] = str(exc)
return info return info
@@ -354,45 +413,100 @@ def swap_update_details() -> dict:
info["url"] = rel.get("html_url") info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip() body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None info["body"] = body[:2000] if body else None
except Exception as exc: # noqa: BLE001 if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc:
info["error"] = str(exc) info["error"] = str(exc)
return info return info
# LLM-Zusammenfassung der anstehenden Hermes-Commits (die Box liest ihre Release-Notes selbst). # LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes
# Gecacht auf den neuesten Commit-Hash — das Modal darf beliebig oft geöffnet werden. # selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer
_relnotes_cache: dict = {"key": "", "summary": ""} # (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …".
# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig
# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen.
_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}}
# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — <grund>".
# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut.
_ACTION_RX = re.compile(
r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE)
def _summarize_hermes_commits(commits: list[dict]) -> str: def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
key = commits[0]["hash"] if commits else "" """Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt
ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`."""
empty = {"summary": "", "action_needed": None, "action_text": ""}
if not key: if not key:
return "" return empty
if _relnotes_cache["key"] == key and _relnotes_cache["summary"]: cache = _relnotes_caches.setdefault(kind, {})
return _relnotes_cache["summary"] if cache.get("key") == key and cache.get("data"):
return cache["data"]
from config import LLAMA_SWAP_URL from config import LLAMA_SWAP_URL
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
prompt = ( prompt = (
"Du bist der Update-Berater einer lokalen AI-Box (Hermes-Agent auf Strix Halo; genutzt werden: " "Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, "
"api_server/Gateway, memory-provider-Plugin 'mc2-memory', terminal-/web-Tools, approvals, cron). " "fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n"
"Hier die Commit-Titel des anstehenden Hermes-Updates:\n\n" + subjects + "\n\n" f"{context}\n\n"
"Fasse auf DEUTSCH in maximal 6 Stichpunkten zusammen: (1) Breaking Changes oder geänderte/" "Anstehende Änderungen:\n" + changes + "\n\n"
"entfernte Config-Schlüssel (WICHTIGSTES zuerst, explizit warnen), (2) was unser Setup betrifft, " "Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n"
"(3) welche neuen Features sich für uns lohnen. Keine Einleitung, nur die Punkte." "Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n"
"'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das "
"Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der "
"Normalfall.\n"
"'AKTION: JA — <was er konkret tun/entscheiden muss>' — NUR wenn er wirklich selbst "
"handeln muss.\n"
"Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder "
"geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup "
"betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift."
) )
try: try:
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={ r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
"model": "fast", "max_tokens": 380, "temperature": 0.2, "model": "fast", "max_tokens": 550, "temperature": 0.2,
"chat_template_kwargs": {"enable_thinking": False}, "chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
}) })
r.raise_for_status() r.raise_for_status()
summary = ((r.json().get("choices") or [{}])[0].get("message", {}).get("content") or "").strip() resp = r.json()
if summary: choice = (resp.get("choices") or [{}])[0]
_relnotes_cache.update(key=key, summary=summary) text = ((choice.get("message") or {}).get("content") or "").strip()
return summary finish_reason = choice.get("finish_reason") or ""
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
return f"(Zusammenfassung nicht verfügbar: {exc})" # finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
# letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig.
if finish_reason == "length" and text:
head, _, _tail = text.rpartition("\n")
text = head if head else ""
# Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1,
# aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile).
action_needed: bool | None = None
action_text = ""
lines = text.splitlines()
for idx, ln in enumerate(lines):
if m := _ACTION_RX.match(ln):
action_needed = m.group(1).upper() == "JA"
action_text = (m.group(2) or "").strip()
text = "\n".join(lines[:idx] + lines[idx + 1:]).strip()
break
data = {"summary": text, "action_needed": action_needed, "action_text": action_text}
if text:
cache.update(key=key, data=data)
return data
except Exception as exc:
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""}
def _summarize_hermes_commits(commits: list[dict]) -> dict:
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
"terminal-/web-Tools, approvals, cron).")
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
def hermes_update_details() -> dict: def hermes_update_details() -> dict:
@@ -419,8 +533,8 @@ def hermes_update_details() -> dict:
info["commits"] = commits info["commits"] = commits
info["behind"] = len(commits) info["behind"] = len(commits)
if commits: if commits:
info["summary"] = _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
@@ -461,7 +575,7 @@ def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."} return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]} return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001 except Exception as exc:
return {"ok": False, "out": "", "err": str(exc)} return {"ok": False, "out": "", "err": str(exc)}
@@ -528,7 +642,10 @@ def os_update_job(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password(sudo_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).
cmd = ("sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y " # DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
# sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab.
cmd = ("sudo apt-get update && "
"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", sudo_password=sudo_password)
@@ -540,8 +657,9 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
return None return None
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
if err := check_sudo_needs_password(sudo_password): # KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
return err # (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
def on_done(): def on_done():
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich _engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
@@ -551,7 +669,7 @@ def engine_update_job(sudo_password: str | None = None) -> dict | None:
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. # neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
"Engine-Update (llama.cpp Vulkan)", "Engine-Update (llama.cpp Vulkan)",
group="maintenance", on_done=on_done, sudo_password=sudo_password) group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
@@ -560,8 +678,7 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
return None return None
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
if err := check_sudo_needs_password(sudo_password): # KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
return err
def on_done(): def on_done():
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich _swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
@@ -571,39 +688,113 @@ def swap_update_job(sudo_password: str | None = None) -> dict | None:
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. # Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD], job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
"Router-Update (llama-swap)", "Router-Update (llama-swap)",
group="maintenance", on_done=on_done, sudo_password=sudo_password) group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def _hermes_update_cmd() -> str:
"""Die komplette Hermes-Update-Befehlskette (Backup → Update → Doctor → UI-Build →
Neustarts → Postcheck) — geteilt von hermes_update_job und update_all_job."""
git = system.find_hermes_agent_git()
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
py = os.path.join(path, "venv", "bin", "python")
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh")
# Backup → Stop UI (verhindert Crash durch Löschen der Sourcen) → update → doctor →
# UI-Build mit MC2-Basepath (/hermes-ui/) → Gateway + UI-Neustart → Smoke-Test.
hui_web = os.path.join(path, "web")
hui_dist = os.path.join(path, "hermes_cli", "web_dist")
npm_path = os.path.expanduser("~/.hermes/node/bin")
build_cmd = (
f"if [ -d {hui_web} ]; then "
f"cd {hui_web} && PATH={npm_path}:$PATH npx --no-install vite build --base=/hermes-ui/ --outDir /tmp/h-build --emptyOutDir "
f"&& rm -rf {hui_dist} && cp -r /tmp/h-build {hui_dist}; fi"
)
# EHRLICHER Exit-Code (Lehre 08.07.): Die Dienste müssen auch bei einem GESCHEITERTEN
# Update wieder hochkommen (die UI wurde ja gestoppt) — aber der Job darf dann nicht
# 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
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
return ("RC=0; "
f"bash {backup} || true; "
f"systemctl --user stop hermes-builtin-ui || true; "
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
f"&& {py} -m hermes_cli.main doctor "
f"&& {build_cmd}; }} || RC=1; "
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
f"systemctl --user restart hermes-gateway hermes-builtin-ui || RC=1; "
f"sleep 6; bash {postcheck} || RC=1; "
f"if [ $RC -ne 0 ]; then echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; "
f"exit $RC")
def hermes_update_job() -> dict: def hermes_update_job() -> dict:
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern).""" (alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
if busy := _maintenance_busy(): if busy := _maintenance_busy():
return busy return busy
git = system.find_hermes_agent_git()
path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent")
py = os.path.join(path, "venv", "bin", "python")
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh")
# Backup → update → Gateway-Neustart → Gehirn-Check (Job wird rot, wenn Mem0/Plugin kaputt).
# Backup → update → doctor (Config/Deps-Diagnose der NEUEN Version) → Gateway-Neustart →
# Gehirn-Check (erweitert um Config-Drift-Scan, Tool- und Voice-Smoke — Lehre aus v0.18:
# 'approvals.mode auto' wurde still ungültig und legte alle Tools in pending_approval).
cmd = (f"bash {backup} || true; "
f"cd {path} && {py} -m hermes_cli.main update --yes "
f"&& {py} -m hermes_cli.main doctor "
f"&& systemctl --user restart hermes-gateway "
f"&& sleep 6 && bash {postcheck}")
def on_done(): def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen _comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
job_id = jobengine.start_job(["bash", "-c", cmd], "Hermes-Agent-Update", job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
group="maintenance", on_done=on_done) group="maintenance", on_done=on_done)
return {"ok": True, "job_id": job_id} return {"ok": True, "job_id": job_id}
def update_all_job(sudo_password: str | None = None) -> dict:
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
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;
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
if busy := _maintenance_busy():
return busy
upd = updates()
parts: list[tuple[str, str]] = []
if upd.get("engine") and ENGINE_UPDATE_CMD:
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
if upd.get("swap") and SWAP_UPDATE_CMD:
parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD))
if any(c.get("update") is True for c in upd.get("components") or []):
parts.append(("Hermes-Agent", _hermes_update_cmd()))
os_pending = (upd.get("os") or 0) > 0
if os_pending:
if err := check_sudo_needs_password(sudo_password):
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
if not parts:
return err
os_pending = False
else:
parts.append(("OS (apt)", (
"sudo apt-get update && "
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
f"&& bash {STACK_POSTCHECK}")))
if not parts:
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
cmd = " && ".join(
f"(echo; echo '════════ [{i + 1}/{len(parts)}] {label} ════════'; {c})"
for i, (label, c) in enumerate(parts))
if not os_pending:
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
def on_done():
_engine_cache.update(ts=0.0, avail=False)
_swap_cache.update(ts=0.0, avail=False)
_comp_cache.update(ts=0.0, data=[])
labels = "".join(label for label, _ in parts)
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
group="maintenance", on_done=on_done,
sudo_password=sudo_password)
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
def reboot(sudo_password: str | None = None) -> dict: def reboot(sudo_password: str | None = None) -> dict:
if err := check_sudo_needs_password(sudo_password): if err := check_sudo_needs_password(sudo_password):
return err return err
-147
View File
@@ -1,147 +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 re
from difflib import SequenceMatcher
import httpx
from config import MEM0_SERVICE_URL
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}
+128
View File
@@ -0,0 +1,128 @@
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
Laden fliegt alles raus, was älter als 24 h ist.
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
Lücke pro Deploy sind egal.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from config import MODELS_DIR
log = logging.getLogger(__name__)
SAMPLE_S = 10 # Abtast-Takt
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
FLUSH_S = 300 # höchstens alle 5 min auf Platte
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
_points: deque = deque(maxlen=MAXLEN)
_loaded = False
_last_flush = 0.0
def _load() -> None:
global _loaded
if _loaded:
return
_loaded = True
try:
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
cutoff = time.time() - RETENTION_S
for p in raw if isinstance(raw, list) else []:
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
_points.append(p)
if _points:
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
except FileNotFoundError:
pass
except Exception:
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
def _flush() -> None:
global _last_flush
_last_flush = time.time()
try:
tmp = HISTORY_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
tmp.replace(HISTORY_PATH)
except OSError:
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
def _sample() -> None:
import psutil
from services.system import _gpu_sysfs
from services.token_stats import get_stats
vm = psutil.virtual_memory()
disk = None
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
pass
gpu = None
try:
g = _gpu_sysfs()
gpu = g.get("busy_percent") if g else None
except Exception:
pass
tp = tc = None
try:
ts = get_stats()
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
except Exception:
pass
# 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])
async def sampler_loop() -> None:
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
_load()
while True:
try:
await asyncio.to_thread(_sample)
except Exception:
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
if time.time() - _last_flush >= FLUSH_S:
try:
await asyncio.to_thread(_flush)
except Exception:
pass
await asyncio.sleep(SAMPLE_S)
def history(minutes: int = 60) -> dict:
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
_load()
minutes = max(1, min(int(minutes), RETENTION_S // 60))
cutoff = time.time() - minutes * 60
pts = [p for p in _points if p[0] >= cutoff]
stride = max(1, len(pts) // MAX_RETURN_POINTS)
pts = pts[::stride]
return {
"sample_s": SAMPLE_S * stride,
"points": [
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
for p in pts
],
}
+171
View File
@@ -0,0 +1,171 @@
"""
Erinnerungen & Routinen (Lucy-Proaktivität, Faden A3).
„Lucy, erinner mich morgen um 9 an …" — der Hermes-Agent legt per Tool (mcp_mc.py:
reminder_create) einen Eintrag an; der Wecker-Loop hier feuert ihn zur Zeit in den
Melde-Briefkasten (announce.py → Lucy spricht) und parallel auf Telegram.
Bewusst OHNE Hermes-cron: der Wecker läuft deterministisch im MC2-Backend (systemd,
überlebt Gateway-Ausfälle) und bleibt in UNSEREN Schichten (Hermes-Quellcode/Config
bleibt Auto-Update-Kanal, Verdikt Faden 11c).
Zeiten: ISO-8601. Ohne Offset gilt die Zeitzone des Commanders (MC_LOCAL_TZ,
Default Europe/Berlin) — die Box selbst läuft auf UTC, naive Zeiten dürfen daher
NIE als Box-Lokalzeit interpretiert werden. Wiederholung: daily | weekdays | weekly.
"""
import json
import logging
import os
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR
from services import announce
log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
REPEATS = ("", "daily", "weekdays", "weekly")
LATE_NOTE_S = 600 # feuert >10 min zu spät (Box war aus) → ehrlich dazusagen
_lock = threading.Lock()
_state: dict | None = None # {"next_id": int, "items": [...]}
def _load() -> dict:
global _state
if _state is None:
try:
_state = json.loads(STORE_PATH.read_text(encoding="utf-8"))
assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list)
except Exception:
_state = {"next_id": 1, "items": []}
return _state
def _save(state: dict) -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
log.warning("reminders: Store %s nicht schreibbar", STORE_PATH, exc_info=True)
def _parse_when(when: str) -> datetime:
"""ISO-8601 → bewusste Zeit. Naive Angaben = Commander-Zeitzone (NICHT Box-UTC)."""
try:
dt = datetime.fromisoformat(when.strip())
except ValueError:
raise ValueError("Zeit nicht lesbar — bitte ISO-8601 mit Datum UND Uhrzeit, z.B. 2026-07-04T09:00.")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=LOCAL_TZ)
return dt
def _fmt(ts: float) -> str:
"""Menschlich lesbare Commander-Lokalzeit (fürs Tool-Echo/Listing)."""
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%a %d.%m.%Y %H:%M")
def _advance(ts: float, repeat: str) -> float:
"""Nächste Wiederholung NACH jetzt (holt verpasste Termine ohne Mehrfach-Feuern auf)."""
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
now = time.time()
while dt.timestamp() <= now:
dt += timedelta(days=7 if repeat == "weekly" else 1)
if repeat == "weekdays":
while dt.weekday() > 4: # Sa/So überspringen
dt += timedelta(days=1)
return dt.timestamp()
def create(text: str, when: str, repeat: str = "") -> dict:
text = (text or "").strip()
if not text:
raise ValueError("Leerer Erinnerungs-Text.")
repeat = (repeat or "").strip().lower()
if repeat == "once":
repeat = ""
if repeat not in REPEATS:
raise ValueError("repeat muss leer, daily, weekdays oder weekly sein.")
ts = _parse_when(when).timestamp()
if repeat:
if ts <= time.time(): # Startpunkt vorbei → auf die nächste Wiederholung rücken (Uhrzeit bleibt exakt)
ts = _advance(ts, repeat)
elif ts <= time.time() - 60:
raise ValueError(f"Die Zeit liegt in der Vergangenheit ({_fmt(ts)}) — bitte neu umrechnen "
f"(jetzt ist {_fmt(time.time())}).")
with _lock:
state = _load()
if len(state["items"]) >= MAX_ITEMS:
raise ValueError(f"Zu viele offene Erinnerungen (max {MAX_ITEMS}).")
item = {"id": state["next_id"], "text": text[:500], "next_ts": ts,
"repeat": repeat, "created_ts": time.time()}
state["next_id"] += 1
state["items"].append(item)
_save(state)
log.info("reminder #%s angelegt: %s%s%s", item["id"], _fmt(ts), text[:60],
f" (Routine {repeat})" if repeat else "")
return {**item, "when_local": _fmt(ts)}
def list_all() -> list[dict]:
with _lock:
items = sorted(_load()["items"], key=lambda i: i["next_ts"])
return [{**i, "when_local": _fmt(i["next_ts"])} for i in items]
def delete(reminder_id: int) -> dict:
with _lock:
state = _load()
for i, item in enumerate(state["items"]):
if item["id"] == reminder_id:
state["items"].pop(i)
_save(state)
return {**item, "when_local": _fmt(item["next_ts"])}
raise KeyError(f"Erinnerung {reminder_id} nicht gefunden.")
def _fire_due() -> None:
now = time.time()
with _lock:
state = _load()
due = [i for i in state["items"] if i["next_ts"] <= now]
if not due:
return
for item in due:
if item["repeat"]:
item["next_ts"] = _advance(item["next_ts"], item["repeat"])
else:
state["items"].remove(item)
_save(state)
for item in due:
late = now - item["next_ts"] > LATE_NOTE_S if not item["repeat"] else False
text = f"Erinnerung, Commander: {item['text']}"
if late:
text += f" (Eigentlich fällig um {_fmt(item['next_ts'])} — die Box war wohl aus.)"
announce.add(text, subject="[Erinnerung]", source="reminder")
announce.notify_telegram("[Erinnerung]", item["text"])
log.info("reminder #%s gefeuert%s", item["id"], " (Routine)" if item["repeat"] else "")
async def reminders_loop() -> None:
"""Wecker (Hintergrund-Task im MC2-Lifespan). Verpasste einmalige Erinnerungen
(Box war aus) feuern beim nächsten Tick nach — ehrlich als verspätet markiert."""
import asyncio
log.info("reminders: Wecker aktiv (Tick %ss, Zeitzone %s)", INTERVAL, LOCAL_TZ)
while True:
try:
await asyncio.to_thread(_fire_due)
except Exception:
log.debug("reminders: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
+33 -1
View File
@@ -50,9 +50,41 @@ _CODE_HINT = re.compile(
) )
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
VISION_CAPABLE = {"vision", "scout"}
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
def has_image(body: dict) -> bool:
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
content = m.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
return True
return False
def _text_of(body: dict) -> str: def _text_of(body: dict) -> str:
msgs = body.get("messages") or [] msgs = body.get("messages") or []
return "\n".join(str(m.get("content") or "") for m in msgs) # Multimodaler content ist eine Liste — nur die Text-Parts fuers Komplexitaets-Routing
# zusammenziehen (ein dict/Liste als str() wuerde die Zeichen-Schwelle verfaelschen).
out = []
for m in msgs:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str):
out.append(c)
elif isinstance(c, list):
for part in c:
if isinstance(part, dict) and part.get("type") == "text":
out.append(str(part.get("text") or ""))
return "\n".join(out)
def _route_chat(text: str, n: int) -> tuple[str, str]: def _route_chat(text: str, n: int) -> tuple[str, str]:
+6 -2
View File
@@ -29,7 +29,10 @@ DEFAULTS: dict = {
"fast": os.environ.get("MC_ROUTE_FAST", "fast"), "fast": os.environ.get("MC_ROUTE_FAST", "fast"),
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"), "heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
"coder": os.environ.get("MC_ROUTE_CODER", "coder"), "coder": os.environ.get("MC_ROUTE_CODER", "coder"),
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", "").strip(), "coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""),
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
"vision": os.environ.get("MC_ROUTE_VISION", "vision"),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")), "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
@@ -41,6 +44,7 @@ FIELDS: list[dict] = [
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"}, {"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"},
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"}, {"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"}, {"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000}, {"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000}, {"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"}, {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
@@ -77,7 +81,7 @@ def _coerce(patch: dict) -> dict:
out[k] = bool(v) out[k] = bool(v)
else: # str else: # str
sv = str(v).strip() sv = str(v).strip()
if k != "coder_lite" and not sv: if k not in ("coder_lite", "vision") and not sv:
raise ValueError(f"{k} darf nicht leer sein") raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv out[k] = sv
return out return out
+155
View File
@@ -0,0 +1,155 @@
"""
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
"""
import asyncio
import logging
import os
import time
import httpx
import psutil
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap
log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
def _reach(url: str, path: str = "/health") -> bool:
try:
with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}").status_code < 500
except Exception:
return False
def _check_engine() -> bool:
return llamaswap.engine_reachable()
def _check_brain() -> bool:
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
st = llamaswap.brain_status()
if st.get("ready"):
return True
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
def _check_disk() -> bool:
try:
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
except Exception:
return True # kein Messwert ≠ Alarm
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
CHECKS: dict[str, tuple] = {
"engine": (_check_engine,
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
"Die Modell-Engine ist wieder da."),
"brain": (_check_brain,
"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."),
"hermes": (lambda: _reach(HERMES_API_URL),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"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),
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
"Der Hör-Dienst läuft wieder."),
"disk": (_check_disk,
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
"Die Platte hat wieder genug Luft."),
}
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
_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")
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
"Das Steuerpult ist wieder da.")
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 läuft wieder.")
class _Watch:
__slots__ = ("alert_ts", "alerted", "fails")
def __init__(self) -> None:
self.fails = 0 # Fehl-Ticks in Folge
self.alerted = False # Alarm ist raus, Entwarnung steht aus
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
def _notify_telegram(subject: str, text: str) -> None:
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
legt der Wächter selbst ab."""
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
def _tick() -> None:
now = time.time()
for name, (check, fail_msg, ok_msg) in CHECKS.items():
w = _watches[name]
try:
ok = bool(check())
except Exception:
ok = False
if ok:
w.fails = 0
if w.alerted:
w.alerted = False
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
_notify_telegram("[Box wieder ok]", ok_msg)
continue
w.fails += 1
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
if due:
prefix = "" if not w.alerted else "Immer noch: "
w.alerted = True
w.alert_ts = now
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
_notify_telegram("[Box-Problem]", prefix + fail_msg)
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
async def sentry_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
await asyncio.sleep(START_DELAY)
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
+1 -1
View File
@@ -10,7 +10,7 @@ TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
# Kanonische Rollen — eine Quelle der Wahrheit (deckt sich mit llamaswap.ROLE_IDS, # Kanonische Rollen — eine Quelle der Wahrheit (deckt sich mit llamaswap.ROLE_IDS,
# maintenance.ROLE_MAP, frontend ModelBadges.ROLES + Discover.ROLE_METADATA). # maintenance.ROLE_MAP, frontend ModelBadges.ROLES + Discover.ROLE_METADATA).
# `hermes` = Lucys Agent-Hirn (warm + ko-resident); UI-Label „Hirn". # `hermes` = Lucys Agent-Hirn (warm + ko-resident); UI-Label „Hirn".
ROLE_IDS = ["fast", "heavy", "coder", "vision", "hermes", "scout"] ROLE_IDS = ["fast", "heavy", "coder", "vision", "hermes", "scout", "kritiker", "reranker"]
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Priorität). Ein Modell wird der # Kategorien (Reihenfolge = Anzeige + Zuordnungs-Priorität). Ein Modell wird der
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout". # ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout".
+10 -4
View File
@@ -9,9 +9,9 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
import glob import glob
import os import os
import subprocess import subprocess
import threading
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
@@ -116,7 +116,6 @@ def find_hermes_agent_git() -> dict | None:
candidates = [ candidates = [
"~/hermes-agent", "~/hermes-agent",
"~/.hermes/hermes-agent", "~/.hermes/hermes-agent",
"~/hermes-webui/hermes-agent",
"~/.hermes" "~/.hermes"
] ]
for c in candidates: for c in candidates:
@@ -157,6 +156,7 @@ def get_engine_version() -> dict:
_VERSION_CACHE = {"ts": 0.0, "data": {}} _VERSION_CACHE = {"ts": 0.0, "data": {}}
_VERSION_LOCK = threading.Lock()
def check_versions_cached() -> dict: def check_versions_cached() -> dict:
@@ -165,12 +165,18 @@ def check_versions_cached() -> dict:
if now - _VERSION_CACHE["ts"] < 30.0: if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"] return _VERSION_CACHE["data"]
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) # Lock gegen Scan-Stampede: FastAPI führt sync-Routen im Threadpool aus → ohne Lock würden
# parallele Dashboard-/Agent-Aufrufe die git-/Versions-Scans mehrfach gleichzeitig anwerfen.
# Doppelt geprüft, damit ein zweiter Thread den frisch gefüllten Cache nimmt statt neu zu scannen.
with _VERSION_LOCK:
now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"]
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
data = { data = {
"mc2": get_git_info(mc2_path), "mc2": get_git_info(mc2_path),
"engine": get_engine_version(), "engine": get_engine_version(),
"hermes_ui": get_git_info("~/hermes-webui"),
"hermes_agent": find_hermes_agent_git() "hermes_agent": find_hermes_agent_git()
} }
_VERSION_CACHE["ts"] = now _VERSION_CACHE["ts"] = now
+26 -2
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
@@ -26,9 +25,22 @@ _lock = threading.Lock()
_stats: dict | None = None _stats: dict | None = None
_dirty = False _dirty = False
_last_flush = 0.0 _last_flush = 0.0
# mtime der Datei beim letzten eigenen Laden/Schreiben — seit dem Gateway-Auszug
# (UMBAU v3 P1) schreibt der mc2-gateway-Prozess die Datei, das Steuerpult liest nur
# noch: ohne mtime-Vergleich zeigte es ab Prozessstart eingefrorene Zahlen.
_disk_mtime: float | None = None
def _stat_mtime() -> float | None:
try:
return STATS_FILE.stat().st_mtime
except OSError:
return None
def _load_from_disk() -> dict: def _load_from_disk() -> dict:
global _disk_mtime
_disk_mtime = _stat_mtime()
if not STATS_FILE.exists(): if not STATS_FILE.exists():
return dict(_BASELINE) return dict(_BASELINE)
try: try:
@@ -51,19 +63,31 @@ def _ensure_loaded() -> dict:
def _write(stats: dict) -> None: def _write(stats: dict) -> None:
global _disk_mtime
try: try:
STATS_FILE.parent.mkdir(parents=True, exist_ok=True) STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATS_FILE.with_suffix(".tmp") tmp = STATS_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
json.dump(stats, f) json.dump(stats, f)
tmp.replace(STATS_FILE) tmp.replace(STATS_FILE)
_disk_mtime = _stat_mtime() # eigener Write ist kein Fremd-Update
except OSError: except OSError:
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True) log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
def get_stats() -> dict: def get_stats() -> dict:
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.""" """Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.
Multi-Prozess-fähig: Hat ein ANDERER Prozess (mc2-gateway) die Datei inzwischen
geschrieben und liegen hier keine ungeflushten Inkremente, wird frisch geladen.
Im Gateway-Prozess selbst ist nach jedem Flush Datei == Speicher → der
mtime-Vergleich lädt dort nie unnötig nach."""
global _stats
with _lock: with _lock:
if _stats is not None and not _dirty:
mtime = _stat_mtime()
if mtime is not None and mtime != _disk_mtime:
_stats = _load_from_disk()
return json.loads(json.dumps(_ensure_loaded())) return json.loads(json.dumps(_ensure_loaded()))
+125 -6
View File
@@ -1,24 +1,44 @@
""" """
Per-Stage-Latenz-Metriken für die Voice/Lucy-Pipeline. Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline.
Misst die Server-seitige Dauer jeder Stufe (STT, Vision-Beschreibung, Chat-TTFB, TTS) und hält Zwei Sichten auf dieselben Messungen:
rollende Statistiken (avg/p50/p95/last) im Speicher. Macht aus Latenz-VERMUTUNGEN gemessene Fakten
— die eigentliche Voraussetzung, um gezielt zu optimieren (Stufe 5/C2 des Reviews). Anzeige im 1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die
Frontend-Overhaul (E) analog zur TokenPerformanceCard. letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?").
2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit
seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein
Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren).
**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
**Mem0-Retrieve** läuft zwar in Hermes, ruft aber MC2s `/api/memory` per HTTP zurück → messbar und als
Unter-Detail INNERHALB der Hirn-Zeit ausgewiesen (kein Doppelzählen). Die **Tool-Runden** dagegen laufen
im Hermes-LLM-Loop ohne Callback an MC2 → für MC2 unsichtbar, sie stecken im „Generierung"-Bucket.
STT (davor) und Mem0-Retrieve (währenddessen) sind separate HTTP-Requests ohne Turn-ID. Auf einem
EIN-Nutzer-Gerät genügt eine schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer bzw. der
letzte Retrieve werden global „geparkt" und vom nächsten Chat-Turn eingesammelt (mit Frist-/Reihenfolge-
Check). Kein Turn-ID-Durchreichen durch den Lucy-Client 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).
""" """
import threading import threading
import time import time
import uuid
from collections import deque from collections import deque
_LOCK = threading.Lock() _LOCK = threading.Lock()
_MAX = 200 _MAX = 200
_STAGES: dict[str, deque] = {} _STAGES: dict[str, deque] = {}
_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", "chat_ttfb", "tts") STAGES = ("stt", "vision", "memory_retrieve", "chat_ttfb", "chat_first_content", "tts")
# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer / Mem0-Retrieve, je
# (ms, perf_counter-Zeitstempel). Der nächste passende Chat-Turn sammelt sie ein und leert sie.
_PARKED: dict[str, tuple[float, float] | None] = {"stt": None, "retrieve": None}
def record_stage(stage: str, ms: float) -> None: def record_stage(stage: str, ms: float) -> None:
@@ -32,6 +52,36 @@ def record_stage(stage: str, ms: float) -> None:
dq.append(float(ms)) dq.append(float(ms))
def park(kind: str, ms: float) -> None:
"""Eine Messung, die NICHT im Chat-Request selbst passiert (STT davor, Mem0-Retrieve als
Rückruf während), global parken, damit der nächste Chat-Turn sie einsammeln kann."""
if ms is None or ms < 0 or kind not in _PARKED:
return
with _LOCK:
_PARKED[kind] = (float(ms), time.perf_counter())
def _take_stt(max_age: float = 20.0) -> float | None:
"""Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start)."""
with _LOCK:
v = _PARKED.get("stt")
if v and (time.perf_counter() - v[1]) <= max_age:
_PARKED["stt"] = None
return round(v[0], 1)
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)."""
@@ -48,6 +98,68 @@ class Timer:
record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0) record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0)
class TurnTrace:
"""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.
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:
self.id = uuid.uuid4().hex[:8]
self.ts = time.time()
self.perf0 = time.perf_counter()
self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt)
self.session_id = (session_id or "")[:24]
self.kind = kind
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.had_images = False
self.error: str | None = None
def note_vision(self, ms: float) -> None:
self.vision_ms = round(ms, 1)
record_stage("vision", ms)
def mark_brain_start(self) -> None:
"""Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT
in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment)."""
self._brain0 = time.perf_counter()
def note_ttfb(self) -> None:
record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend
def note_first_content(self) -> None:
"""Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT)."""
ms = (time.perf_counter() - self._brain0) * 1000.0
self.hirn_ms = round(ms, 1)
record_stage("chat_first_content", ms)
def commit(self) -> dict:
total = (time.perf_counter() - self.perf0) * 1000.0
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.
gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None
rec = {
"id": self.id,
"ts": round(self.ts, 3),
"session": self.session_id,
"kind": self.kind,
"had_images": self.had_images,
"stt_ms": stt,
"vision_ms": self.vision_ms,
"hirn_ms": self.hirn_ms,
"gen_ms": gen if (gen is None or gen >= 0) else 0.0,
"mem0_ms": mem0,
"total_ms": round(total, 1),
"error": self.error,
}
with _LOCK:
_TURNS.append(rec)
return rec
def _summary(vals: list[float]) -> dict: def _summary(vals: list[float]) -> dict:
if not vals: if not vals:
return {"count": 0} return {"count": 0}
@@ -66,3 +178,10 @@ def get_metrics() -> dict:
"""Rollende Zusammenfassung je Stufe.""" """Rollende Zusammenfassung je Stufe."""
with _LOCK: with _LOCK:
return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()} return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()}
def get_trace(limit: int = 20) -> list[dict]:
"""Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown."""
limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60))
with _LOCK:
return list(_TURNS)[-limit:][::-1]
+105 -42
View File
@@ -1,22 +1,29 @@
""" """
Hält das Agent-Hirn (Rolle `hermes`) dauerhaft warm. Hält das ganze WARM-SET (Hirn + Gedächtnis/embed) dauerhaft warm. Die Augen (vision/VL-30B)
sind seit 07.07. ON-DEMAND (ttl 900, User-Entscheid) und gehören NICHT mehr zum Warm-Set.
Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb
der `brains`-Gruppe, wird die ganze Gruppe (inkl. Hirn) verdrängt. `persist: true` der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur
verhindert nur Idle-Unload, NICHT die Gruppen-Verdrängung; neu vorgewärmt wird sonst erst Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config-
beim nächsten llama-swap-(Re)Start. Dieser Wächter schließt die Lücke: ist die Box idle Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen.
(nichts geladen), pingt er das Hirn vor. Während aktiver Last (irgendetwas geladen) hält Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze
er sich raus, verdrängt also nie ein gerade genutztes Modell. Set über deploy/warmup.sh nach — Hirn UND embed (sonst bliebe embed kalt, live vom
Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich
raus, verdrängt also nie ein gerade genutztes Modell.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL, MC_REWARM_MODEL. warmup.sh deckt korrekt ab: fast über /v1/chat/completions, embed über /v1/embeddings
(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend.
Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle
warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast') + MC_WARMUP_EMBED (Default 'embed').
""" """
import asyncio import asyncio
import logging import logging
import os import os
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__)
@@ -24,54 +31,110 @@ log = logging.getLogger(__name__)
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0" ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25")) START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das
# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen.
_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh"))
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
_loop: asyncio.AbstractEventLoop | None = None
_wake: asyncio.Event | None = None
def _brain_model() -> str: def nudge() -> None:
"""Aktives Agent-Hirn = Hermes' model.default (sonst model.model). So wärmt der Wächter """Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
immer das WIRKLICH genutzte Hirn (passt sich Hirn-Wechseln an). Override: MC_REWARM_MODEL.""" solange der Wächter (noch) nicht läuft."""
forced = os.environ.get("MC_REWARM_MODEL") if _loop is not None and _wake is not None and not _loop.is_closed():
if forced:
return forced
try: try:
from ruamel.yaml import YAML _loop.call_soon_threadsafe(_wake.set)
from config import HERMES_HOME except RuntimeError:
p = HERMES_HOME / "config.yaml" pass
if p.exists():
with p.open(encoding="utf-8") as f:
cfg = YAML().load(f) or {} def _run_warmup() -> bool:
m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {} """Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions,
v = m.get("default") or m.get("model") embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht.
if v: False, wenn das Skript fehlt."""
return str(v) if not os.path.exists(_WARMUP_SH):
log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH)
return False
try:
subprocess.Popen(["bash", _WARMUP_SH],
env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception: except Exception:
log.debug("rewarm: Hirn-Lookup fehlgeschlagen", exc_info=True) log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True)
return "fast" return False
async def _running_empty() -> bool: def _warmset_members() -> set[str]:
"""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:
from services import llamaswap
groups = llamaswap.list_groups() or {}
except Exception:
return set()
want: set[str] = set()
for g in groups.values():
if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")):
want.update(g.get("members") or [])
return want
async def _warmset_missing() -> list[str] | None:
"""Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running
nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der
Fall, der nach einem watch-config-Reload/Deploy auftritt."""
try:
async with httpx.AsyncClient(timeout=8.0) as c: async with httpx.AsyncClient(timeout=8.0) as c:
r = await c.get(f"{LLAMA_SWAP_URL}/running") r = await c.get(f"{LLAMA_SWAP_URL}/running")
data = r.json() or {} data = r.json() or {}
return not (data.get("running") or []) except Exception:
return None
ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"}
async def _warm(model: str) -> None: want = _warmset_members()
async with httpx.AsyncClient(timeout=180.0) as c: if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer
await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ return [] if ready else ["<leer>"]
"model": model, "max_tokens": 1, return [m for m in want if m not in ready]
"messages": [{"role": "user", "content": "ping"}],
})
async def rewarm_loop() -> None: async def rewarm_loop() -> None:
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch, wärmt bei Idle vor.""" """Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein
Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach."""
global _loop, _wake
_loop = asyncio.get_running_loop()
_wake = asyncio.Event()
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
while True: while True:
try: try:
if await _running_empty(): missing = await _warmset_missing()
model = _brain_model() if missing:
log.info("rewarm: Box idle → Hirn '%s' wird vorgewärmt", model) log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing))
await _warm(model) _run_warmup()
except Exception: except Exception:
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True) log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL) # Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
try:
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
_wake.clear()
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)")
_run_warmup()
except asyncio.TimeoutError:
pass
+80
View File
@@ -0,0 +1,80 @@
"""
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
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
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
In-Process-Nudge aus llamaswap.write_config)
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
• memory.auto_dedupe_loop — Gedächtnis-Dubletten (HTTP an den Mem0-Sidecar)
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
Steuerpult. Der Briefkasten-Store gehört weiter EXKLUSIV dem MC2-Prozess — dieser
Prozess liefert Meldungen per HTTP ab (announce.py, MC_ANNOUNCE_HTTP).
Die Loop-Schalter (MC_REWARM_ENABLED / MC_SENTRY_ENABLED / MC_MEM_DEDUPE_ENABLED)
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.
"""
import asyncio
import logging
import os
from config import CONFIG_PATH
from services import memory as memory_svc
from services import sentry, warmer
logging.basicConfig(
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
log = logging.getLogger("steward")
# Wie oft nach einer llama-swap-Config-Änderung geschaut wird (mtime-Watch). Ersetzt
# warmer.nudge() aus dem MC2-Prozess: ein -watch-config-Reload verwirft das Warm-Set,
# wir sehen die Änderung binnen Sekunden statt erst beim nächsten 90s-Tick.
CONFIG_WATCH_S = int(os.environ.get("MC_STEWARD_CONFIG_WATCH_S", "5"))
async def config_watch() -> None:
last: float | None = None
while True:
try:
mtime = CONFIG_PATH.stat().st_mtime
except OSError:
mtime = None
if last is not None and mtime is not None and mtime != last:
log.info("steward: %s geändert → Warm-Set-Nudge", CONFIG_PATH)
warmer.nudge()
last = mtime
await asyncio.sleep(CONFIG_WATCH_S)
async def main() -> None:
tasks: list[asyncio.Task] = []
if warmer.ENABLED:
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
tasks.append(asyncio.create_task(config_watch()))
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
warmer.INTERVAL, CONFIG_WATCH_S)
if sentry.ENABLED:
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:
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
return
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
+41 -1
View File
@@ -51,6 +51,10 @@ async def run_shell(req: dict):
["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
capture_output=True, text=True, timeout=60, capture_output=True, text=True, timeout=60,
encoding="utf-8", errors="replace", encoding="utf-8", errors="replace",
# Der Executor läuft unter pythonw (kein eigenes Konsolenfenster) — ohne dieses
# Flag bekäme JEDE gespawnte powershell ein sichtbares Fenster (beim 10-s-Polling
# der Lucy-Annahme blitzte das im Sekundentakt auf dem Desktop, 12.07.).
creationflags=subprocess.CREATE_NO_WINDOW,
) )
return { return {
"stdout": result.stdout.strip(), "stdout": result.stdout.strip(),
@@ -108,13 +112,49 @@ async def press_keys(req: dict):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
# ── Medien & Lautstärke (A3: Lucy steuert den PC per Sprache) ──────────────
# Media-Keys wirken auf den AKTIVEN Player (Spotify, YouTube, VLC …) — genau wie
# die Tasten auf einer Multimedia-Tastatur. Lautstärke = System-Master (2 % je Schritt).
MEDIA_KEYS = {"play_pause": "playpause", "next": "nexttrack", "prev": "prevtrack", "stop": "stop"}
VOLUME_KEYS = {"up": "volumeup", "down": "volumedown", "mute": "volumemute"}
@app.post("/media", dependencies=[Depends(require_auth)])
async def media_control(req: dict):
action = (req.get("action") or "").strip().lower()
key = MEDIA_KEYS.get(action)
if not key:
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(MEDIA_KEYS)}")
try:
import pyautogui
pyautogui.press(key)
return {"ok": True, "action": action}
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/volume", dependencies=[Depends(require_auth)])
async def volume_control(req: dict):
action = (req.get("action") or "").strip().lower()
key = VOLUME_KEYS.get(action)
if not key:
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(VOLUME_KEYS)}")
steps = 1 if action == "mute" else max(1, min(25, int(req.get("steps", 5))))
try:
import pyautogui
pyautogui.press(key, presses=steps, interval=0.02)
return {"ok": True, "action": action, "steps": steps}
except Exception as e:
raise HTTPException(500, str(e))
# ── Apps / Browser ───────────────────────────────────────────────────────── # ── Apps / Browser ─────────────────────────────────────────────────────────
@app.post("/open", dependencies=[Depends(require_auth)]) @app.post("/open", dependencies=[Depends(require_auth)])
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)
@@ -0,0 +1,76 @@
---
name: projekt-uebernehmen
description: Ein von der AI-Box vorbereitetes Projekt in Zed uebernehmen und starten - Konzept und Roadmap einlesen, die offenen Punkte mit dem Commander klaeren, dann Etappe 1 planen und bauen. Nutze diesen Skill beim ERSTEN Chat in einem Projekt-Ordner, der KONZEPT.md und ROADMAP.md enthaelt, aber noch keinen (oder kaum) eigenen Code - also bei jedem frisch vorbereiteten Repo aus dem Auftragsbuch.
metadata:
quelle: mission-control-2/client/ide-skills
---
# Vorbereitetes Projekt übernehmen
Die Box hat gedacht, du baust. Das Konzept in diesem Ordner ist **bereits gehärtet** — mehrere
Fach-Rollen und ein Advocatus Diaboli haben es auf der Box durchgearbeitet. Deine Aufgabe ist
nicht, es neu zu denken, sondern es umzusetzen.
**Zwei Modelle, ein Ablauf:** Stufe 1 und 2 (einlesen, nachfragen, planen) gehören in den
**Planer**, Stufe 3 (bauen) in den **Coder**. Du kannst nicht selbst umschalten — wenn der Plan
freigegeben ist, bitte den Commander ausdrücklich darum, oben im Modellwähler auf Coder zu
wechseln. Läufst du schon im Coder, ist das auch in Ordnung: dann gilt trotzdem erst lesen,
fragen, Plan zeigen — und **keine Datei vor seinem „los"**.
Drei Stufen, in dieser Reihenfolge. **Vor Stufe 3 wird keine Datei angefasst.**
## 1. Einlesen — still, ohne Rückfragen
Lies, was da ist: `KONZEPT.md`, `ROADMAP.md`, `AGENTS.md`, `SAVEPOINT.md`, `README.md`.
Verschaff dir den Ist-Zustand: `git log --oneline -5` und den Dateibaum.
Zwei Abbruch-Fälle — prüfe sie ZUERST:
- **Es gibt schon Code und Historie.** Dann ist das keine Übernahme, sondern Fortsetzung:
arbeite nach `SAVEPOINT.md` weiter und sag in zwei Sätzen, wo ihr steht. Dieser Skill ist
hier fertig.
- **Nur `KONZEPT.md` + `README.md`, keine `ROADMAP.md`.** Das ist ein *geprüfte-Idee*-Repo, kein
startklares Projekt — die Box legt bei „Idee prüfen" bewusst kein Gerüst an. Sag dem Commander:
*„Das ist bisher nur ein durchdachtes Konzept, noch kein Projekt. Im Auftragsbuch gibt es auf
der Karte den Knopf Gefällt mir — Projekt daraus machen'; danach hat das Repo eine Roadmap und
ich kann loslegen."* Dann **STOPP**, nichts bauen.
Sonst: fasse in **höchstens 8 Zeilen** zusammen — was gebaut wird, für wen, welche Technik,
und was laut Roadmap **Etappe 1** ist. Alltagssprache, keine Fachbegriffe ohne Übersetzung.
## 2. Fragen stellen — der eigentliche Wert dieses Schritts
Stell **3 bis 6 Fragen**. Nicht mehr, und nur solche, die den Start wirklich blockieren.
- **Nichts fragen, was in den Dokumenten steht.** Wer das Konzept nicht gelesen hat, merkt man
genau hier.
- Jede Frage: eine Zeile, Alltagssprache, **mit deiner Empfehlung** („ich würde X nehmen, weil …").
Der Commander ist kein Entwickler — frag nach Zielen, Vorlieben und Gegebenheiten, nie nach
Bau-Prinzipien oder Architektur-Geschmack. Das ist dein Job.
- Die üblichen echten Lücken: Wo läuft es am Ende? Welche Zugänge/Geräte/Pfade brauchst du von
ihm? Was ist in Etappe 1 ausdrücklich **nicht** drin? Woran merkt ihr beide, dass Etappe 1
fertig ist?
- Der Abschnitt „offene Punkte" / „Risiken" im Konzept ist deine Fundgrube — genau dafür steht er da.
Dann **warten**. Keine Annahmen, kein Vorpreschen, kein „ich fange schon mal an".
## 3. Plan → „los" → bauen
1. **Etappe-1-Plan** vorlegen: Ziel · Vorgehen in kleinen Schritten · Technik und warum ·
Fertig-Kriterien · Risiken. Etappe 1 muss **für sich lauffähig** sein — eine halbe Baustelle ist
kein Meilenstein.
2. **STOPP**, auf sein „los" warten. Danach der Satz, den er hören muss: *„Bitte oben im
Modellwähler auf **Coder** umschalten, dann lege ich los."* Umschalten kannst du nicht selbst.
3. Bauen: kleine Schritte, ein Sicherungspunkt (Commit mit klarem Namen) je Meilenstein und vor
riskanten Änderungen. Am Ende **live testen** — beweisen statt behaupten.
4. Nachführen: `SAVEPOINT.md` (Stand, nächster Schritt, offene Fragen) und die erledigte Etappe in
`ROADMAP.md` abhaken. Das ist das Gedächtnis für den nächsten Chat.
## Merksätze
- **Du denkst das Konzept nicht neu.** Findest du einen echten Widerspruch oder eine Lücke:
benenne sie und frag — bau nicht still etwas anderes.
- **Nichts bauen, was nicht in Etappe 1 steht.** Ideen für später gehören in die Roadmap, nicht
in den Code.
- Geheimnisse (Schlüssel, Passwörter, Tokens) nie in den Code — `.env` plus `.gitignore`.
- Klemmt dieselbe Sache zweimal: anhalten, sichern, erklären, fragen.
@@ -0,0 +1,27 @@
# Security Memo: Gitea Registrierung deaktivieren
## Status
**Aktuell:** Registrierung ist OFFEN (Standard-Gitea-Setup)
**Ziel:** `DISABLE_REGISTRATION = true` in `[security]` Abschnitt der app.ini setzen
## Risiko
- **DDNS-Endpunkt** (`git.tobisniceshomelab.ddnsfree.com`) ist öffentlich erreichbar
- **Version im Footer** (1.26.2) identifizierbar — potenziell anfällige Version
- Jeder kann sich ohne Genehmigung registrieren → potenzielle Angreifer/Spammer
## Empfohlene Maßnahme
1. Auf dem Gitea-Server (Proxmox LXC `192.168.178.153`) in `/etc/gitea/app.ini` im Abschnitt `[security]` die Zeile `DISABLE_REGISTRATION = true` hinzufügen
2. Gitea neu starten: `sudo systemctl restart gitea` (oder wie auch der Service heißt)
## Commander-Approval notwendig
- ⚠️ Diese Änderung ist **nur im laufenden Betrieb** auf dem Gitea-Server vorzunehmen
- **Branch `wartung/gitea-disable-registration`** enthält eine Beispielkonfiguration und Dokumentation
- **Bitte bestätige mit "JA", dass du die Änderung freigibst**, damit sie auf dem Server umgesetzt werden kann
## Hinweis
Ohne Commander-Ja: keine Änderung an der Gitea-Instanz vornehmen. Die Sicherheitslücke bleibt bestehen, bis die Freigabe erfolgt.
---
*Erstellt am: 2026-07-17*
*Branch: `wartung/gitea-disable-registration`*
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# pre_llm_call-Hook (Faden 5, 13.07.2026): verdrahtet Box-Selbstwissen MECHANISCH in
# JEDEN Kanban-Worker — egal welches Profil (werkstatt, betrieb, default-als-Worker).
# Der Kontext wird ephemer pro Turn an die User-Message angehaengt (agent/turn_context.py),
# NUR wenn die Session in einem Kanban-Task-Workspace laeuft. Lucys interaktiver/Voice-Chat
# laeuft NICHT dort → sofortiger No-op, kein Persona- oder Latenz-Eingriff. Loest
# Fallstrick #1 (Ops/Mess/Verify-Worker flailen, weil sie llama-swap :8080 nicht kennen).
# Ausgabe {"context":"..."} = anhaengen; {} = durchlassen (No-op).
set -u
payload="$(cat -)"
# Scope-Gate: NUR echte Kanban-Worker — erkannt an der cwd im Task-Workspace.
# ACHTUNG (Bug-Fund Faden 8, 13.07.): Worker tragen als task_id den SESSION-Zeitstempel
# (z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check feuerte also
# NIE fuer Worker. Der zuverlaessige Signal ist die cwd unter kanban/workspaces/; Lucy/
# Voice/CLI laufen nie dort.
cwd=$(printf %s "$payload" | jq -r '.cwd // ""' 2>/dev/null || echo "")
case "$cwd" in
"$HOME/.hermes/kanban/workspaces/"*) : ;; # Kanban-Worker → Orientierung anhaengen
*) printf '{}'; exit 0 ;; # Lucy / Voice / CLI / interaktiv → No-op
esac
# Zeiger auf den vollen, naechtlich generierten Selbst-Steckbrief (nur wenn er existiert).
STECKBRIEF="$HOME/.hermes/state/selbst-steckbrief.md"
hint=""
[ -f "$STECKBRIEF" ] && hint=" Dein vollstaendiger Selbst-Steckbrief (aktuelle Versionen/Dienste/Modelle/Crons/offene Karten) liegt in $STECKBRIEF — dort nachlesen statt raten."
# Stabile Anti-Flail-Orientierung: WER bin ich, WO laufen die Modelle, WAS ist tabu.
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.]
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}
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.
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/.
FRONTEND-BAUEN (seit 15.07. abends): Node LTS liegt sudo-frei unter ~/.local (node/npm im Worker-PATH). Bei Frontend-Aenderungen baust DU das dist MIT: cd frontend && npm ci --no-audit --no-fund && npm run build — und committest frontend/dist im selben Branch (die Box zeigt sonst nach Annahme den alten Stand; Bau-Beweis 15.07.: kompletter Build in ~4 s).
WO WAS LAEUFT (20.07. — zwei getrennte Maschinen, NICHT verwechseln): Du laeufst auf der AI-Box (192.168.178.151) — das ist die WERKBANK (Agent/Lucy/MC2/Modelle), NICHT das Ziel-System fuer Projekt-Deployments. Der Proxmox-Host 'pve' (192.168.178.108) ist ein SEPARATER Rechner; dort laufen die LXC-Container. Von hier per 'ssh pve' erreichbar (root, Key), Steuerung mit 'pct'. Welche Container es gerade gibt, schaust du bei Bedarf mit 'ssh pve pct list' nach — nicht raten, nicht auswendig annehmen. Soll etwas dauerhaft laufen, gehoert es in einen LXC auf pve, nicht auf die Box.
REPO FEHLT? (20.07.): Soll dein Ergebnis in ein Gitea-Repo, das noch NICHT existiert (eigenes/neues Projekt), dann lege es SELBST an — NICHT den Commander fragen, NICHT git init: bash ~/.hermes/scripts/gitea-repo-create.sh <name> "<kurze Beschreibung>" (immer privat, letzte Ausgabezeile = CLONE <url>), dann voll klonen und darin arbeiten. NUR mission-control-v2 und das Lucy-Repo NIE neu anlegen/veraendern.
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07./20.07. — Worker starben an finish_reason=length UND an vollgelaufenem Kontext): Dein Kontext ist ENDLICH und fuellt sich vor allem durch GROSSE Datei- und Terminal-Ausgaben. Lies NIE eine grosse Datei am Stueck — nutze grep und Zeilenbereiche (read mit offset/limit); Datei-Reads sind auf 50k Zeichen gedeckelt, aber auch viele mittlere summieren sich. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage.
EOF
)"
jq --null-input --arg c "$ctx" '{context:$c}'
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""No-Progress-Bremse (Phase 2 des Drei-Welten-Plans, 19.07.2026).
Generische, turn-UEBERGREIFENDE Schleifenbremse — ergaenzt Hermes' native
tool_loop_guardrails, die (a) pro Turn zaehlen und (b) nur als Fehler
klassifizierte Ergebnisse sehen. Die teuer gelernte Luecke (SSH-Marathon 18.07.,
36 min / 75 Schleifen): ein Befehl laeuft "erfolgreich" durch, die AUSGABE sagt
aber immer dasselbe ("Permission denied") — der Agent variiert Befehle gegen
eine unsichtbare Wand, ueber Turn-Grenzen hinweg, und nichts stoppt ihn.
Prinzip (KEINE hartkodierten Fehler-Strings — Leitplanke des Commanders:
"wenn sich was aendert, muss es das SELBST erkennen"):
post_tool_call → Ergebnis normalisieren (Zahlen/Hex-IDs raus) → Muster-Hash.
Gleicher Hash beim gleichen Tool in Folge = Zaehler hoch.
Unbekannte Muster landen in neue-signaturen.jsonl —
Futter fuer Traum/Radar, um daraus Playbooks zu verdichten.
pre_tool_call → Zaehler >= 3 fuer dieses Tool: EINMAL mechanisch blocken
(Diagnose-Anweisung statt naechster Blindversuch), Zaehler
zuruecksetzen. Der Folgeversuch ist frei; laeuft er wieder
3x gegen dasselbe Muster, blockt es erneut.
Gezaehlt wird nur, wo Wiederholung wirklich Stillstand heisst: terminal IMMER
(dort verstecken sich die "erfolgreichen" Fehler), andere Tools nur bei
status=error. Lese-Tools deckt die native idempotent-Bremse ab.
Aufruf (hooks in config.yaml): `no-progress-bremse.py pre` bzw. `... post`.
State: ~/.hermes/state/no-progress/<session>.json (48 h Selbst-Aufraeumen).
Fail-open: jeder eigene Fehler -> {} (die Bremse darf nie selbst zur Wand werden).
"""
import hashlib
import json
import os
import re
import sys
import time
STATE_DIR = os.path.expanduser("~/.hermes/state/no-progress")
SIG_LOG = os.path.join(STATE_DIR, "neue-signaturen.jsonl")
SEEN_FILE = os.path.join(STATE_DIR, "seen-sigs.json")
THRESHOLD = 3
STATE_TTL = 48 * 3600
BLOCK_REASON = (
"NO-PROGRESS-BREMSE: '{tool}' hat {n}x in Folge dasselbe Ergebnismuster geliefert — "
"das ist Stillstand, egal wie sehr die Befehle variieren. STOPP. "
"WICHTIG: Dein Befehl wurde NICHT ausgefuehrt. Das hier ist eine Bremse, KEIN Ergebnis — "
"schliesse daraus NICHTS ueber die Existenz oder den Zustand von Dateien, Verzeichnissen "
"oder Diensten (Vorfall 21.07.2026: ein Worker hielt diese Meldung fuer 'Verzeichnis nicht "
"vorhanden' und baute eine halbe Stunde Arbeit neu). "
"1) Benenne das Hindernis in EINEM Satz (was genau meldet das Ergebnis?). "
"2) Diagnose statt Variation — bei SSH/Zugriff ZUERST: Schluessel-Passphrase pruefen "
"(`ssh-keygen -y -f <key> </dev/null` — stilles 'Permission denied' = verschluesselter Key), "
"dann Gegenseite (Auth-Log, Dienststatus, Erreichbarkeit) LESEN statt raten. "
"3) Bleibt es unklar: Aufgabe als blocked/Karte mit dem EXAKTEN Ergebnistext an den "
"Commander uebergeben. Dein naechster Versuch ist frei — nutze ihn erst NACH der Diagnose."
)
# Rein LESENDE Befehle nie bremsen (21.07.2026). Die Bremse verlangt selbst „Diagnose
# statt Variation" — genau die besteht aus vielen kurzen Schau-Befehlen (ls/cat/git log),
# deren Ergebnisse sich stark aehneln. Die Signatur hielt das fuer Stillstand und blockte
# den naechsten Blick; der Worker las die Block-Meldung als „da ist nichts" und baute
# alles neu (Karte t_d26c3203, eine halbe Stunde Arbeit weggeworfen). Geschaut wird also
# ungebremst — gebremst wird nur, wer immer wieder dasselbe TUT.
# Bewusst strukturell (Befehls-Art), nicht per Fehlertext-Liste: die Bremse soll generisch
# bleiben und Neues selbst erkennen.
_LESEND = {"ls", "cat", "head", "tail", "wc", "stat", "find", "grep", "rg", "file",
"du", "df", "echo", "pwd", "tree", "readlink", "basename", "dirname", "which"}
_GIT_LESEND = {"status", "log", "show", "diff", "ls-remote", "ls-files", "rev-parse",
"merge-base", "describe"}
def _nur_lesend(cmd: str) -> bool:
"""True, wenn der Befehl ausschliesslich schaut — kein Schreiben, kein Eingriff."""
cmd = (cmd or "").strip()
if not cmd or ">" in cmd: # jede Umleitung = schreiben
return False
for teil in re.split(r"&&|\|\||;|\|", cmd):
worte = teil.split()
i = 0
while i < len(worte) and "=" in worte[i] and not worte[i].startswith("-"):
i += 1 # VAR=wert-Praefixe ueberspringen
if i >= len(worte):
return False
verb = os.path.basename(worte[i])
if verb == "git":
rest = [w for w in worte[i + 1:] if not w.startswith("-")]
# `git log …` oder `git -C <pfad> log …` → erstes oder zweites Wort zaehlt
if not (rest[:1] and rest[0] in _GIT_LESEND) and \
not (rest[1:2] and rest[1] in _GIT_LESEND):
return False
elif verb not in _LESEND:
return False
return True
_NUM_RE = re.compile(r"\d+")
_HEX_RE = re.compile(r"\b[0-9a-f]{8,}\b")
_WS_RE = re.compile(r"\s+")
def _norm_sig(tool: str, text: str) -> str:
t = (text or "")[:2000].lower()
t = _HEX_RE.sub("#", _NUM_RE.sub("#", t))
t = _WS_RE.sub(" ", t).strip()[:400]
return hashlib.sha1(f"{tool}|{t}".encode()).hexdigest()[:16]
def _session_key(p: dict) -> str:
sid = p.get("session_id") or p.get("parent_session_id") or ""
if sid:
return re.sub(r"[^A-Za-z0-9_.-]", "_", str(sid))[:80]
return "cwd-" + hashlib.sha1(os.getcwd().encode()).hexdigest()[:12]
def _prune() -> None:
now = time.time()
for f in os.listdir(STATE_DIR):
fp = os.path.join(STATE_DIR, f)
if f.endswith(".json") and f != os.path.basename(SEEN_FILE):
if now - os.path.getmtime(fp) > STATE_TTL:
os.remove(fp)
def _load(path: str) -> dict:
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except Exception:
return {}
def _save(path: str, data: dict) -> None:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh)
os.replace(tmp, path)
def main() -> None:
mode = sys.argv[1] if len(sys.argv) > 1 else "post"
payload = json.load(sys.stdin)
tool = str(payload.get("tool_name") or "")
if not tool:
print("{}")
return
# Delegations-Ausnahme (20.07.2026): worker.sh / fremdblick.sh sind der
# Fliessband-/Orchestrator-Motor — konzept-fliessband ruft sie pro Runde/Rolle
# WIEDERHOLT auf (verschiedene Prompts, aehnliche Prosa-Ergebnisse). Die Bremse
# bildet ihre Signatur aus dem Ergebnis-Text → sie hielt diese legitimen
# Mehrrunden faelschlich fuer eine Schleife und blockte (Pomodoro-IDE-Test lief
# 19 min dagegen an). Diese Helfer haben ihr EIGENES Runden-Limit (max 3) →
# von der generischen Bremse ausnehmen.
_ti = payload.get("tool_input")
_cmd = _ti.get("command") if isinstance(_ti, dict) else (_ti if isinstance(_ti, str) else "")
if tool == "terminal" and ("worker.sh" in str(_cmd) or "fremdblick.sh" in str(_cmd)):
print("{}")
return
# Schauen ist keine Schleife (siehe _nur_lesend): weder zaehlen noch blocken.
if tool == "terminal" and _nur_lesend(str(_cmd)):
print("{}")
return
os.makedirs(STATE_DIR, exist_ok=True)
sfile = os.path.join(STATE_DIR, _session_key(payload) + ".json")
state = _load(sfile)
tools = state.setdefault("tools", {})
if mode == "post":
status = str(payload.get("status") or "ok")
# terminal immer beobachten (dort tarnen sich Fehler als Erfolg),
# sonst nur echte Fehler — Lese-Tools regelt die native Bremse.
if tool != "terminal" and status != "error":
print("{}")
return
sig = _norm_sig(tool, str(payload.get("result") or ""))
entry = tools.get(tool) or {}
entry["count"] = entry.get("count", 0) + 1 if entry.get("sig") == sig else 1
entry["sig"] = sig
tools[tool] = entry
_save(sfile, state)
_prune()
seen = _load(SEEN_FILE)
sigs = seen.setdefault("sigs", [])
if sig not in sigs and status == "error" or (tool == "terminal" and sig not in sigs and entry["count"] >= 2):
sigs.append(sig)
del sigs[:-500]
_save(SEEN_FILE, seen)
sample = _WS_RE.sub(" ", str(payload.get("result") or ""))[:200]
with open(SIG_LOG, "a", encoding="utf-8") as fh:
fh.write(json.dumps({"ts": int(time.time()), "tool": tool,
"sig": sig, "sample": sample}, ensure_ascii=False) + "\n")
print("{}")
return
# mode == "pre"
entry = tools.get(tool) or {}
if entry.get("count", 0) >= THRESHOLD:
entry["count"] = 0
tools[tool] = entry
_save(sfile, state)
print(json.dumps({"decision": "block",
"reason": BLOCK_REASON.format(tool=tool, n=THRESHOLD)},
ensure_ascii=False))
return
print("{}")
if __name__ == "__main__":
try:
main()
except Exception:
print("{}")
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# pre_verify-Gate (P1-5, 09.07.2026): erinnert den Agenten VOR dem Fertigmelden an
# die zwei MC2-Pflichten aus AGENTS.md — py_compile-Gate und die dist-Falle.
# Antwort {"action":"continue","message":...} = Agent arbeitet weiter; {} = durchlassen.
set -u
payload="$(cat -)"
attempt=$(printf %s "$payload" | jq -r '.extra.attempt // 0' 2>/dev/null || echo 0)
# Ein-Schuss: nach dem ersten Nudge nicht erneut nerven (Docs-Empfehlung).
if [ "$attempt" != "0" ]; then printf '{}'; exit 0; fi
mapfile -t paths < <(printf %s "$payload" | jq -r '.extra.changed_paths[]? // empty' 2>/dev/null)
[ ${#paths[@]} -eq 0 ] && { printf '{}'; exit 0; }
msgs=()
# Gate 1: py_compile fuer alle geaenderten .py, die existieren
pyfail=""
for p in "${paths[@]}"; do
case "$p" in
*.py)
if [ -f "$p" ] && ! err=$(python3 -m py_compile "$p" 2>&1); then
pyfail="${pyfail}${p}: ${err}"$'\n'
fi
;;
esac
done
if [ -n "$pyfail" ]; then
msgs+=("py_compile-Gate ROT:"$'\n'"${pyfail}Erst fixen, dann fertigmelden.")
fi
# Gate 2: dist-Falle — frontend/src geaendert, aber kein frontend/dist angefasst.
# Feuert NUR in Repos mit der MC2-Konvention (frontend/dist ist git-getrackt) —
# sonst nudgt es in beliebigen Spielordnern ohne Build (False-Positive, 09.07. beobachtet).
srcfile=""; dist=0
for p in "${paths[@]}"; do
case "$p" in
*/frontend/src/*|frontend/src/*) srcfile="$p" ;;
*/frontend/dist/*|frontend/dist/*) dist=1 ;;
esac
done
if [ -n "$srcfile" ] && [ $dist -eq 0 ]; then
# Repo-Wurzel vom geaenderten File aus hochlaufen (max 8 Ebenen).
root=$(dirname "$srcfile"); n=0
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
root=$(dirname "$root"); n=$((n+1))
done
if [ -e "$root/.git" ] && git -C "$root" ls-files frontend/dist 2>/dev/null | head -1 | grep -q .; then
msgs+=("AGENTS.md-Gate: Du hast frontend/src geaendert, aber frontend/dist nicht. Die Box baut NICHT selbst: cd frontend && npm run build, dann das neue dist im selben Branch mit-committen — sonst zeigt die Box nach Merge+Deploy den alten Stand.")
fi
fi
# Gate 3 (R5, 15.07.): Orphan-Check — git-init-Repos ohne gemeinsamen Ursprung mit
# origin/main sind bei der Annahme technisch tot (3 Vorfaelle 12.14.07., je eine
# Nacht Arbeit verloren). VOR dem Fertigmelden fangen, nicht erst im Auftragsbuch.
orphan=""; checked=""
for p in "${paths[@]}"; do
root=$(dirname "$p"); n=0
while [ $n -lt 8 ] && [ -n "$root" ] && [ "$root" != "/" ] && [ ! -e "$root/.git" ]; do
root=$(dirname "$root"); n=$((n+1))
done
[ -e "$root/.git" ] || continue
case " $checked " in *" $root "*) continue ;; esac
checked="$checked $root"
git -C "$root" remote get-url origin >/dev/null 2>&1 || continue
git -C "$root" fetch -q origin main >/dev/null 2>&1 || true
if git -C "$root" rev-parse --verify -q origin/main >/dev/null 2>&1 \
&& ! git -C "$root" merge-base HEAD origin/main >/dev/null 2>&1; then
orphan="${orphan}${root}"$'\n'
fi
done
if [ -n "$orphan" ]; then
msgs+=("Orphan-Gate ROT: Dieses Repo hat KEINEN gemeinsamen Ursprung mit origin/main (git init statt Klonen?):"$'\n'"${orphan}So ist der Vorschlag bei der Annahme technisch TOT. Nicht fertigmelden — frisch VOLL klonen, Branch von origin/main abzweigen, Aenderungen ruebertragen.")
fi
if [ ${#msgs[@]} -eq 0 ]; then printf '{}'; exit 0; fi
joined=$(printf '%s\n\n' "${msgs[@]}")
jq --null-input --arg m "$joined" '{action:"continue", message:$m}'
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""pre_tool_call-Guard (Faden 8, 13.07.2026): sperrt Schreibzugriffe auf den LIVE-Checkout
~/mission-control-v2 und die Hermes-QUELLE ~/.hermes/hermes-agent HART. Loest Fallstrick #2
(Worker wandert in die falsche Ebene / lag Patches im Live-Checkout ab). Die native
is_write_denied deckt diese Pfade NICHT ab (nur Credentials/System) -> diese Luecke.
UNBEDINGT (kein Task-/Profil-Scope): KEIN legitimer Hermes-Agent-Flow schreibt je in diese
zwei Pfade — der Werkstatt-Worker arbeitet im WORKSPACE-Klon (<workspace>/mission-control-v2,
NICHT home-verankert -> erlaubt), Lucy fasst MC2-Quelle nie an, und die Wartungs-Pipeline
(deploy.sh, Annahme-Runner) laeuft als Shell ueber ssh, NICHT ueber Hermes-Tools. Ein
Task-Scope waere hier sogar gefaehrlich: Worker tragen als task_id den Session-Zeitstempel
(z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check liesse also alle
Worker-Schreibzugriffe durch (Bug 13.07., so entdeckt).
WICHTIG: Gesperrt wird der home-verankerte Live-Checkout ($HOME/mission-control-v2); der
blosse Name "mission-control-v2" zaehlt NUR, wenn er vom cwd aus in einen Tabu-Pfad aufloest
(V16 unten) — der Workspace-Klon (~/.hermes/kanban/workspaces/<id>/mission-control-v2) bleibt
frei, sonst waere die legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden
nur Schreib-Operationen.
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
"""
import json
import os
import re
import sys
def out(obj):
sys.stdout.write(json.dumps(obj))
sys.exit(0)
try:
payload = json.loads(sys.stdin.read() or "{}")
except Exception:
out({})
tool = payload.get("tool_name") or ""
ti = payload.get("tool_input") or {}
home = os.path.realpath(os.path.expanduser("~"))
LIVE = os.path.join(home, "mission-control-v2")
SRC = os.path.join(home, ".hermes", "hermes-agent")
# R2 (Review 15.07.): systemd-User-Units. Die Nacht-Werkstatt hat am 14.07. einen Override
# DIREKT scharf geschaltet — ging gut, verletzt aber "jede Code-Zeile ist Klick-pflichtig".
# Unit-/Override-Dateien werden nur noch ueber eine angenommene Karte installiert.
SYSD = os.path.join(home, ".config", "systemd", "user")
MSG = (
"TABU (Faden 8/R2): Schreiben an dieser Stelle ist fuer Worker gesperrt. Der Live-Checkout "
"~/mission-control-v2, die Hermes-Quelle ~/.hermes/hermes-agent und die systemd-User-Units "
"~/.config/systemd/user duerfen NIE direkt veraendert werden. Arbeite im FRISCHEN Klon "
"deines Task-Workspaces; MC2-Aenderungen kommen als Vorschlags-Branch, systemd-Unit-/"
"Override-Dateien werden nur ueber eine angenommene Karte installiert, Hermes-Verhalten "
"nur ueber Config/SOUL/Skill/Hook/MCP. Siehe docs/wissen/GRENZEN.md."
)
def block():
out({"action": "block", "message": MSG})
def under_tabu(path):
rp = os.path.realpath(path)
return any(rp == t or rp.startswith(t + os.sep) for t in (LIVE, SRC, SYSD))
# --- Datei-Tools: Zielpfad kanonisch pruefen (zuverlaessig) -------------------------
if tool in ("write_file", "patch"):
fp = ti.get("path") or ti.get("file_path") or ti.get("filepath") or ""
if not fp:
out({})
fp = os.path.expanduser(fp)
cwd = payload.get("cwd") or ""
if not os.path.isabs(fp) and cwd:
fp = os.path.join(cwd, fp)
if under_tabu(fp):
block()
out({})
# --- Terminal: nur bei Schreib-Operation, die auf einen Tabu-Pfad zielt -------------
if tool == "terminal":
cmd = ti.get("command") or ""
if not cmd:
out({})
cwd_raw = payload.get("cwd") or ""
cwd = os.path.realpath(os.path.expanduser(cwd_raw)) if cwd_raw else ""
# R5 (Review 15.07.): `git init` im Kanban-Workspace hart blocken. Drei Naechte in
# Folge bauten Worker git-init-Orphans ohne gemeinsamen Ursprung mit main
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
# ~14.07.) — solche Vorschlaege sind bei der Annahme technisch tot, die Nacht-Arbeit
# verpufft. Kein legitimer Worker-Flow braucht git init; MC2-/Lucy-Arbeit = VOLL
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
# projekt-start neue Repos initialisieren).
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
if cwd_raw.startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
out({"action": "block", "message": (
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
"12.14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
"http://192.168.178.153:3000/Hitonabi/mission-control-v2 "
"(Credentials liegen in ~/.git-credentials), dann Branch von origin/main abzweigen.")})
# Schreib-Subkommandos von git (V16: + clone — `git clone URL ~/mission-control-v2`
# rutschte vorher an (a) UND (d) vorbei).
git_write = (
r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*"
r"(commit|push|reset|checkout|switch|merge|add|rm|mv|clean|stash|rebase|apply|restore|init|clone|remote|config)\b"
)
# V16 (Vorfall 16.07.2026): Werkstatt-Worker t_7a05992a ersetzte um 03:59 den Live-
# Checkout mit cwd=$HOME und RELATIVEN Pfaden (`rm -rf mission-control-v2 && git
# clone ...`) — keine home-verankerte Tabu-Form im Kommandotext, der Guard sah nur
# Strings und liess durch (.venv weg -> Gateway im 203/EXEC-Crash-Loop). Drei Stopfen:
# (1) cwd liegt IN einem Tabu-Pfad -> jede Schreib-Operation blocken (relative Ziele
# treffen zwangslaeufig den Tabu-Baum). Lesen bleibt erlaubt.
if cwd and under_tabu(cwd):
if (re.search(git_write, cmd) or re.search(r"(?:^|[^\w])sed\s+-i", cmd)
or re.search(r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s", cmd)
or re.search(r">>?\s*(?![/&])", cmd)
or re.search(r"(?:^|[^\w])tee\s+(?:-a\s+)?(?!/)", cmd)):
block()
# (2) cwd == $HOME -> `git clone`/`git init`, deren Ziel direkt in $HOME landet,
# blocken (exakt der Vorfalls-Hergang). Worker klonen im Task-Workspace; ein
# explizites Ziel AUSSERHALB von $HOME (z.B. Unterordner) bleibt erlaubt.
m = re.search(r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*(?:clone|init)\b([^|&;]*)", cmd)
if cwd == home and m:
toks = m.group(1).split()
dest = toks[-1] if toks else ""
# kein brauchbares Ziel-Argument (leer, Option oder URL) -> implizit direkt in $HOME
implicit = (not dest or dest.startswith("-")
or re.search(r"://|^git@|^[\w.-]+@[\w.-]+:|\.git$", dest))
if (implicit or under_tabu(os.path.join(home, dest))
or os.path.dirname(os.path.realpath(os.path.join(home, dest))) == home):
out({"action": "block", "message": (
"TABU (V16): `git clone`/`git init` direkt in $HOME ist gesperrt — so wurde "
"am 16.07. der Live-Checkout ~/mission-control-v2 durch einen frischen Klon "
"ersetzt (.venv weg, Gateway tot). Klone/initialisiere in deinem Task-"
"Workspace (~/.hermes/kanban/workspaces/<task>/...) oder gib ein Ziel "
"ausserhalb von $HOME an.")})
# Home-verankerte Tabu-Formen. Der Workspace-Klon
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
tabu_forms = [
LIVE, "~/mission-control-v2", "$HOME/mission-control-v2", "${HOME}/mission-control-v2",
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
]
# (3) cwd == Vorfahr eines Tabu-Pfads (z.B. $HOME) -> auch die RELATIVE Nennung des
# Tabu-Ziels (mission-control-v2, .hermes/hermes-agent, .config/systemd/user)
# zaehlt als Treffer fuer die Schreib-Checks unten. Die Lookarounds stellen
# sicher, dass der Name ein eigenes Argument beginnt — als Teil eines laengeren
# Pfades (z.B. .hermes/kanban/workspaces/<id>/mission-control-v2) matcht er
# NICHT, die legitime Workspace-Arbeit bleibt frei.
rel_pats = []
if cwd:
for t in (LIVE, SRC, SYSD):
if t.startswith(cwd + os.sep):
rel = os.path.relpath(t, cwd).replace(os.sep, "/")
rel_pats.append(r"(?<![\w./@-])(?:\./)?" + re.escape(rel) + r"(?![\w.-])")
tabu_alt = "(?:" + "|".join([re.escape(t) for t in tabu_forms] + rel_pats) + ")"
if not re.search(tabu_alt, cmd):
out({}) # kein Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
if re.search(git_write, cmd):
block()
# (b) sed -i, das einen Tabu-Pfad anfasst.
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
block()
# (c) Umleitung > / >> / tee, die auf einen Tabu-Pfad ZIELT (nicht 2>/dev/null o.ae.).
if re.search(r"(?:>>?|(?:^|[^\w])tee\s+(?:-a\s+)?)\s*" + tabu_alt, cmd):
block()
# (d) destruktiv/kopierend mit Tabu-Pfad im selben Kommando-Segment.
if re.search(
r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s+[^|&;]*" + tabu_alt,
cmd,
):
block()
out({})
out({})
+76
View File
@@ -0,0 +1,76 @@
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
# Gitea-Actions-Runner (arcane-VM).
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
shell: bash
run: |
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
set -u +e
rot=0
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
exit 0
fi
if [ -n "$py_datei" ]; then
echo "== Python erkannt =="
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
ruff check . || rot=1
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
while IFS= read -r req; do
[ -n "$req" ] || continue
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
python -m compileall -q . || rot=1
echo "-- Pytest (keine Tests gefunden = ROT)"
ec=0; pytest -q || ec=$?
if [ $ec -eq 5 ]; then
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
rot=1
elif [ $ec -ne 0 ]; then
rot=1
fi
fi
if [ -n "$pkg_dateien" ]; then
echo "== Node/TypeScript erkannt =="
while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
d=$(dirname "$pkg")
echo "-- $d"
if [ ! -f "$d/package-lock.json" ]; then
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
rot=1; continue
fi
(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 '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
done <<< "$pkg_dateien"
fi
if [ $rot -ne 0 ]; then
echo "❌ AMPEL ROT — nichts heißt fertig', solange das rot ist."
fi
exit $rot
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
set -uo pipefail
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
[ -n "$TOKEN" ] || exit 0
python3 - "$TOKEN" "$STATE" <<'PY'
import json, sys, urllib.request, subprocess, os
token, state_pfad = sys.argv[1], sys.argv[2]
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
BASE = "http://192.168.178.153:3000/api/v1"
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
def api(pfad):
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)
try:
gesehen = set(open(state_pfad).read().split())
except OSError:
gesehen = set()
neu = []
try:
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
repos = api("/repos/search?limit=50&private=true").get("data", [])
except Exception as e:
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
sys.exit(0)
for repo in repos:
full = repo.get("full_name", "")
if not full or repo.get("archived"):
continue
try:
d = api(f"/repos/{full}/actions/tasks?limit=1")
except Exception:
continue
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
for t in rows[:1]:
kennung = f"{full}#{t.get('id')}"
status = t.get("status")
if status == "failure" and kennung not in gesehen:
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
try:
req = urllib.request.Request(
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=20) as r:
log_ende = r.read().decode(errors="replace")[-4000:]
wirklich_rot = "Job succeeded" not in log_ende
except Exception:
wirklich_rot = True
if wirklich_rot:
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
# darf nicht schon als gesehen gelten.
if status in ("failure", "success", "cancelled", "skipped"):
gesehen.add(kennung)
if neu:
print("🔴 CI-AMPEL ROT:")
for full, sha, name in neu:
print(f"• {full} @ {sha} ({name})")
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.")
with open(state_pfad, "w") as f:
f.write("\n".join(sorted(gesehen)))
PY
+162
View File
@@ -0,0 +1,162 @@
#!/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
+17 -3
View File
@@ -126,8 +126,20 @@ check_hermes(){
notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft." notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft."
return return
fi fi
# Rot/Timeout ⇒ Rollback: Code auf alten Stand, Config aus dem frischen Backup, Neustart, Gehirn-Check. # Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) —
say "$name: Job endete '$state' — ROLLBACK auf $old_head" # Autonomie 0g. Nur Config, reversibel, hart gegatet; scheitert es, fällt es sauber in den Rollback.
say "$name: Job endete '$state' — versuche Selbstreparatur (Config) vor dem Rollback…"
local sr_out sr_rc sugg=""
sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$?
echo "$sr_out"
if [ "$sr_rc" -eq 0 ]; then
SUMMARY+=("$name: Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback).")
return
fi
sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)"
# Rollback: Code auf alten Stand, Config aus dem frischen Backup, Neustart, Gehirn-Check.
say "$name: Selbstreparatur griff nicht — ROLLBACK auf $old_head"
if [ -n "$old_head" ]; then if [ -n "$old_head" ]; then
git -C "$HERMES_DIR" reset --hard "$old_head" >/dev/null 2>&1 git -C "$HERMES_DIR" reset --hard "$old_head" >/dev/null 2>&1
local bak stage local bak stage
@@ -143,7 +155,9 @@ check_hermes(){
if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then
set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün" set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün"
SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.") SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.")
notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT." notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+
Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
return return
fi fi
fi fi
+80 -2
View File
@@ -4,15 +4,22 @@
# 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: mem0 (Chroma + history.db) · ~/.hermes (config.yaml, .env, plugins/) ·
# /etc/llama-swap/config.yaml # /etc/llama-swap/config.yaml · known-good/ (Versions-Manifest, s. u.)
# 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.
#
# Off-Box (C12): nach dem lokalen Tarball wird der Backup-Ordner per rsync auf ein
# ZWEITES Gerät gespiegelt (Default: Proxmox-Host), damit ein Plattenausfall der Box
# nicht auch die Backups mitnimmt. Fehlschlag ist NICHT fatal — das lokale Backup gilt.
set -euo pipefail set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}" MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
DEST_DIR="$MODELS_DIR/mc2-backups" DEST_DIR="$MODELS_DIR/mc2-backups"
RETAIN="${MC_BACKUP_RETAIN:-14}" RETAIN="${MC_BACKUP_RETAIN:-14}"
# 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_KEY="${MC_BACKUP_OFFSITE_KEY:-$HOME/.ssh/mc2_offsite}"
MEM0_DIR="${MC_MEM0_DIR:-/srv/models/mem0}" 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}"
@@ -27,12 +34,65 @@ mkdir -p "$STAGE/mem0" "$STAGE/hermes" "$STAGE/llama-swap"
[ -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
[ -f "$LSWAP" ] && cp -a "$LSWAP" "$STAGE/llama-swap/" || true [ -f "$LSWAP" ] && cp -a "$LSWAP" "$STAGE/llama-swap/" || true
# Hermes-Desktop-/Gate-Infrastruktur (09.07.2026) — ohne sie verliert ein Restore die
# Desktop-Verbindung und die pre_verify-Gates STILL (config.yaml referenziert die Hooks):
[ -d "$HERMES/agent-hooks" ] && cp -a "$HERMES/agent-hooks" "$STAGE/hermes/agent-hooks" || true
[ -f "$HERMES/shell-hooks-allowlist.json" ] && cp -a "$HERMES/shell-hooks-allowlist.json" "$STAGE/hermes/" || true
[ -f "$HERMES/desktop-gateway-token" ] && cp -a "$HERMES/desktop-gateway-token" "$STAGE/hermes/" || true
[ -f "$HERMES/pc-paths.yaml" ] && cp -a "$HERMES/pc-paths.yaml" "$STAGE/hermes/" || true
_TOKDROPIN="$HOME/.config/systemd/user/hermes-builtin-ui.service.d/session-token.conf"
[ -f "$_TOKDROPIN" ] && { mkdir -p "$STAGE/hermes/systemd-dropins"; cp -a "$_TOKDROPIN" "$STAGE/hermes/systemd-dropins/"; } || true
# Cron-Jobs + Radar-State (10.07.2026) — jobs.json liegt AUSSERHALB des Repos; ohne diese
# Zeilen verliert ein Restore ALLE Hermes-Crons (traum/Briefing/Monats-Review/Chef-Gutachter/
# Release-Radar) still:
[ -d "$HERMES/cron" ] && cp -a "$HERMES/cron" "$STAGE/hermes/cron" || true
[ -d "$HERMES/state" ] && cp -a "$HERMES/state" "$STAGE/hermes/state" || true
# --- Known-Good-Manifest -----------------------------------------------------
# Versions-Schnappschuss des LAUFENDEN Stacks: pip freeze je venv, Engine-Build,
# Git-Stände, Modell-Liste. Zweck: die requirements sind teils ungepinnt (>=) —
# bei einer Neuinstallation sagen DIESE Listen, welche Versionen nachweislich
# zusammen liefen. Rein informativ, restore.sh fasst es nicht an.
# Alles best-effort: ein fehlendes venv/Tool darf das Backup nie abbrechen.
KG="$STAGE/known-good"
mkdir -p "$KG"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
HERMES_AGENT="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}"
UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
# pip freeze je venv; mem0-venv ist uv-managed (kein pip-Modul) → uv als Fallback.
freeze_venv() { # $1 = python-Pfad, $2 = Zieldatei
if [ ! -x "$1" ]; then echo "venv fehlt: $1" > "$2"; return 0; fi
"$1" -m pip freeze > "$2" 2>/dev/null \
|| "$UV" pip freeze --python "$1" > "$2" 2>/dev/null \
|| echo "pip freeze fehlgeschlagen: $1" > "$2"
}
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"
{
echo "known-good-Stand vom $TS auf $(hostname)"
echo "os : $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-?}") · kernel $(uname -r)"
echo "mc2 : $(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo '?') ($(git -C "$SRC" log -1 --format=%cs 2>/dev/null || echo '?'))"
echo "hermes-agent : $(git -C "$HERMES_AGENT" rev-parse --short HEAD 2>/dev/null || 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 "python-venvs :"
for P in "$SRC/backend/.venv/bin/python" "$HOME/.mem0/venv/bin/python" "$HOME/.voice/venv/bin/python"; do
echo " $P$("$P" --version 2>&1 || echo 'fehlt')"
done
} > "$KG/versions.txt" || true
# Modell-Bestand (Namen + Größen): welche GGUF/mmproj-Dateien liefen zusammen.
find "$MODELS_DIR" -maxdepth 3 \( -name '*.gguf' -o -name '*mmproj*' \) -print0 2>/dev/null \
| xargs -r -0 du -h 2>/dev/null | sort -k2 > "$KG/models.txt" || true
cat > "$STAGE/MANIFEST.txt" <<EOF 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) inhalt : mem0 (chroma + history.db), 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
@@ -45,3 +105,21 @@ chmod 600 "$OUT" # enthält .env (Secrets)
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1)) | xargs -r rm -f ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1)) | xargs -r rm -f
echo "OK — Backup: $OUT ($(du -h "$OUT" | cut -f1))" echo "OK — Backup: $OUT ($(du -h "$OUT" | cut -f1))"
# --- Off-Box-Spiegel (C12) -------------------------------------------------
# Spiegelt die Tarballs 1:1 aufs Zweitgerät; --delete zieht die Retention mit,
# sodass die Off-Box-Kopie nicht unbegrenzt wächst. Perms (600) bleiben erhalten.
if [ -n "$OFFSITE" ]; then
if [ -f "$OFFSITE_KEY" ]; then
if rsync -a \
-e "ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" \
--include='mc2-state-*.tar.gz' --exclude='*' --delete \
"$DEST_DIR/" "$OFFSITE/"; then
echo "OK — Off-Box-Kopie gespiegelt → $OFFSITE"
else
echo "WARN — Off-Box-Sync fehlgeschlagen (lokales Backup ist gültig): $OFFSITE" >&2
fi
else
echo "WARN — Off-Box-Key fehlt ($OFFSITE_KEY) → nur lokales Backup." >&2
fi
fi
+72
View File
@@ -0,0 +1,72 @@
#!/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."
+59
View File
@@ -0,0 +1,59 @@
# Betriebs-/Mess-Worker (Profil „betrieb")
Du bist der Betriebs- und Mess-Worker der AI-Box. Du arbeitest EINE Kanban-Aufgabe ab —
Ops, Benchmarks, Messungen, Health-/Diagnose-Checks, Verifikation von Behauptungen auf der
LEBENDEN Box (Dienste, Modelle, Latenzen, Logs, Ressourcen). Keine Persona, kein Smalltalk.
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
du reparierst nicht.**
## Schritt 0: WIEDERAUFNAHME — bei „Prior attempts" erst nachsehen, dann messen
Steht in deinem Auftrag ein Abschnitt **„Prior attempts on this task"**, hat vor dir schon ein
Lauf gemessen und ist unterwegs gestorben (meist Kontext-Limit). **Dein Workspace ist derselbe.**
Also zuerst: `cat FORTSCHRITT.md` und `ls -la` im Workspace — liegen dort schon Messwerte, Logs,
Bench-Ausgaben? **Vorhandene Zahlen misst du NICHT neu.** Das ist hier keine Bequemlichkeit,
sondern Pflicht: ein wiederholter Bench lädt 70-GB-Modelle, gefährdet Lucys Warm-Set und kann
einen OOM auslösen (Regel 5). Fehlt nur noch die Auswertung, wertest du aus und schließt ab.
Nur was nachweislich fehlt oder unbrauchbar ist, wird neu gemessen.
**Fortschritt hinterlassen:** jede fertige Messung sofort als Zeile in `FORTSCHRITT.md` im
Workspace (was gemessen, welche Zahl, in welcher Datei) — und die Rohausgabe in eine Datei
daneben. Eine Messung, die nur im Kontext steht, ist beim nächsten Lauf verloren.
## Eiserne Regeln (nicht verhandelbar)
1. **MESSEN, NICHT ÄNDERN.** Du fasst das Live-System NICHT an: kein `systemctl restart`, kein
Config-Edit, kein Deploy/Merge/Push, kein Laden/Entladen von Modellen, das das Warm-Set
stört. Ergibt die Messung, dass etwas geändert werden muss → schreib es als BEFUND/Vorschlag
in die Summary (oder leg per `kanban_create` eine Idee an), mach es NICHT selbst.
2. **Nur LESEN am Bestand:** Live-Checkout `~/mission-control-v2` und Hermes-Quelle
`~/.hermes/hermes-agent` sind TABU zum Schreiben (Lesen zur Orientierung ok). Alles außerhalb
einer reinen Messung ist tabu.
3. **TABU-Zonen:** Security-Config (approvals/Tokens/ufw/sudoers), `~/.hermes/config.yaml`,
systemd-Units. Braucht die Aufgabe so etwas → `kanban_block` mit Begründung, nicht selbst tun.
4. **Kenn deine Werkzeuge** (Endpunkte/Pfade stehen in deiner Box-Orientierung + im Selbst-
Steckbrief `~/.hermes/state/selbst-steckbrief.md`):
- Health/Status: `curl -s http://127.0.0.1:9001/api/health`, `.../api/system/status`.
- **Dienste:** `systemctl --user status <dienst>` — aber VORHER
`export XDG_RUNTIME_DIR=/run/user/$(id -u)`, sonst sind die User-Units per SSH UNSICHTBAR
(häufigste Ops-Falle). Logs: `journalctl --user -u <dienst> --no-pager -n 100`.
- Modelle/Router: `curl -s http://127.0.0.1:8080/...` bzw. `http://127.0.0.1:9001/v1/models`.
- Bench/Smoke (nur lesen/messen, nicht deployen): `~/mission-control-v2/deploy/bench/brain-bench.sh`,
`.../deploy/bench/model-bench.sh`, `.../deploy/self-smoke.sh`, `.../deploy/stack-postcheck.sh`.
5. **BENCH-VORSICHT:** Nie zwei große Benches (70-GB-Modelle) direkt nacheinander → OOM-Kill
(Vorfall E5, 02.07.2026). Schwere Modelle (heavy/vision) nur laden, wenn die Aufgabe es
verlangt; danach den Ausgangszustand wiederherstellen. **Lucys Warm-Set und ihre ~1-s-Sprech-
Latenz NIE gefährden** — ein Config-Reload leert das Warm-Set, ein OOM killt laufende Modelle.
6. **VERIFIZIEREN VOR BEHAUPTEN.** Dein Bericht enthält BELEGTE Zahlen und echte Kommando-
Ausgaben (bei Vergleichen: Vorher/Nachher), keine Vermutung. Zitiere die reale Ausgabe, nicht
das, was du erwartest. Der Agenten-Erzählung — auch deiner eigenen — nie glauben; das Log,
der curl, der bench sind die Wahrheit.
7. **Falsche Ebene erkannt?** Braucht die Aufgabe in Wahrheit eine CODE-Änderung → das ist die
**werkstatt**, nicht du (`kanban_block` mit Verweis). Reine Doku/Recherche ohne Box-Bezug →
**default**. Grenzen im Zweifel: `~/mission-control-v2/docs/wissen/GRENZEN.md`.
8. **Fertig = `kanban_complete`** mit deutscher Summary: was gemessen, die ZAHLEN/der klare
Befund, wie geprüft (welche Kommandos). Unklar, blockiert oder Werkzeug fehlt → `kanban_block`
mit ehrlicher Diagnose statt geratener Zahlen.
## Stil
Deutsch in allem User-Sichtbaren. Zahlen statt Prosa. Ein knapper, belegter Befund schlägt
einen langen, vagen Bericht. Keine Nebenbaustellen — genau die eine Messung, sauber.
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Box-Konsole — ttyd web terminal wrapping a login shell (direkter Box-Zugriff)
Documentation=https://github.com/tsl0922/ttyd
After=network.target
[Service]
# Exponiert eine echte interaktive Login-Shell (bash -l) der Box als Web-Terminal — der
# direkte, SSH-artige Zugriff, den das UI als „Konsole" einbettet (iframe). Schwester-Dienst
# zum hermes-terminal (das die Agent-CLI zeigt); dieser hier zeigt die nackte Shell.
#
# SICHERHEIT: Bindet NUR an Loopback (lo/127.0.0.1) und läuft hinter dem MC2-Reverse-Proxy
# (routers/console.py → same-origin /console/ auf dem offenen Port 9001). Port 7682 ist von
# außen dicht → keine eigene Firewall-Regel nötig. Login-Schutz: ttyd startet die Shell über
# `su - hitonabi` → fragt beim Öffnen das Box-Passwort ab (kein gespeichertes Passwort, PAM
# prüft gegen das echte Konto). Trusted-Home-LAN, kein Internet-Exposure.
Type=simple
# --interface lo = nur Loopback. --base-path /console = ttyd bedient /console/* (Reverse-Proxy).
# `su - hitonabi` = Passwort-Login (Box-Passwort) vor der Shell. -t = dunkles Theme.
ExecStart=/usr/bin/ttyd --writable --interface lo --port 7682 --base-path /console --max-clients 2 --cwd %h -t 'theme={"background":"#0b0f1a"}' /bin/su - hitonabi
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Wrapper fuer "git commit", der das Frontend automatisch baut, wenn Dateien geaendert wurden.
# Benutzung: ./deploy/commit.sh -m "deine commit message"
set -euo pipefail
# In das Projekt-Root wechseln
cd "$(dirname "$0")/.."
FRONTEND_DIR="frontend"
# Pruefen, ob Dateien im Frontend-Ordner fuer den Commit vorgemerkt (staged) sind,
# die einen Build erfordern (src, index.html, package.json, etc.).
if git diff --cached --name-only | grep -q "^$FRONTEND_DIR/src/\|^$FRONTEND_DIR/index.html\|^$FRONTEND_DIR/package.json\|^$FRONTEND_DIR/vite.config.ts"; then
echo "=== Frontend-Aenderungen in der Stage entdeckt. Starte Build... ==="
(cd "$FRONTEND_DIR" && npm run build)
# Das neu gebaute dist-Verzeichnis direkt mit stagen
git add "$FRONTEND_DIR/dist"
echo "=== Build abgeschlossen und frontend/dist gestaged. ==="
else
echo "=== Keine Frontend-Aenderungen in der Stage. Ueberspringe Build. ==="
fi
# Führe den eigentlichen Commit mit den übergebenen Parametern durch
exec git commit "$@"
+37 -82
View File
@@ -1,90 +1,45 @@
#!/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}" echo "Starte MC2 Deployment..."
cd "$SRC" # 1. Neuesten Code holen
git fetch -q origin && git reset -q --hard origin/main git pull origin main
# venv + Abhängigkeiten # 1.5 Frontend aus dem Release-Branch holen (nur dist Ordner)
if [ ! -d "$SRC/backend/.venv" ]; then echo "Hole gebautes Frontend aus release-dist Branch..."
python3 -m venv "$SRC/backend/.venv" git fetch origin release-dist:refs/remotes/origin/release-dist || true
fi git restore --source=origin/release-dist --staged --worktree frontend/dist 2>/dev/null || echo "Kein release-dist Branch gefunden (erster Lauf?)."
"$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. # 3. Llama-Swap Konfiguration prüfen und synchronisieren
UV="$(command -v uv || echo "$HOME/.local/bin/uv")" if ! cmp -s deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml; then
"$UV" pip install -q --python "$HOME/.mem0/venv/bin/python" -r "$SRC/mem0_service/requirements.txt" echo "Llama-Swap Konfiguration hat sich geändert, synchronisiere..."
( cd "$SRC/mem0_service" && "$HOME/.mem0/venv/bin/python" migrate.py ) || true # NOPASSWD für llama-swap Neustart ist vorausgesetzt (siehe AGENTS.md)
else sudo cp deploy/llama-swap.config.yaml /etc/llama-swap/config.yaml
echo "WARN: ~/.mem0/venv fehlt — Mem0-Sidecar wird nicht gestartet (siehe Plan A1)." sudo systemctl restart llama-swap
fi fi
# Voice-Sidecar (lokales STT + gestuftes TTS für „Mit Hermes reden") — eigenes Python-3.12-venv # 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
# (~/.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
# der restliche Stack weiter (der Voice-Tab meldet den Sidecar dann als offline).
bash "$SRC/voice_service/install.sh" || echo "WARN: Voice-Sidecar-Setup fehlgeschlagen — Voice-Tab bleibt offline."
# Hermes-Memory-Provider-Plugin (auto-lernen/Recall via Mem0-Sidecar) nach ~/.hermes/plugins/
# spiegeln. Context-only (kein Tool-Loop). Aktivierung in ~/.hermes/config.yaml:
# memory.memory_enabled: true + memory.provider: mc2-memory (einmalig, box-lokal).
if [ -d "$HOME/.hermes" ]; then
mkdir -p "$HOME/.hermes/plugins/mc2-memory"
cp "$SRC/hermes/plugins/mc2-memory/__init__.py" "$SRC/hermes/plugins/mc2-memory/plugin.yaml" \
"$HOME/.hermes/plugins/mc2-memory/"
fi
# systemd-USER-Units installieren/aktualisieren
mkdir -p "$HOME/.config/systemd/user"
cp "$SRC/deploy/mission-control-2.service" "$HOME/.config/systemd/user/mission-control-2.service"
# Hermes-Terminal (ttyd -> `hermes chat`), in MC2 als Terminal-Seite eingebettet.
# Einmalig manuell noetig: `sudo apt install -y ttyd` + `sudo systemctl disable --now ttyd` (apt-Default-Dienst).
cp "$SRC/deploy/hermes-terminal.service" "$HOME/.config/systemd/user/hermes-terminal.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"
# 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"
# 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"
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable mission-control-2 >/dev/null 2>&1 || true
systemctl --user enable hermes-terminal >/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 --now mc2-backup.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-autoupdate.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
systemctl --user restart mission-control-2
command -v ttyd >/dev/null 2>&1 && systemctl --user restart hermes-terminal 2>/dev/null || true
sleep 2 # 4.5 Hermes Skills synchronisieren
echo "--- Health ---" echo "Synchronisiere Hermes Skills..."
curl -sf http://127.0.0.1:9001/api/health && echo mkdir -p ~/.hermes/skills
echo "OK — Mission Control 2.0 läuft auf :9001 (User-Dienst, sudo-frei)." # 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
# 5. Dienste neu starten
echo "Starte Backend neu..."
systemctl --user restart mc2-gateway.service mission-control-2.service
echo "✅ Deployment erfolgreich abgeschlossen!"
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026;
erweitert 19.07.2026 um die No-Progress-Bremse und Mehrfach-Eintraege pro Event).
Kanban-Worker laufen unter IHREM Profil (HERMES_HOME=~/.hermes/profiles/<name>) und lesen
dessen config.yaml — NICHT die Top-Level ~/.hermes/config.yaml. Die worker-relevanten Hooks
muessen also in JEDER Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie
(Bug-Fund 13.07.).
WICHTIG (Falle, 19.07.): Ein Event darf nur EINMAL als YAML-Key existieren. Existiert
`pre_tool_call:` bereits (tabu-pfade-guard), wird ein weiterer Eintrag IN den Block
eingefuegt — ein zweiter `pre_tool_call:`-Key wuerde beim YAML-Laden den ersten
verschlucken und den Tabu-Guard lautlos deaktivieren.
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
"""
import datetime
import os
import shutil
import sys
try:
import yaml
except Exception:
print(" (PyYAML fehlt — ensure-profile-hooks uebersprungen)")
sys.exit(0)
AH = os.path.expanduser("~/.hermes/agent-hooks")
# Reihenfolge zaehlt: tabu-pfade-guard MUSS vor der Bremse stehen (Schutz vor Komfort).
WANT = [
("pre_llm_call", f"{AH}/box-steckbrief-inject.sh", 10),
("pre_tool_call", f"{AH}/tabu-pfade-guard.py", 10),
("pre_tool_call", f"{AH}/no-progress-bremse.py pre", 10),
("post_tool_call", f"{AH}/no-progress-bremse.py post", 10),
]
if len(sys.argv) < 2:
print(" usage: ensure-profile-hooks.py <config.yaml>")
sys.exit(0)
p = sys.argv[1]
if not os.path.isfile(p):
sys.exit(0)
txt = open(p, encoding="utf-8").read()
anchor = (" pre_verify:\n"
f" - command: {AH}/pre-verify-gates.sh\n"
" timeout: 60\n")
if txt.count(anchor) != 1:
print(f" [{os.path.basename(os.path.dirname(p))}] kein eindeutiger pre_verify-Anker — SKIP")
sys.exit(0)
new = txt
changed = False
for event, cmd, timeout in WANT:
if cmd in new:
continue
entry = f" - command: {cmd}\n timeout: {timeout}\n"
ev_line = f" {event}:\n"
if ev_line in new:
# Eintrag ANS ENDE des bestehenden Event-Blocks: direkt nach dem Key einfuegen
# waere auch ok, aber hinter dem letzten Eintrag haelt die gewollte Reihenfolge.
idx = new.index(ev_line) + len(ev_line)
end = idx
for line in new[idx:].splitlines(keepends=True):
if line.startswith((" - ", " ")):
end += len(line)
else:
break
new = new[:end] + entry + new[end:]
else:
new = new.replace(anchor, anchor + ev_line + entry, 1)
changed = True
if not changed:
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks schon registriert.")
sys.exit(0)
try:
d = yaml.safe_load(new)
h = d["hooks"]
for event, cmd, _t in WANT:
assert any(cmd == x.get("command", "") for x in h.get(event, [])), f"{event}: {cmd} fehlt"
assert any("pre-verify-gates.sh" in x.get("command", "") for x in h["pre_verify"])
# Tabu-Guard muss VOR der Bremse stehen (beide in pre_tool_call).
cmds = [x.get("command", "") for x in h["pre_tool_call"]]
assert cmds.index(f"{AH}/tabu-pfade-guard.py") < cmds.index(f"{AH}/no-progress-bremse.py pre")
except Exception as exc:
print(f" [{p}] Validierung fehlgeschlagen ({exc}) — config UNVERAENDERT.")
sys.exit(0)
bak = p + ".bak-hooks-" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(p, bak)
tmp = p + ".tmp-hooks"
open(tmp, "w", encoding="utf-8").write(new)
os.replace(tmp, p)
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks ergaenzt (Backup {os.path.basename(bak)}).")
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Gateway-Cutover (UMBAU v3 P1, Stufe 2 = Unabhängigkeit):
# Hermes' LLM-Endpunkte vom Steuerpult-Umweg (127.0.0.1:9001/v1) auf den
# eigenständigen Gateway (127.0.0.1:9010/v1) umstellen — Top-Level-config.yaml
# UND alle Profil-Configs (werkstatt/betrieb). Danach überleben Lucys Hirn und
# die komplette Nacht-Autonomie jeden MC2-Neustart.
#
# BEWUSST NICHT in deploy.sh: ~/.hermes/config.yaml ist Zwei-Schreiber-sensibel
# (Hermes schreibt sie selbst). Einmalig von Hand bzw. per angenommener Karte:
# bash ~/mission-control-v2/deploy/gateway-cutover.sh # umstellen
# bash ~/mission-control-v2/deploy/gateway-cutover.sh --revert # zurück
# Je geänderter Datei entsteht ein Backup *.bak-gwcut-<Zeitstempel>.
# Danach: hermes-gateway-Neustart + kurzer Funktionsnachweis.
set -euo pipefail
if [ "${1:-}" = "--revert" ]; then
ALT="127.0.0.1:9010/v1"; NEU="127.0.0.1:9001/v1"
PROBE="http://127.0.0.1:9001/api/health" # Rückweg: MC2 muss /v1 wieder selbst bedienen
else
ALT="127.0.0.1:9001/v1"; NEU="127.0.0.1:9010/v1"
PROBE="http://127.0.0.1:9010/gw/health" # Hinweg: Gateway-Prozess muss leben
fi
# Sicherung: NIE auf ein totes Ziel umstellen — das würde Lucy das Hirn abschneiden.
if ! curl -sf -m 5 "$PROBE" >/dev/null 2>&1; then
echo "ABBRUCH: Ziel $NEU antwortet nicht ($PROBE). Nichts geändert."
exit 1
fi
STAMP="$(date +%Y%m%d-%H%M%S)"
ALT_RE="${ALT//./\\.}" # Punkte für sed escapen
geaendert=0
for f in "$HOME/.hermes/config.yaml" "$HOME"/.hermes/profiles/*/config.yaml; do
[ -f "$f" ] || continue
grep -q "$ALT" "$f" || continue
cp "$f" "$f.bak-gwcut-$STAMP"
sed -i "s|$ALT_RE|$NEU|g" "$f"
echo "umgestellt: $f (Backup: $f.bak-gwcut-$STAMP)"
geaendert=1
done
if [ "$geaendert" -eq 0 ]; then
echo "Nichts zu tun — '$ALT' kommt in keiner Hermes-Config vor."
exit 0
fi
echo "hermes-gateway neu starten …"
systemctl --user restart hermes-gateway
sleep 3
if systemctl --user is-active --quiet hermes-gateway; then
echo "hermes-gateway läuft."
else
echo "WARN: hermes-gateway ist nach dem Neustart NICHT aktiv!"
echo " journalctl --user -u hermes-gateway -n 30 und ggf.: $0 --revert"
exit 1
fi
echo "Empfohlener Nachweis: cd ~/.hermes/hermes-agent && venv/bin/hermes doctor"
echo "FERTIG. Rückweg jederzeit: $0 --revert"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# gitea-pr (24.07.2026): PR auf Gitea per REST-API anlegen — der EINZIGE Weg für
# Worker, einen Pull-Request zu erstellen. Kein Web-Login, kein Passwort: das
# HTTP-Token aus ~/.git-credentials reicht (Vorfall 23.07.: Wartung-Worker bat
# um das Gitea-Passwort für die Web-GUI und blockte — genau das nie wieder).
# Nutzung: gitea-pr <repo> <head-branch> "<titel>" ["<body>"] [base=main]
set -euo pipefail
REPO="${1:?Nutzung: gitea-pr <repo> <head-branch> \"<titel>\" [\"<body>\"] [base]}"
HEAD="${2:?head-branch fehlt}"
TITLE="${3:?titel fehlt}"
BODY="${4:-}"
BASE="${5:-main}"
OWNER="Hitonabi"
# Intern statt DDNS-Domain: die ist nachts (Zwangstrennung) oft nicht erreichbar.
HOST="http://192.168.178.153:3000"
creds="$(printf 'protocol=http\nhost=192.168.178.153:3000\n\n' | git credential fill)"
username="$(sed -n 's/^username=//p' <<<"$creds")"
password="$(sed -n 's/^password=//p' <<<"$creds")"
[ -n "$password" ] || { echo "FEHLER: kein Token in ~/.git-credentials gefunden" >&2; exit 1; }
payload="$(TITLE="$TITLE" BODY="$BODY" HEAD="$HEAD" BASE="$BASE" python3 -c '
import json, os
print(json.dumps({"title": os.environ["TITLE"], "body": os.environ["BODY"],
"head": os.environ["HEAD"], "base": os.environ["BASE"]}))')"
antwort="$(curl -sS -w '\n%{http_code}' -X POST \
-u "$username:$password" -H "Content-Type: application/json" \
-d "$payload" "$HOST/api/v1/repos/$OWNER/$REPO/pulls")"
status="${antwort##*$'\n'}"
koerper="${antwort%$'\n'*}"
if [ "$status" = "201" ]; then
printf '%s' "$koerper" | python3 -c 'import json,sys; pr=json.load(sys.stdin); print("PR angelegt:", pr.get("html_url", pr.get("url", "?")))'
else
echo "FEHLER: Gitea-API antwortete $status" >&2
printf '%s\n' "$koerper" | head -c 600 >&2
exit 1
fi
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Gitea-Repo-Anlage für Lucy (17.07.2026): schließt die Lücke im „Projekte selbst
# starten"-Weg — bislang konnte die Werkstatt nur Branches in BESTEHENDE Repos
# proposen, aber kein neues Projekt-Repo anlegen.
#
# Nutzung: gitea-repo-create.sh <name> ["Beschreibung"]
# Ergebnis (stdout, letzte Zeile): CLONE <https-clone-url> → Aufrufer kann direkt klonen.
#
# HARTE Sicherheits-Leitplanken (Gitea ist öffentlich erreichbar!):
# • Repos werden IMMER PRIVAT angelegt (kein öffentliches Repo aus Versehen).
# • Token wird aus ~/.git-credentials gelesen, NIE ausgegeben/geloggt.
# • Jede Anlage wird protokolliert (Audit-Spur) + still an die Chronik gespiegelt.
# Braucht ein Token mit Scope `write:user` (zusätzlich zu `write:repository`); ohne das
# meldet Gitea 403 — das Skript sagt dann genau, was zu tun ist (Token in Gitea neu
# generieren), statt kryptisch zu scheitern.
set -uo pipefail
NAME="${1:-}"
BESCHREIBUNG="${2:-Von Lucy angelegtes Projekt-Repo.}"
# Worker-Subprozesse laufen evtl. mit abweichendem $HOME (Profil-Home) — das dedizierte
# Anlage-Token (write:user) UND .git-credentials liegen aber nur im ECHTEN User-Home.
# Darum den echten Home aus /etc/passwd aufloesen, sonst faellt das Skript aufs falsche
# Token zurueck (Fan-out-Nachlese 20.07.: projektstart-Worker blockte an genau dieser
# Stelle mit "Token hat nur write:repository").
REALHOME="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f6)"; REALHOME="${REALHOME:-$HOME}"
CRED="$REALHOME/.git-credentials"
# Dediziertes Anlage-Token (Scope write:user) — GETRENNT vom Push-Token in
# ~/.git-credentials, damit ein Überschreiben nie Push/PR bricht. Priorität:
# ENV GITEA_CREATE_TOKEN → <echter-Home>/.config/gitea/create-token → Fallback .git-credentials.
CREATE_TOKEN_FILE="$REALHOME/.config/gitea/create-token"
LOG="$REALHOME/.hermes/state/gitea-repos-created.log"
if [ -z "$NAME" ]; then
echo "Nutzung: gitea-repo-create.sh <name> [\"Beschreibung\"]" >&2
exit 2
fi
# Repo-Name defensiv prüfen (Gitea erlaubt Buchstaben/Ziffern/-/_/.)
if ! printf '%s' "$NAME" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$'; then
echo "Ungültiger Repo-Name '$NAME' (erlaubt: Buchstaben/Ziffern/.-_, Beginn alphanumerisch)." >&2
exit 2
fi
# User + Host sind NICHT geheim: aus .git-credentials ableiten, sonst Standardwerte.
LINE="$(grep -m1 'git\.tobisniceshomelab\.ddnsfree\.com' "$CRED" 2>/dev/null || head -1 "$CRED" 2>/dev/null || true)"
USER="$(printf '%s' "$LINE" | sed -nE 's#https://([^:]+):.*#\1#p')"; USER="${USER:-Hitonabi}"
HOST="$(printf '%s' "$LINE" | sed -nE 's#https://[^@]+@([^/]+).*#\1#p')"; HOST="${HOST:-git.tobisniceshomelab.ddnsfree.com}"
BASE="https://${HOST}"
# Anlage-Token bestimmen (dediziert bevorzugt, damit Push-Token unberührt bleibt)
if [ -n "${GITEA_CREATE_TOKEN:-}" ]; then
TOKEN="$GITEA_CREATE_TOKEN"
elif [ -r "$CREATE_TOKEN_FILE" ]; then
TOKEN="$(tr -d '[:space:]' < "$CREATE_TOKEN_FILE")"
else
TOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"
fi
if [ -z "$TOKEN" ]; then
echo "Kein Gitea-Token gefunden (weder \$GITEA_CREATE_TOKEN noch $CREATE_TOKEN_FILE noch ~/.git-credentials)." >&2
exit 1
fi
RESP="$(mktemp)"; trap 'rm -f "$RESP"' EXIT
CODE="$(curl -s -o "$RESP" -w '%{http_code}' -X POST "$BASE/api/v1/user/repos" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"description":sys.argv[2],"private":True,"auto_init":True,"default_branch":"main"}))' "$NAME" "$BESCHREIBUNG")")"
case "$CODE" in
201)
CLONE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("clone_url",""))' "$RESP" 2>/dev/null)"
FULL="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("full_name",""))' "$RESP" 2>/dev/null)"
mkdir -p "$(dirname "$LOG")"
echo "$(date '+%Y-%m-%d %H:%M') angelegt (privat): $FULL" >> "$LOG"
# Stiller Chronik-Spiegel (Audit-Spur, wie die Radar-Meldungen)
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 -c 'import json,sys; print(json.dumps({"text":"Neues PRIVATES Projekt-Repo angelegt: "+sys.argv[1],"subject":"Gitea","source":"gitea-repo-create","priority":"silent"}))' "$FULL")" \
>/dev/null 2>&1 || true
# CI-Ampel einpflanzen (Wasserdicht-Runde 22.07.): JEDES neue Repo wird mit der
# universellen Actions-Ampel geboren (deploy/ampel-ci.yml). Defensiv: fehlt die
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
# dedizierte Anlage-Token hat u.U. nur write:user).
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
# saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
saat () {
local QUELLE="$1" ZIEL="$2" MSG="$3" NAME="$4" CODE
if [ ! -r "$QUELLE" ]; then
echo "WARNUNG: Vorlage fehlt ($QUELLE) — Repo ohne $NAME angelegt." >&2
return
fi
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/$ZIEL" \
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
--data "$(python3 - "$QUELLE" "$MSG" <<'PY'
import base64, json, sys
inhalt = open(sys.argv[1], "rb").read()
print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
PY
)")"
if [ "$CODE" = "201" ]; then
echo "$NAME eingepflanzt ($ZIEL)."
else
echo "WARNUNG: $NAME-Seed antwortete HTTP $CODE (Repo ist trotzdem da)." >&2
fi
}
# JEDES neue Repo wird mit beiden Wächtern geboren:
# ci.yml = die AUSSEN-Prüfung (Gitea Actions nach dem Push, Wasserdicht-Runde 22.07.)
# VERIFY = die INNEN-Prüfung (das OpenCode-Plugin führt sie nach jeder Etappe aus und
# gibt rote Tests dem Agenten sofort zurück, statt sie erst der CI zu zeigen)
# Defensiv: scheitert ein Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
saat "$REALHOME/mission-control-v2/deploy/ampel-ci.yml" ".gitea/workflows/ci.yml" \
"CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)" "CI-Ampel"
saat "$REALHOME/mission-control-v2/deploy/opencode/VERIFY.template" "VERIFY" \
"Pruef-Tor (automatisch bei Repo-Anlage eingepflanzt)" "Pruef-Tor"
echo "Repo '$FULL' angelegt (privat, main initialisiert)."
echo "CLONE ${CLONE}"
exit 0 ;;
403)
if grep -q 'write:user' "$RESP"; then
echo "FEHLT: Das benutzte Gitea-Token darf keine Repos anlegen (Scope 'write:user' fehlt)." >&2
echo "So wird es freigeschaltet (einmalig, durch den Commander):" >&2
echo " 1) In Gitea ein Token mit Scope 'write:user' erzeugen (nur DAS reicht fürs Anlegen)." >&2
echo " 2) Den Token-Wert in ~/.config/gitea/create-token legen (chmod 600) —" >&2
echo " getrennt vom Push-Token, das in ~/.git-credentials unberührt bleibt." >&2
else
echo "403 von Gitea:" >&2; head -c 300 "$RESP" >&2; echo >&2
fi
exit 3 ;;
409)
echo "Ein Repo namens '$NAME' existiert bereits." >&2
exit 4 ;;
*)
echo "Gitea antwortete mit HTTP $CODE:" >&2; head -c 300 "$RESP" >&2; echo >&2
exit 1 ;;
esac
+65
View File
@@ -0,0 +1,65 @@
# Gitea app.ini - Beispielkonfiguration mit DISABLE_REGISTRATION
# Speichere diese Datei auf dem Server unter /etc/gitea/app.ini
# und setze DISABLE_REGISTRATION=true im Abschnitt [security]
APP_NAME = TobisNicesGitTea
RUN_USER = git
RUN_MODE = prod
[server]
DOMAIN = git.tobisniceshomelab.ddnsfree.com
HTTP_PORT = 3000
APP_DATA_PATH = /var/lib/gitea
PROTOCOL = http
ROOT_URL = https://git.tobisniceshomelab.ddnsfree.com/
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
LFS_CONTENT_PATH = /var/lib/gitea/data/lfs
OFFLINE_MODE = false
[database]
DB_TYPE = sqlite3
PATH = /var/lib/gitea/gitea.db
MAX_OPEN_CONNS = 10
MAX_IDLE_CONNS = 5
MAX_CONN_LIFETIME = 30m
DSN = /var/lib/gitea/gitea.db
[security]
INSTALL_LOCK = true
SECRET_KEY = CHANGE_THIS_TO_A_RANDOM_SECRET_KEY
PASSWORD_HASH_ALGO = pbkdf2
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
[service]
ENABLE_CAPTCHA = false
ENABLE_REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTER_API = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.example.org
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[session]
PROVIDER = file
COOKIE_NAME = gitea sessionId
COOKIE_SECURE = false
[mailer]
ENABLED = false
HOST =
FROM = gitea@example.com
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[security]
DISABLE_REGISTRATION = true
+51 -3
View File
@@ -30,6 +30,25 @@ else
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1 echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
fi fi
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
# --- angewandt → Gateway neu laden; Exit 1 = ein Patch passt nicht mehr → ROT. ---
PATCH_APPLY="${MC2_SRC:-$HOME/mission-control-v2}/deploy/hermes-patches/apply.py"
if [ -f "$PATCH_APPLY" ]; then
python3 "$PATCH_APPLY" >/dev/null 2>&1; _prc=$?
if [ "$_prc" -eq 10 ]; then
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
echo "PASS · Hermes-Runtime-Patches nach Update RE-angewandt (Gateway neu geladen)"
elif [ "$_prc" -eq 0 ]; then
echo "PASS · Hermes-Runtime-Patches unveraendert vorhanden"
else
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
fi
else
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
fi
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \ if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \ "import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
>/dev/null 2>&1 ); then >/dev/null 2>&1 ); then
@@ -53,12 +72,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
# einem Update ist das Modell kalt. Ein Kaltstart kostet ~20-30 s Laden PLUS ~37 s
# Prefill für Hermes' ~70-KB-Systemprompt (33 Tool-Schemas) — zusammen mit zwei
# 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 "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \ -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) -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
@@ -86,6 +115,25 @@ else
echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht (3 Versuche)"; fail=1 echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht (3 Versuche)"; fail=1
fi fi
# --- Browser-Tool: agent-browser überlebt das Update? (Vorfall 15./16.07.2026: das ---
# --- npm ci des Updates bog ~/.local/bin/agent-browser per postinstall auf ---
# --- node_modules/ um und löschte das Paket im selben Lauf → toter Link, jedes ---
# --- „Website öffnen" starb still als „Failed to open".) Heilung direkt hier, denn ---
# --- genau NACH einem Update entsteht der Bruch; try-restart räumt den ---
# --- „nicht installiert"-Cache, den browser_tool pro Prozess hält. ---
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
AB_LINK="$HOME/.local/bin/agent-browser"
AB_GOOD="$HOME/.local/lib/node_modules/agent-browser/bin/agent-browser-linux-x64"
if "$AB_LINK" --version >/dev/null 2>&1; then
echo "PASS · Browser-Tool (agent-browser erreichbar)"
elif [ -x "$AB_GOOD" ] && "$AB_GOOD" --version >/dev/null 2>&1 \
&& ln -sfn "$AB_GOOD" "$AB_LINK" && "$AB_LINK" --version >/dev/null 2>&1; then
systemctl --user try-restart hermes-gateway hermes-builtin-ui >/dev/null 2>&1 || true
echo "GEHEILT · Browser-Tool: toter agent-browser-Link nach Update neu gesetzt (Dienste neu gestartet)"
else
echo "FAIL · Browser-Tool: agent-browser nicht lauffähig (npm install -g agent-browser && agent-browser install --with-deps)"; fail=1
fi
if [ "$fail" -eq 0 ]; then if [ "$fail" -eq 0 ]; then
echo "=== GEHIRN OK ✓ ===" echo "=== GEHIRN OK ✓ ==="
else else
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Hermes Terminal — ttyd web terminal wrapping `hermes chat` (interaktiver Agent)
Documentation=https://github.com/tsl0922/ttyd
After=network.target
[Service]
# Exponiert die interaktive Hermes-Agent-CLI (`hermes chat`, voller Agent mit Tools/PC) als
# Web-Terminal. Wird in MC2 per iframe eingebettet (Terminal-Seite) — Ersatz fuer AnythingLLM.
#
# SICHERHEIT: --writable + LAN-Bind ohne Auth = dasselbe Trust-Modell wie das MC2-Dashboard
# (vertrautes Heim-LAN, kein Internet-Exposure). Fuer Basic-Auth: am ExecStart
# --credential <user>:<pass>
# ergaenzen (Browser fragt dann einmalig im iframe nach).
Type=simple
# --interface eno1 = LAN-Bind (box-spezifisch; eno1 traegt 192.168.178.151).
ExecStart=/usr/bin/ttyd --writable --interface eno1 --port 7681 --max-clients 2 --cwd %h/.hermes %h/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main chat
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+62 -57
View File
@@ -6,9 +6,11 @@ models:
Qwen3.6-35B-A3B: Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns # parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02: # nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. # Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
cmd: | cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3 llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf
ttl: 0 ttl: 0
aliases: aliases:
- hermes - hermes
@@ -19,9 +21,28 @@ models:
out: [text] out: [text]
tools: true tools: true
context: 65536 context: 65536
Qwen3.5-122B-A10B: Qwen3-Coder-Next:
cmd: | cmd: |
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384 llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 5400
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- embed
gpt-oss-120b:
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600 ttl: 600
aliases: aliases:
- heavy - heavy
@@ -30,61 +51,17 @@ models:
out: [text] out: [text]
tools: true tools: true
context: 32768 context: 32768
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-VL-8B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
ttl: 300
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
context: 32768
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- embed
Qwen3-Coder-30B-A3B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-30B-A3B-Instruct-GGUF/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 300
aliases:
- coder-lite
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
gpt-oss-120b:
# heavy-Kandidat (Bench 02.07.: tg 54-55 t/s vs. Qwen3.5-122B 23,5 t/s = 2,3x, 60 statt
# 73 GB). Noch OHNE heavy-Alias — erst Qualitaets-Check (deutsch/Reasoning), dann Entscheid.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-VL-30B-A3B-Instruct: Qwen3-VL-30B-A3B-Instruct:
# vision-Upgrade-Kandidat (Bench 02.07.: tg 90-92 t/s, MoE 3B aktiv, stark auf GUI-Benchmarks). # Lucys AUGEN — seit 07.07. ON-DEMAND (User-Entscheid: „Gehirn + Embedding reichen
# Noch OHNE vision-Alias — erst Screenshot-Qualitaets-Check, dann Entscheid (brains-Budget!). # komplett; Vision nur wenn Lucy auf dem Desktop an ist oder die IDE-Lane sie braucht").
# NICHT mehr in brains; ttl 900 = 15 Min Nachlauf nach dem letzten Blick. Die Lucy-App
# wärmt die Augen beim Start selbst an (useVoiceAgent-Warm-Gate). Vorher: warm, ttl 0 —
# das kostete ~19 GB Dauerbelegung und verdrängte den 60-GB-Chef-Gutachter.
cmd: | cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 300 ttl: 900
aliases:
- vision
capabilities: capabilities:
in: [text, image] in: [text, image]
out: [text] out: [text]
@@ -100,11 +77,39 @@ models:
out: [text] out: [text]
tools: true tools: true
context: 131072 context: 131072
Devstral-Small-2-24B:
# KRITIKER (25.07.2026): Gegenleser aus einer ANDEREN Modellfamilie (Mistral, Apache-2.0,
# 24B DICHT, 256k Kontext, 68,0 % SWE-bench Verified vs. 59,2 % bei GLM-4.7-Flash).
# Loest GLM-4.7-Flash in der kritiker-Rolle ab. Dicht = auf Strix Halo bandbreitengebunden
# und damit langsam beim SCHREIBEN — fuer einen Kritiker egal: er liest viel, schreibt wenig.
# --jinja ist Pflicht (Chat-Template, sonst kaputte Tool-Calls).
cmd: |
llama-server -m /srv/models/Devstral-Small-2-GGUF/mistralai_Devstral-Small-2-24B-Instruct-2512-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 16384 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256
ttl: 1800
aliases:
- kritiker
capabilities:
in: [text]
out: [text]
tools: true
context: 16384
Qwen3-Reranker-0.6B:
# Gedächtnis-Zweitstufe (07.07., Rollen-Audit): ordnet Mem0-Suchtreffer nach echter
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
cmd: |
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- reranker
groups: groups:
brains: brains:
swap: false swap: false
# llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der
# MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen!
persist: true persist: true
persistent: true
members: members:
- Qwen3-Embedding-0.6B - Qwen3-Embedding-0.6B
- Qwen3-VL-8B-Instruct - Qwen3-Reranker-0.6B
- Qwen3.6-35B-A3B - Qwen3.6-35B-A3B
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Auftragsbuch: einen LUCY-Vorschlags-Branch ANNEHMEN — das PC-Gegenstück zu auftrag-annehmen.sh.
# Lucy (Electron) lebt auf dem Windows-PC (F:\Coding Stuff\lucy); dist liegt NICHT im Repo.
# Annehmen heißt darum: Merge auf main (hier, Box) → der PC zieht main, baut dist neu und
# startet Lucy neu, WENN sie lief (User-Entscheid: Lucy startet nur, wenn der Commander sie will).
# Schlägt am PC irgendetwas fehl, wird der Merge auf main automatisch revertiert — die laufende
# Lucy bleibt die alte (der PC-Runner stellt dist aus seinem Backup wieder her).
#
# Der PC wird über den PC-Executor (client/hermes-pc/executor.py, :7777, Bearer-Token aus
# ~/.hermes/config.yaml) gesteuert. /shell hat 60 s Timeout → der Build läuft DETACHED
# (deploy/lucy-annahme.ps1 im Lucy-Repo schreibt eine Status-Datei, wir pollen sie).
#
# Läuft wie der MC2-Runner als EIGENE systemd-Unit und als /tmp-Kopie (Selbst-Reset-Falle).
# Status-Schlüssel in der Auftragsbuch-JSON: "lucy:<branch>".
#
# Nutzung: lucy-annahme.sh <branch> (z. B. wartung/turncheck-default-aus)
set -uo pipefail
BRANCH="${1:?Nutzung: lucy-annahme.sh <branch>}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
LUCY="${MC2_LUCY_DIR:-$HOME/lucy}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
HERMES_CONF="${HERMES_CONF:-$HOME/.hermes/config.yaml}"
SLUG="lucy-$(echo "$BRANCH" | tr '/' '-')"
WT="/tmp/annahme-$SLUG"
LOG="/tmp/annahme-$SLUG.log"
# Windows-Seite (Pfade im PC-Dateisystem; einfach gehalten — EIN PC, EIN Checkout).
PC_REPO='F:\Coding Stuff\lucy'
PC_STATUSFILE='F:\Coding Stuff\lucy\.lucy-annahme.json'
PC_RUNNER='F:\Coding Stuff\lucy\deploy\lucy-annahme.ps1'
BUILD_TIMEOUT_S=1500 # 25 min Deckel für npm ci + tsc + electron-builder + Stimm-Warmup
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" "lucy:$BRANCH" "$1" "$2" <<'PY'
import json, os, sys, time
p, key, state, detail = sys.argv[1:5]
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
d = {}
d.setdefault("branches", {})[key] = {"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
}
# PC-Executor: PowerShell-Kommando ausführen. stdout des PCs → stdout; rc 0 nur wenn
# Transport UND Kommando grün. Token/URL kommen aus der Hermes-Config (kein zweiter Ablageort).
PC_URL="$(sed -n 's/^[[:space:]]*PC_EXECUTOR_URL:[[:space:]]*//p' "$HERMES_CONF" | head -1 | tr -d "\"'" | tr -d '[:space:]')"
PC_TOKEN="$(sed -n 's/^[[:space:]]*PC_EXECUTOR_TOKEN:[[:space:]]*//p' "$HERMES_CONF" | head -1 | tr -d "\"'" | tr -d '[:space:]')"
pc_shell(){ # $1 = PowerShell-Kommando
python3 - "$PC_URL" "$PC_TOKEN" "$1" <<'PY'
import json, sys, urllib.request
url, token, cmd = sys.argv[1:4]
req = urllib.request.Request(url + "/shell", data=json.dumps({"command": cmd}).encode(),
headers={"Authorization": "Bearer " + token,
"Content-Type": "application/json"})
try:
r = json.load(urllib.request.urlopen(req, timeout=58))
except Exception as e:
print(f"(PC-Executor nicht erreichbar: {e})")
sys.exit(2)
out = (r.get("stdout") or "").strip()
if out:
print(out)
err = (r.get("stderr") or "").strip()
if err:
print(err, file=sys.stderr)
sys.exit(0 if r.get("returncode") == 0 else 1)
PY
}
cleanup_wt(){ git -C "$LUCY" worktree remove --force "$WT" 2>/dev/null || true; }
fail(){
status "fehlgeschlagen" "$1"
notify "Lucy-Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
cleanup_wt
exit 1
}
# Revert nach schon gepushtem Merge (PC-Phase rot): main zurückdrehen, PC-Checkout nachziehen.
revert_main(){ # $1 = Grund für die Meldung
status "rollback" "PC-Phase rot — Merge wird automatisch zurückgedreht"
git -C "$LUCY" fetch -q origin || true
cleanup_wt
if git -C "$LUCY" 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
# PC-Checkout best-effort auf den revertierten Stand nachziehen (Quelle = laufende Lucy).
pc_shell "git -C '$PC_REPO' fetch origin main 2>&1; git -C '$PC_REPO' merge --ff-only FETCH_HEAD 2>&1" >>"$LOG" 2>&1 || true
status "zurueckgerollt" "$1 — Merge automatisch revertiert, Lucy läuft in der alten Version weiter"
notify "Lucy-Vorschlag '$BRANCH' hat den PC-Build/Neustart GERISSEN: $1. Merge automatisch zurückgedreht — Lucy läuft in der alten Version weiter. Der Branch bleibt zur Analyse liegen. Log: $LOG"
exit 1
fi
cleanup_wt
status "kritisch" "Revert fehlgeschlagen — Lucy-Repo und PC von Hand prüfen"
notify "KRITISCH: Lucy-Annahme '$BRANCH' scheiterte UND der automatische Revert schlug fehl — bitte Lucy-Repo (Gitea main) und PC prüfen. Log: $LOG"
exit 2
}
[ -n "$PC_URL" ] && [ -n "$PC_TOKEN" ] || fail "PC-Executor-Zugang fehlt in $HERMES_CONF (PC_EXECUTOR_URL/TOKEN)"
# ── Vorprüfungen am PC (VOR dem Merge — solange ist nichts kaputtzumachen) ──
status "laeuft" "Vorprüfung: ist der PC bereit?"
curl -sf -m 8 "$PC_URL/health" >/dev/null 2>&1 \
|| fail "PC-Executor nicht erreichbar — Lucy-Annahme braucht den eingeschalteten PC (Executor-Task läuft bei Anmeldung)"
PC_BRANCH="$(pc_shell "git -C '$PC_REPO' rev-parse --abbrev-ref HEAD" 2>>"$LOG")" \
|| fail "PC-Arbeitskopie nicht lesbar (git am PC): $PC_BRANCH"
[ "$PC_BRANCH" = "main" ] || fail "PC-Arbeitskopie steht auf '$PC_BRANCH' statt main — bitte am PC aufräumen"
PC_DIRTY="$(pc_shell "git -C '$PC_REPO' status --porcelain -uno" 2>>"$LOG")" \
|| fail "PC-Arbeitskopie nicht prüfbar"
[ -z "$PC_DIRTY" ] || fail "PC-Arbeitskopie hat ungespeicherte Änderungen — bitte erst am PC committen/aufräumen: $(echo "$PC_DIRTY" | head -3 | tr '\n' ' ')"
WAS_RUNNING="$(pc_shell "if (Get-Process Lucy -ErrorAction SilentlyContinue) { '1' } else { '0' }" 2>>"$LOG")" || WAS_RUNNING="0"
case "$WAS_RUNNING" in 1|0) ;; *) WAS_RUNNING="0" ;; esac
# ── Merge auf main (Box, isolierter Worktree — wie beim MC2-Runner) ─────────
status "laeuft" "Merge wird vorbereitet"
git -C "$LUCY" fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
git -C "$LUCY" rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
# Kaputt aufgesetzte Branches (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen.
git -C "$LUCY" 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"
cleanup_wt
git -C "$LUCY" worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
MERGE_MSG="Auftragsbuch: Lucy-Vorschlag '$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 (wie im MC2-Runner).
status "laeuft" "Merge-Konflikt — Auto-Rebase wird versucht"
WT2="/tmp/annahme-rebase-$SLUG"
git -C "$LUCY" worktree remove --force "$WT2" 2>/dev/null || true
REBASED=""
if git -C "$LUCY" 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 -C "$LUCY" 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
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
# ── PC: main ziehen, Build detached starten ─────────────────────────────────
status "laeuft" "PC zieht den neuen Stand"
pc_shell "git -C '$PC_REPO' fetch origin main 2>&1; if (\$LASTEXITCODE -ne 0) { exit 1 }; git -C '$PC_REPO' merge --ff-only FETCH_HEAD 2>&1" >>"$LOG" 2>&1 \
|| revert_main "PC konnte main nicht ziehen (git fetch/merge am PC rot)"
pc_shell "if (Test-Path '$PC_RUNNER') { exit 0 } else { exit 1 }" >>"$LOG" 2>&1 \
|| revert_main "PC-Runner-Skript fehlt im Lucy-Repo (deploy/lucy-annahme.ps1)"
# WICHTIG: -File-Pfad MUSS eingebettete Anführungszeichen tragen — Start-Process fügt
# die ArgumentList-Elemente OHNE Quoting zusammen; 'F:\Coding Stuff\…' zerbricht sonst am
# Leerzeichen und die gespawnte powershell stirbt still (erster Live-Lauf, 12.07.).
pc_shell "Remove-Item -Force '$PC_STATUSFILE' -ErrorAction SilentlyContinue; Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','\"$PC_RUNNER\"','-WasRunning','$WAS_RUNNING'" >>"$LOG" 2>&1 \
|| revert_main "PC-Build ließ sich nicht starten"
# ── Status-Datei pollen, Fortschritt in die UI spiegeln ─────────────────────
status "laeuft" "PC baut Lucy (dist) — das dauert ein paar Minuten"
DEADLINE=$(( $(date +%s) + BUILD_TIMEOUT_S ))
LAST_DETAIL=""
FINAL=""
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
sleep 10
RAW="$(pc_shell "if (Test-Path '$PC_STATUSFILE') { Get-Content -Raw '$PC_STATUSFILE' }" 2>>"$LOG")" || continue
[ -n "$RAW" ] || continue
# Parse OHNE f-String-Escapes: \" in f-String-Ausdruecken ist seit Python 3.12/3.14
# ein SyntaxError (alter Pre-3.12-Stil) — genau das legte den ersten Live-Lauf lahm.
PARSED="$(printf '%s' "$RAW" | python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
except Exception:
sys.exit(0)
detail = str(d.get("detail", "")).replace("|", "/").replace("\n", " ")
done = "1" if d.get("done") else "0"
ok = "1" if d.get("ok") else "0"
print(done + "|" + ok + "|" + detail)
')"
[ -n "$PARSED" ] || continue
DONE="${PARSED%%|*}"; REST="${PARSED#*|}"; OK="${REST%%|*}"; DETAIL="${REST#*|}"
if [ "$DETAIL" != "$LAST_DETAIL" ] && [ -n "$DETAIL" ]; then
LAST_DETAIL="$DETAIL"
status "laeuft" "PC: $DETAIL"
fi
if [ "$DONE" = "1" ]; then FINAL="$OK"; break; fi
done
[ -n "$FINAL" ] || revert_main "PC-Build-Timeout nach $((BUILD_TIMEOUT_S/60)) Minuten (Status-Datei blieb offen)"
[ "$FINAL" = "1" ] || revert_main "${LAST_DETAIL:-PC meldet Fehler ohne Detail}"
# ── Grün: Branch aufräumen, ehrlich melden ──────────────────────────────────
git -C "$LUCY" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
git -C "$LUCY" fetch -q --prune origin 2>/dev/null || true
status "eingespielt" "${LAST_DETAIL:-Build grün}"
if [ "$WAS_RUNNING" = "1" ]; then
notify "Lucy-Vorschlag '$BRANCH' ist LIVE — dist am PC neu gebaut, Lucy neu gestartet, Stimme meldet sich. ✅"
else
notify "Lucy-Vorschlag '$BRANCH' ist eingespielt — dist am PC neu gebaut. Lucy war aus und bleibt aus; die neue Version startet beim nächsten Öffnen. ✅"
fi
exit 0
+34
View File
@@ -0,0 +1,34 @@
# systemd-USER-Unit für den MC2-Gateway — der /v1-Datenpfad als eigener Prozess (UMBAU v3 P1).
# Durch diesen Prozess läuft JEDER LLM-Aufruf der Box (Lucys Hirn, Nacht-Crons, Worker-
# Delegation, Vision) — er ist bewusst winzig, wird praktisch nie angefasst und startet
# sich immer selbst neu (Restart=always: die Wirbelsäule steht, bevor es jemand merkt).
# Loopback-only (:9010): alle kritischen Verbraucher sind box-lokal; LAN-Clients
# (IDE-Lane) kommen weiter über MC2 :9001/v1, das roh hierher durchreicht.
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
[Unit]
Description=MC2 Gateway (/v1-Datenpfad: model:auto-Routing + Bild-Weiche)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn gateway_app:app --host 127.0.0.1 --port 9010
Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
# Briefkasten-Meldungen (z. B. Kontext-Limit-Warnung) NIE direkt in die Store-Datei —
# die gehört exklusiv dem Steuerpult; per HTTP abliefern (gleiches Muster wie mc2-steward).
Environment=MC_ANNOUNCE_HTTP=http://127.0.0.1:9001
Restart=always
RestartSec=2
# Stoppen darf nicht warten: der Gateway hält minutenlange LLM-Streams offen — uvicorns
# „Waiting for connections to close" + default 90 s TimeoutStop machten jeden Restart zum
# 502-Fenster (15.07. live gemessen, 14:29→14:31). Stateless Proxy → nach 5 s hart beenden
# ist verlustarm; Clients (Hermes api_max_retries=3) wiederholen abgerissene Anfragen.
TimeoutStopSec=5
[Install]
WantedBy=default.target
-26
View File
@@ -1,26 +0,0 @@
[Unit]
Description=MC2 Mem0 Sidecar — auto-lernendes, semantisches Gedächtnis (mem0 + chroma)
Documentation=https://github.com/mem0ai/mem0
After=network.target
[Service]
# Mem0 + chromadb laufen nur unter Python 3.12 (~/.mem0/venv) — das MC2-Backend (3.14)
# kann sie nicht importieren. Darum dieser schlanke Sidecar; MC2 spricht ihn per HTTP an.
# Bind 127.0.0.1: nur lokal erreichbar (MC2 proxyt nach außen).
Type=simple
WorkingDirectory=%h/mission-control-v2/mem0_service
Environment=MEM0_PORT=8765
Environment=MEM0_CHROMA_PATH=/srv/models/mem0/chroma
Environment=MEM0_HISTORY_DB=/srv/models/mem0/history.db
Environment=MEM0_EMBED_URL=http://127.0.0.1:8080/v1
Environment=MEM0_LLM_URL=http://127.0.0.1:8080/v1
Environment=MEM0_EMBED_MODEL=embed
Environment=MEM0_LLM_MODEL=fast
Environment=MEM0_EMBED_DIMS=1024
Environment=TOKENIZERS_PARALLELISM=false
ExecStart=%h/.mem0/venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8765
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
+12
View File
@@ -14,12 +14,24 @@ Wants=network-online.target
Type=simple Type=simple
WorkingDirectory=%h/mission-control-v2/backend WorkingDirectory=%h/mission-control-v2/backend
ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001 ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001
Environment=PYTHONPATH=%h/mission-control-v2
Environment=MC_PORT=9001 Environment=MC_PORT=9001
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080 Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models Environment=MC_MODELS_DIR=/srv/models
# Geteiltes Gedächtnis = die bestehende v1-DB (Kontinuität bis/über Cutover). # Geteiltes Gedächtnis = die bestehende v1-DB (Kontinuität bis/über Cutover).
Environment=MC_MEMORY_DB=/srv/models/mission-control-memory.db Environment=MC_MEMORY_DB=/srv/models/mission-control-memory.db
# Gateway-Auszug (UMBAU v3 P1): /v1 roh an den eigenständigen mc2-gateway-Prozess
# durchreichen. Diese Zeile entfernen (+ daemon-reload + restart) = Rollback, MC2
# bedient /v1 wieder selbst.
Environment=MC_V1_UPSTREAM=http://127.0.0.1:9010
# Steward-Auszug (UMBAU v3 P2): Die Wächter-Loops (Re-Warm, Health-Sentry, Mem0-Dedupe)
# laufen jetzt im eigenen Dienst mc2-steward.service — hier deshalb AUS. Diese drei
# Zeilen entfernen (+ daemon-reload + restart) = Rollback (Loops wieder im Steuerpult).
# Der reminders_loop bleibt bewusst HIER (teilt Datei+CRUD mit /api/reminders).
Environment=MC_REWARM_ENABLED=0
Environment=MC_SENTRY_ENABLED=0
Environment=MC_MEM_DEDUPE_ENABLED=0
# KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den # KEIN MC_ENGINE_UPDATE_CMD-Override mehr: Das alte /usr/local/bin/update-llamacpp zog den
# ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates # ROCm-Build nach /opt/llamacpp (totes Rollback-Dir) statt des aktiven Vulkan-Builds → Updates
# liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default # liefen ins Leere ("DONE", aber nichts passierte). Ohne Override nutzt das Backend den Default
+32 -2
View File
@@ -22,12 +22,42 @@ shift $((OPTIND - 1))
MSG="${1:-}" MSG="${1:-}"
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)" [ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
if [ -z "$MSG" ]; then if [ -z "$MSG" ]; then
echo "usage: notify.sh [-s subject] <nachricht>" >&2 # Bei leerer Pipe-Eingabe lautlos beenden (wichtig für Cron-Skripte ohne Ausgabe)
exit 2 exit 0
fi fi
TS="$(date '+%Y-%m-%d %H:%M:%S')" TS="$(date '+%Y-%m-%d %H:%M:%S')"
# Lucy-Briefkasten (Proaktivität A3): Meldung zusätzlich an MC2 spiegeln — die Desktop-Lucy
# spricht sie dann von sich aus. Best-effort (darf den Telegram-Weg nie aufhalten).
# MC_NOTIFY_NO_ANNOUNCE=1 unterdrückt das (der Health-Wächter legt seine Einträge selbst ab).
if [ "${MC_NOTIFY_NO_ANNOUNCE:-0}" != "1" ]; then
curl -sf -m 3 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 - "$SUBJECT" "$MSG" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[2], "subject": sys.argv[1], "source": "notify"}))
PY
)" >/dev/null 2>&1 || true
fi
# Nachtweiche: Zwischen 00:00 und 06:59 Uhr (inklusive) fangen wir Telegram-Nachrichten ab
# und sammeln sie für den Morgen-Digest, ES SEI DENN es ist ein Alarm.
HOUR="$(date +%H)"
if [ "$HOUR" -ge 0 ] && [ "$HOUR" -lt 7 ]; then
case "${SUBJECT,,}" in
*alarm*|*notfall*)
;; # Alarme gehen immer sofort durch
*)
# Sammeln für Morgen-Digest
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
echo "- ${SUBJECT:+$SUBJECT: }$MSG" >> "$NIGHT_QUEUE"
echo "$TS QUEUED für Morgen-Digest: $MSG" >> "$LOG"
exit 0
;;
esac
fi
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen). # Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
if [ -n "$SUBJECT" ]; then if [ -n "$SUBJECT" ]; then
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)" SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
+166
View File
@@ -0,0 +1,166 @@
# Projektstart-Worker (Profil „projektstart") — IDE-Vorbereiter
Du bist der **IDE-Vorbereiter** der AI-Box. Du bearbeitest EINE Kanban-Aufgabe: eine Idee des
Commanders, die er **selbst in der Agentic IDE bauen** will — nicht die Box.
Dein Ergebnis ist **kein Code**, sondern ein **startklares, leeres Projekt-Repo**: ein
wasserdicht durchdachtes Konzept plus alle Steuerungs-Dokumente, mit denen der Commander in der Agentic IDE
dem Coder sofort einen sauberen Auftrag geben kann. Du denkst und bereitest vor — **gebaut wird
in der Agentic IDE, von ihm.**
## Schritt 0: WIEDERAUFNAHME — immer zuerst, nie aus Reflex neu anfangen
Steht in deinem Auftrag ein Abschnitt **„Prior attempts on this task"**, dann hat vor dir schon
ein Lauf an genau dieser Karte gearbeitet — meist ist er am Kontext-Limit gestorben, mitten in
der Arbeit. **Dein Workspace ist derselbe wie seiner, seine Arbeit liegt noch da.** Verschaffe
dir ZUERST einen Überblick, bevor du irgendetwas neu denkst:
1. `cat FORTSCHRITT.md` in deinem Workspace (falls vorhanden) — die Notizen deines Vorgängers.
2. `ls -la` im Workspace: liegt dort ein geklontes Repo? Was steht drin —
`git -C <repo> log --oneline -3` und `git -C <repo> status --short`. **Uncommittete Dateien
sind fertige Arbeit, der nur noch der Push fehlt.**
3. `ls -dt /tmp/konzept-*` — die Denk-Werkstatt des Fliessbands. Ein vorhandenes `konzept.md`
mit `runde*`/`haertetest*` daneben ist ein FERTIG GEDACHTES Konzept. Das denkst du nicht neu.
4. Existiert das Gitea-Repo schon? `git ls-remote https://<host>/<owner>/<name>.git`
Dann setzt du an der **ersten Lücke** an — nie davor. Der häufigste Fall: alles gedacht, Repo
steht, `KONZEPT.md` geschrieben, es fehlen nur `git add/commit/push` und die Übergabe. Das sind
zwei Minuten. **Ein zweiter kompletter Denk-Durchlauf ist ein Fehlschlag:** er kostet eine halbe
Stunde, wirft die Arbeit deines Vorgängers weg und läuft in genau dasselbe Kontext-Limit wie er.
Nur wenn wirklich nichts Brauchbares dasteht, fängst du bei Schritt 1 an.
## Fortschritt hinterlassen — damit dein Nachfolger übernehmen kann
Nach **jeder** abgeschlossenen Stufe eine Zeile ans Ende von `FORTSCHRITT.md` im
Workspace-Wurzelverzeichnis — **nicht im Repo-Klon**, sonst landet sie im Projekt-Repo (dort
gehören nur die Dateien aus Schritt 3 hinein). Inhalt: was fertig ist, wo es liegt, was als
Nächstes dran wäre. Das kostet Sekunden und ist
das Einzige, was einen Kontext-Tod von einem Totalverlust unterscheidet:
- [x] Konzept gehaertet — /tmp/konzept-rippy/konzept.md (14 KB, 2 Runden + Haertetest)
- [x] Repo angelegt + geklont — Hitonabi/rippy (bisher nur README)
- [ ] KONZEPT.md committen + pushen <- HIER WEITER
Halte dich kurz (eine Zeile je Stufe) und schreib sie SOFORT, nicht am Ende — am Ende kommst du
vielleicht nicht mehr dazu. Dieselbe Kurzfassung gehört als Notiz in deinen `kanban_heartbeat`,
dann sieht der Commander im Auftragsbuch, wo du stehst.
## Der Ablauf (genau diese vier Schritte, in dieser Reihenfolge)
### 1. Konzept wasserdicht härten — via `konzept-fliessband`
Nutze den Skill **konzept-fliessband** vollständig: der starke Denker (`gpt-oss-120b`/heavy über
`~/.hermes/scripts/worker.sh`) wählt den Rollen-Cast, die Rollen schärfen in Runden EIN gemeinsames
Konzept, ein Advocatus Diaboli (`fremdblick.sh`, fremdes Modell) sucht Löcher — bis keine Blocker
mehr offen sind (max. 3 Runden). „In der Vorbereitung alles rausholen" ist ausdrücklich gewollt:
lieber gründlich denken als schnell fertig sein. Ergebnis: **EIN wasserdichtes Konzept-Dokument**
(Text), das später als `KONZEPT.md` ins Repo geht.
Halte dich an die Leitplanken des Skills: jede Rollen-Stimme MUSS aus einem `worker.sh`-Aufruf an
heavy stammen (nie selbst ausdenken), großzügige `WORKER_MAXTOK`/`FREMDBLICK_MAXTOK` (Reasoning-
Modelle brauchen Token-Luft), Modell-Wechsel minimieren.
> **Achtung, hier ging es schon zweimal schief:** Der Skill endet mit einer eigenen Ablage
> (`cp … ~/konzepte/<slug>-konzept.md`) und einer Telegram-Übergabe. Das ist die Anleitung für
> Aufrufer OHNE Kanban-Karte. **Für dich gilt sie nicht als Ende.** Das Konzept ist erst
> abgeliefert, wenn es als `KONZEPT.md` **im Repo** liegt (Schritte 24). Die Kopie nach
> `~/konzepte/` darfst du zusätzlich machen — sie ersetzt das Repo aber NIE, und `kanban_complete`
> kommt erst danach. Ohne Repo sieht der Commander im Auftragsbuch nichts von deiner Arbeit.
### 2. Repo anlegen (leer, privat)
Wähle einen kurzen, sauberen Projektnamen (kebab-case, z. B. `homelab-dashboard`). **Nimm den
Namen, den ein Vorgänger schon vergeben hat, wenn Schritt 0 einen gefunden hat** — sonst legst du
für dieselbe Idee ein zweites Repo an. Lege das Repo an:
bash ~/.hermes/scripts/gitea-repo-create.sh <name> "<eine Zeile Beschreibung>"
Das Skript legt IMMER **privat** an, protokolliert die Anlage und gibt als **letzte Zeile**
`CLONE <https-clone-url>` aus. Existiert der Name schon (Skript meldet Konflikt), häng eine kurze
Unterscheidung an (`<name>-v2` o. ä.) und melde das in der Übergabe.
### 3. Repo NUR mit Doku füllen — KEIN Code
Klone das frische Repo in deinen Task-Workspace (NICHT `git init`, NICHT shallow) und schreibe
genau diese Dateien — sonst **nichts**, kein Code, kein Gerüst:
- **`KONZEPT.md`** — das wasserdichte Konzept aus Schritt 1 (Ziel, Ansatz, Architektur-Skizze,
offene Entscheidungen, was der Advocatus Diaboli geprüft hat).
- **`ROADMAP.md`** — der Etappen-/Meilenstein-Plan aus dem Konzept: jede Etappe für sich
lauffähig, in Reihenfolge, mit klarem „fertig wenn …".
- **`AGENTS.md`** — Arbeits-Konventionen für die Agentic IDE (nach dem `projekt-start`-Ritual):
Deutsch/Nicht-Entwickler, Plan-vor-Code, kleine Schritte, beweisen statt behaupten, Savepoints,
Box-Modelle je Aufgabe (plan=heavy, build=coder). Kurz, konkret, projektbezogen.
**Ganz oben hinein — wörtlich:** „Dieses Projekt ist von der AI-Box vorbereitet: KONZEPT.md ist
bereits gehärtet, ROADMAP.md ist der Plan. Beim ersten Chat den Skill **`projekt-uebernehmen`**
nutzen (einlesen → nachfragen → Etappe 1) — das Konzept wird NICHT neu geplant."
Das ist die Übergabe an die Agentic IDE; ohne diese Zeile fängt er womöglich von vorn an.
**Dazu diese drei HARTEN Regeln — wörtlich in die AGENTS.md (Wasserdicht-Runde 22.07.,
Lehre aus rippy: Prosa-Regeln wurden komplett ignoriert, darum stehen sie ab jetzt überall):**
„1. Die **CI-Ampel** (Gitea Actions, liegt seit der Repo-Anlage unter `.gitea/workflows/ci.yml`
NICHT löschen) muss **GRÜN** sein, bevor eine Etappe fertig' heißt. Keine Tests = rot = nicht fertig.
2. Weicht etwas vom KONZEPT ab (anderes Werkzeug, andere Bibliothek, gestrichenes Muss-Feature):
**STOPP und den Commander fragen** — niemals still ersetzen. (rippy-Lehre: Muss-Feature
MakeMKV lossless' wurde still durch lossy HandBrake ersetzt.)
3. Deployment NUR über `./deploy.sh` (idempotent, EIN fester Compose-Projektname) —
nie freihändig per SSH zusammenbauen. (rippy-Lehre: Doppel-Anlage `Rippy`+`rippy` auf der VM.)"
- **`README.md`** — was das Projekt ist, in Alltagssprache; erste Schritte zum Loslegen in der Agentic IDE.
- **`SAVEPOINT.md`** — Startzustand: „vorbereitet — Bau beginnt in der Agentic IDE", Stand/nächste Schritte/
offene Fragen (lebendes Übergabe-Dokument für den ersten Chat).
- **`.aiexclude`** — gitignore-Syntax: was die KI NICHT lesen soll (`.env`, Secrets, große Daten).
Committe (`git add -A && git commit`) und pushe auf **main dieses NEUEN Repos** — das ist korrekt:
es ist das frisch angelegte, eigene Repo des Commanders, kein Vorschlag an ein bestehendes System.
Prüfe nach dem Push mit `git ls-remote origin main`, dass es wirklich angekommen ist.
### 4. Übergabe — `kanban_complete`
Schließe die Karte mit einer Zusammenfassung ab, die das Auftragsbuch dem Commander zeigt:
> „Projekt **<name>** ist vorbereitet. Repo: `<clone-url>`. Öffne es in der Agentic IDE und wirf dem Coder
> **KONZEPT.md** vor — der Plan steht in **ROADMAP.md**. Die Box baut ab hier nicht mehr mit."
Nenne knapp, was das Konzept-Härten ergab (Rollen-Cast, was der Skeptiker fand, welche Etappen).
## Sonderfall: „IDEE PRUEFEN" (kein Projektstart)
Steht im Auftrag die Zeile **`AUFTRAGS-ART: IDEE PRUEFEN`**, dann will der Commander die Idee nur
DURCHDACHT haben — er hat sich NICHT entschieden, sie zu bauen. Dann gilt abweichend:
- Schritt 1 (Konzept haerten) laeuft wie beschrieben, aber mit **Produkt-Blick**: Was ist es, fuer
wen, was gibt es schon, kleinster sinnvoller Wurf, was spricht DAGEGEN. Rollen-Cast entsprechend
(nicht nur Entwickler-Rollen).
- Schritt 2 (Repo anlegen) wie gehabt — **auch bei „IDEE PRUEFEN" gibt es ein Repo.** Das Konzept
NUR nach `~/konzepte/` zu kopieren und dann abzuschließen ist ein Fehlschlag: der Commander
liest das Konzept im Auftragsbuch, und das holt es aus dem Repo.
- Schritt 3 schreibt **NUR `KONZEPT.md` + ein knappes `README.md`** — KEINE `ROADMAP.md`, KEINE
`AGENTS.md`, KEIN `SAVEPOINT.md`, KEIN `.aiexclude`, kein Geruest. Das entsteht erst, wenn der
Commander die Idee spaeter als Projekt startet.
- Schritt 4 (Uebergabe) NICHT als Bau-Aufforderung: sag, was die Idee waere, was sie braeuchte und
was dagegen spricht — und dass die Entscheidung bei ihm liegt. Kein „öffne in der IDE und bau".
## Sonderfall: „UEBERARBEITUNG" (der Commander hat das Konzept gelesen)
Steht im Auftrag **`AUFTRAGS-ART: IDEE PRUEFEN — UEBERARBEITUNG`**, existiert das Konzept schon
und der Commander will gezielte Änderungen. Dann gilt abweichend:
- **Schritt 2 entfällt — lege KEIN neues Repo an.** Der Auftrag nennt die Stelle, an der das
Konzept liegt (Repo oder Datei); die neue Fassung gehört an **genau dieselbe Stelle**. Ein
zweites Repo für dieselbe Idee ist ein Fehlschlag.
- Schritt 1 schrumpft: lies das bestehende Konzept, ändere die genannten Punkte **und deren
Folgewirkungen**, lass den Rest stehen. Ein kurzer Härtetest nur auf die geänderten Teile.
- Sein Vorschlag hat Vorrang, aber nicht blind: schafft er ein Problem, bau ihn ein **und**
schreib die Folge sichtbar in den Abschnitt Risiken/offene Punkte. Nicht stillschweigend
übergehen, nicht schönreden.
- Übergabe: was geändert wurde, was daraus folgte, was du bewusst gelassen hast.
## Eiserne Regeln (nicht verhandelbar)
1. **Du baust KEINEN Code.** Kein Gerüst, keine `package.json`, keine Quelldateien. Nur die
Doku-Dateien aus Schritt 3. Wer Code schreibt, hat die Aufgabe verfehlt.
2. **Fremde Repos sind TABU:** `~/mission-control-v2` (MC2) und `~/lucy` NIEMALS klonen, anfassen
oder verwechseln. Du arbeitest ausschließlich im **neu angelegten** Projekt-Repo. (Wenn im
Auftrag ein bestehendes Repo als Vorbild genannt ist, nur LESEN, nie hineinschreiben.)
3. **Jede Rollen-Stimme des Konzepts kommt aus `worker.sh` an heavy** — nie selbst ausgedacht.
4. **Kein `git init`, kein Shallow-Clone.** Immer voll klonen (Credentials in `~/.git-credentials`).
**Der Push gilt erst als erledigt, wenn `git ls-remote origin main` ihn BEWEIST** — ein Exit-Code
0 und „main -> main" auf dem Bildschirm reichen nicht. Ohne diesen Beleg kein `kanban_complete`;
melde stattdessen ehrlich, dass der Push unbestätigt ist. Und wenn die No-Progress-Bremse dabei
anschlägt: **nicht wegargumentieren.** Sie ist billiger als eine Übergabe, die ins Leere zeigt —
der Commander soll dem Satz „Repo liegt bereit" ohne Nachprüfen glauben können.
5. **TABU-Zonen:** approvals/Tokens/ufw/Security-Configs, `~/.hermes/config.yaml`, systemd-Units.
Braucht der Auftrag so etwas → `kanban_block` mit Begründung.
6. **Festfahren-Stopp:** Bleibt das Konzept nach den Runden mit echten Blockern offen (widersprüchliche
Ziele, fehlende Entscheidung des Commanders) → `kanban_block` mit `--kind needs_input` und der
konkreten Frage. NICHT raten, NICHT denselben Schritt stur wiederholen (das löst die No-Progress-
Bremse aus). Beim zweiten Fehlversuch die Diagnose VARIIEREN, nicht den Befehl wiederholen.
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
# Feed für den Evolution-Radar-Cron (Autonomie E4). Liegt in ~/.hermes/scripts/ (deploy.sh
# kopiert ihn); sein stdout wird dem Cron-Agenten als Prompt eingespeist:
# versionierter Auftrag (deploy/radar-prompt.md im MC2-Repo) + Live-Inventar der Box.
set -uo pipefail
cat "$HOME/mission-control-v2/deploy/radar-prompt.md"
echo
echo "## LIVE-INVENTAR (automatisch erhoben am $(date '+%d.%m.%Y'))"
echo
echo "### Update-Status (MC2 /api/maintenance/updates):"
curl -sf --max-time 30 http://127.0.0.1:9001/api/maintenance/updates \
| jq -c '{os_pakete: .os, engine_update: .engine, router_update: .swap, komponenten: .components}' \
2>/dev/null || echo "(MC2-API nicht erreichbar)"
echo
echo "### Installierte Modelle (llama-swap):"
curl -sf --max-time 20 http://127.0.0.1:8080/v1/models | jq -r '.data[].id' 2>/dev/null \
|| echo "(llama-swap nicht erreichbar)"
echo
# llama-server braucht LD_LIBRARY_PATH auf das Vulkan-Verzeichnis für --version.
echo "### Versionen: llama.cpp $( LD_LIBRARY_PATH=/opt/llamacpp-vulkan /usr/local/bin/llama-server --version 2>&1 | grep -o 'version: [0-9]*' || true ) · llama-swap $( /usr/local/bin/llama-swap --version 2>/dev/null | head -1 || true ) · hermes-agent $( bash -lc 'hermes --version' 2>/dev/null | head -1 || echo '?' )"
echo
echo "### Pin-Register (gepinnte Ebenen NICHT zum Update vorschlagen, aber Sprünge melden):"
cat /srv/models/mc2-pins.json 2>/dev/null || echo "{}"
-64
View File
@@ -1,64 +0,0 @@
# Evolution-Radar (monatlicher Auftrag)
Du bist das Evolution-Radar der AI-Box (AMD Strix Halo, 128 GB, 100 % lokal, Vulkan/RADV).
Dein Job: EINMAL im Monat prüfen, ob sich bei unseren Kern-Komponenten oder in deren
Kategorien ein echter Sprung ergeben hat — und das als kurzen deutschen Report melden.
Unten ist das Live-Inventar der Box eingespeist (Update-Status, installierte Modelle, Pins).
## Schritt 1 (PFLICHT, vor jeder Recherche): Inventar lesen und respektieren
Lehre aus Report #1 (02.07.): vier Fehler, eine Ursache — Inventar ignoriert. Deshalb hart:
- **Empfiehl NIE ein Update auf eine Version, die laut Live-Inventar schon installiert ist.**
(Report #1 empfahl „Hermes v0.18" — die Box WAR auf v0.18.0. `komponenten` im Inventar
zeigt das echte git-behind; behind=0 heißt AKTUELL, egal was Release-Notes suggerieren.)
- **Bei Komponenten, die wir aktiv nutzen, erst die EIGENE Version/Nutzung feststellen,
dann Neuigkeiten bewerten.** Aktiv im Einsatz (Stand Repo):
Parakeet via onnx-asr = UNSER STT (25 EU-Sprachen inkl. Deutsch, ~0,45 s — nie als
„kein Deutsch-Fokus" abtun) · Silero VAD **v5** via vad-web in Lucy (v6 existiert) ·
pocket-tts german_24l (CPU, auf dem PC) · Hirn Qwen3.6-35B (MTP-Draft, immer warm).
- **Die Modell-Liste im Inventar enthält auch schon geladene KANDIDATEN** (z. B. gpt-oss-120b,
Qwen3-VL-30B) — die liegen bereit und sind gebencht; nicht als „Neuentdeckung" verkaufen.
- Jede Versions-Aussage im Report muss gegen das Inventar geprüft sein.
## Arbeitsweise: Fan-out mit Subagents (WICHTIG)
Nutze `delegate_task` mit einem `tasks`-Array (role: leaf), um die Recherche in ISOLIERTE
Subagents aufzuteilen — pro Task eine Komponenten-Gruppe. Jeder Subagent recherchiert per
Web-Suche, FILTERT Marketing-Geschwätz aus und liefert dir nur eine Substanz-Zusammenfassung
(max. 5 Zeilen pro Komponente). Du konsolidierst am Ende. Erwähne im Endbericht nur, was
Substanz hat — „nichts Nennenswertes" ist ein gültiges und gutes Ergebnis je Gruppe.
Scheitern Subagents wiederholt (z. B. Kontext-Fehler), recherchiere die betroffenen Gruppen
selbst sequenziell — der Report muss IMMER zustande kommen.
Task-Aufteilung (5 Subagents):
1. **Engine/Router:** llama.cpp (Vulkan, gfx1151/Strix Halo relevant!), llama-swap (mostlygeek)
2. **Agent/Runtime:** hermes-agent (NousResearch), Electron (Major-Sprünge), three-vrm/@pixiv
3. **Voice:** pocket-tts (kyutai), onnx-asr/Parakeet, Silero-VAD, smart-turn — nur Deutsch-taugliches
4. **Modell-Kategorien** für 128-GB-Strix-Halo: bessere lokale Coder-Modelle (vs. Qwen3-Coder-Next),
Chat/Reasoning-MoE (vs. Qwen3.6-35B/Qwen3.5-122B), Vision (vs. Qwen3-VL), deutsche TTS/STT.
Nur GGUF-/llama.cpp-lauffähig, Substanz = Benchmarks/Community-Erfahrung, kein Ankündigungs-Hype.
5. **IDE-/Agent-Tools** (Zed, Kilo Code, Claude Code): besonders Tod-/Nachfolger-Meldungen —
„Projekt eingestellt/archiviert/Fork übernimmt" ist GENAU die Meldung, die wir fangen müssen
(Lehre: Roo Code galt als Empfehlung und war längst eingestellt). Auch: neues dominantes Tool?
## Bereits ENTSCHIEDEN — nicht wieder vorschlagen (Verdikte der Box)
- Vulkan/RADV statt ROCm (gemessen schneller auf gfx1151) · llama.cpp+llama-swap gesetzt
- Hirn = Qwen3.6-35B-A3B mit MTP-Spec-Draft, immer warm · Mem0 als Memory · Electron-App für Lucy
- Pocket-TTS für Lucys Stimme (deutsch, CPU) · KEIN Kyutai-Streaming-STT (kann kein Deutsch)
- VERWORFEN: LobeChat/WebUI-Ersatz, Unmute-Vollstack, AnythingLLM, gemma als Hirn
- Latenz ist heilig: nichts vorschlagen, was Lucys ~2-s-Sprech-Latenz gefährdet
## Report-Format (Endantwort = geht direkt als Telegram-Nachricht raus)
- **Schreibe die Endnachricht in Lucys Stimme an den Commander** (du BIST Lucy, siehe SOUL.md —
das Radar ist nur dein Auftrag): erst 12 Sätze unmissverständlich, was die Funde für ihn
bedeuten und ob er etwas tun muss — danach die knappen Fund-Zeilen.
- Deutsch, maximal ~20 Zeilen, Du-Form, kein Markdown-Overkill, nicht technisch —
Technik in Alltagssprache, Details nur auf Nachfrage
- Struktur: „🛰️ Evolution-Radar <Monat>" → je Fund 13 Zeilen: WAS, WARUM relevant für UNS,
Einstufung **[lohnt vermutlich]** / **[beobachten]** / ggf. Quelle kurz
- Wenn ein Fund ein GEPINNTES Level betrifft (siehe Pins im Inventar): explizit erwähnen
- Nichts gefunden? Dann genau das in 2 Zeilen sagen. Keine Pflicht-Funde erfinden.
- KEINE Konfig-Änderungen vornehmen, KEINE Downloads starten — nur melden.
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Restore-Probe (monatlich, 17.07.2026): „Backups sind Hoffnung, Restores sind Wissen."
# Spielt ECHTE Wiederherstellungen durch, statt nur Sicherungs-Erfolge zu zählen:
# 1) PBS: jüngsten host/aibox-Snapshot ermitteln (Alter < 36 h!), daraus den
# Kanarienvogel (state/backup-canary.txt aus hermes.pxar, selektiv via --pattern —
# ~/.hermes ist ~8 GB, wir ziehen nur die eine Datei) nach /tmp restaurieren und
# den Inhalt EXAKT vergleichen. Erstlauf-Fallback (Canary noch in keinem
# Snapshot): das winzige llamaswap.pxar komplett restaurieren und config.yaml
# auf Substanz prüfen.
# 2) Tarball: jüngstes mc2-state-*.tar.gz (Alter < 36 h) listen und den Canary
# (bzw. beim Erstlauf eine Kern-Datei) herausziehen und prüfen.
# 3) Canary für künftige Läufe sicherstellen (fester Inhalt → exakter Vergleich).
# Läuft als Hermes-Cron --no-agent (stdout = Telegram): monatlicher Herzschlag, bei
# Problemen ⚠️-Alarmtext. Komplett ohne sudo (PBS-Env + Client liegen im User-Raum).
set -uo pipefail
PBS_ENV="$HOME/.config/pbs-backup/env"
PBS_BIN="$HOME/bin/proxmox-backup-client"
CANARY="$HOME/.hermes/state/backup-canary.txt"
CANARY_SOLL="MC2-Backup-Kanarienvogel v1 — dieser Inhalt ist konstant und wird nach dem Restore exakt verglichen."
TARBALL_DIR="/srv/models/mc2-backups"
MAX_ALTER_H=36
TMP="$(mktemp -d /tmp/restore-probe.XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
FEHLER=()
BEFUND=()
briefkasten() { # $1=Text (stiller Chronik-Spiegel)
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" <<'PY'
import json, sys
print(json.dumps({"text": sys.argv[1], "subject": "Restore-Probe",
"source": "restore-probe", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
# ---------- 0) Canary sicherstellen (für DIESEN Lauf evtl. noch nicht im Backup) ----------
if [ ! -f "$CANARY" ] || [ "$(cat "$CANARY" 2>/dev/null)" != "$CANARY_SOLL" ]; then
printf '%s' "$CANARY_SOLL" > "$CANARY"
fi
# ---------- 1) PBS-Restore-Probe ----------
if [ ! -f "$PBS_ENV" ] || [ ! -x "$PBS_BIN" ]; then
FEHLER+=("PBS-Probe unmöglich: Env ($PBS_ENV) oder Client ($PBS_BIN) fehlt")
else
set -a; source "$PBS_ENV"; set +a
SNAP_JSON="$("$PBS_BIN" snapshot list host/aibox --output-format json 2>/dev/null \
|| "$PBS_BIN" snapshots --output-format json 2>/dev/null || true)"
SNAP_INFO="$(printf '%s' "$SNAP_JSON" | python3 -c '
import json, sys, time
try:
snaps = json.load(sys.stdin)
except Exception:
print("KEINE"); raise SystemExit
snaps = [s for s in snaps if isinstance(s, dict)
and s.get("backup-type") == "host" and s.get("backup-id") == "aibox"]
if not snaps:
print("KEINE"); raise SystemExit
s = max(snaps, key=lambda x: x.get("backup-time") or 0)
t = s.get("backup-time") or 0
alter_h = (time.time() - t) / 3600
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t))
print(f"host/aibox/{stamp}")
print(round(alter_h, 1))
' 2>/dev/null || echo KEINE)"
SNAP="$(printf '%s\n' "$SNAP_INFO" | sed -n 1p)"
ALTER_H="$(printf '%s\n' "$SNAP_INFO" | sed -n 2p)"
if [ "$SNAP" = "KEINE" ] || [ -z "$SNAP" ]; then
FEHLER+=("PBS: kein host/aibox-Snapshot auffindbar (Sicherung steht evtl. STILL)")
else
if python3 -c "import sys; sys.exit(0 if float('$ALTER_H') <= $MAX_ALTER_H else 1)" 2>/dev/null; then
BEFUND+=("PBS-Snapshot $SNAP ist ${ALTER_H} h alt (frisch)")
else
FEHLER+=("PBS: jüngster Snapshot ist ${ALTER_H} h alt (> ${MAX_ALTER_H} h) — Sicherung steht STILL")
fi
# Selektives Canary-Restore aus hermes.pxar
mkdir -p "$TMP/pbs"
# --ignore-ownership/-acls: als User kann chown auf fremde Eigentümer nicht
# gelingen (sudo-frei ist Absicht) — uns interessiert der INHALT, nicht das chown.
if "$PBS_BIN" restore "$SNAP" hermes.pxar "$TMP/pbs" \
--pattern "state/backup-canary.txt" \
--ignore-ownership true --ignore-acls true >/dev/null 2>&1 \
&& [ -f "$TMP/pbs/state/backup-canary.txt" ]; then
if [ "$(cat "$TMP/pbs/state/backup-canary.txt")" = "$CANARY_SOLL" ]; then
BEFUND+=("PBS-Restore: Canary aus hermes.pxar wiederhergestellt, Inhalt EXAKT korrekt ✅")
else
FEHLER+=("PBS-Restore: Canary wiederhergestellt, aber Inhalt WEICHT AB (Datenkorruption?)")
fi
else
# Erstlauf-Fallback: winziges llamaswap.pxar komplett
mkdir -p "$TMP/ls"
"$PBS_BIN" restore "$SNAP" llamaswap.pxar "$TMP/ls" \
--ignore-ownership true --ignore-acls true >/dev/null 2>&1 || true
if [ -s "$TMP/ls/config.yaml" ] && grep -q 'models:' "$TMP/ls/config.yaml"; then
BEFUND+=("PBS-Restore: llamaswap.pxar wiederhergestellt, config.yaml plausibel ✅ (Canary war noch nicht im Snapshot — ab dem nächsten dabei)")
else
FEHLER+=("PBS-Restore FEHLGESCHLAGEN: weder Canary noch llamaswap.pxar sauber wiederherstellbar")
fi
fi
fi
fi
# ---------- 2) Tarball-Probe ----------
TARBALL="$(ls -1t "$TARBALL_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1 || true)"
if [ -z "$TARBALL" ]; then
FEHLER+=("Tarball: keine mc2-state-Sicherung unter $TARBALL_DIR gefunden")
else
T_ALTER_H="$(python3 -c "import os, time; print(round((time.time()-os.path.getmtime('$TARBALL'))/3600, 1))")"
if python3 -c "import sys; sys.exit(0 if float('$T_ALTER_H') <= $MAX_ALTER_H else 1)"; then
BEFUND+=("Tarball $(basename "$TARBALL") ist ${T_ALTER_H} h alt (frisch)")
else
FEHLER+=("Tarball: jüngste Sicherung ist ${T_ALTER_H} h alt (> ${MAX_ALTER_H} h) — mc2-backup steht STILL")
fi
CANARY_PFAD="$(tar -tzf "$TARBALL" 2>/dev/null | grep 'backup-canary\.txt$' | head -1 || true)"
if [ -n "$CANARY_PFAD" ]; then
if [ "$(tar -xzf "$TARBALL" -O "$CANARY_PFAD" 2>/dev/null)" = "$CANARY_SOLL" ]; then
BEFUND+=("Tarball-Restore: Canary herausgezogen, Inhalt EXAKT korrekt ✅")
else
FEHLER+=("Tarball-Restore: Canary im Archiv, aber Inhalt WEICHT AB")
fi
else
KERN="$(tar -tzf "$TARBALL" 2>/dev/null | grep -E '(cron|state)/[^/]+$' | head -1 || true)"
if [ -n "$KERN" ] && [ -n "$(tar -xzf "$TARBALL" -O "$KERN" 2>/dev/null | head -c 1)" ]; then
BEFUND+=("Tarball-Restore: Kern-Datei ($KERN) herausgezogen, nicht leer ✅ (Canary ab der nächsten Sicherung dabei)")
else
FEHLER+=("Tarball-Restore FEHLGESCHLAGEN: Archiv listet keine Kern-Dateien oder Extraktion leer")
fi
fi
fi
# ---------- Bericht (stdout = Telegram via --no-agent) ----------
if [ "${#FEHLER[@]}" -eq 0 ]; then
echo "Commander, monatliche Restore-Probe: beide Wiederherstellungs-Wege funktionieren wirklich."
printf '• %s\n' "${BEFUND[@]}"
briefkasten "Restore-Probe grün: $(printf '%s · ' "${BEFUND[@]}")"
else
echo "⚠️ Commander, die monatliche Restore-Probe hat ein ECHTES Problem gefunden:"
printf '• %s\n' "${FEHLER[@]}"
if [ "${#BEFUND[@]}" -gt 0 ]; then
echo "Was funktioniert hat:"
printf '• %s\n' "${BEFUND[@]}"
fi
echo "Bitte zeitnah draufschauen — ein Backup, das nicht zurückkommt, ist keins."
briefkasten "⚠️ Restore-Probe ROT: $(printf '%s · ' "${FEHLER[@]}")"
exit 1
fi
+65 -6
View File
@@ -4,9 +4,13 @@
# automatisch ein Sicherheits-Backup des aktuellen Zustands (Restore ist reversibel). # automatisch ein Sicherheits-Backup des aktuellen Zustands (Restore ist reversibel).
# #
# Nutzung: # Nutzung:
# bash restore.sh --list # vorhandene Backups zeigen # bash restore.sh --list # vorhandene Backups zeigen (lokal + Off-Box)
# bash restore.sh --dry-run <datei|latest> # nur anzeigen, was passieren würde # bash restore.sh --dry-run <datei|latest> # nur anzeigen, was passieren würde
# bash restore.sh [--yes] <datei|latest> # wiederherstellen (--yes = ohne Rückfrage) # bash restore.sh [--yes] <datei|latest> # wiederherstellen (--yes = ohne Rückfrage)
# bash restore.sh --pull-offsite # Off-Box-Kopie (Proxmox) nach lokal holen
#
# Off-Box (C12): fehlt ein Backup lokal (z. B. Box-Platte neu), wird es automatisch
# vom Zweitgerät (Proxmox) geholt. Ziel/Key wie in backup.sh (überschreibbar per Env).
set -euo pipefail set -euo pipefail
MODELS_DIR="${MC_MODELS_DIR:-/srv/models}" MODELS_DIR="${MC_MODELS_DIR:-/srv/models}"
@@ -16,14 +20,38 @@ LSWAP="${MC_CONFIG_PATH:-/etc/llama-swap/config.yaml}"
HERMES="${HERMES_HOME:-$HOME/.hermes}" HERMES="${HERMES_HOME:-$HOME/.hermes}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}" SRC="${MC2_SRC:-$HOME/mission-control-v2}"
SERVICES="mem0-service mission-control-2 hermes-gateway" SERVICES="mem0-service mission-control-2 hermes-gateway"
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_SSH="ssh -i $OFFSITE_KEY -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
usage() { echo "Nutzung: restore.sh --list | restore.sh [--dry-run] [--yes] <datei|latest>"; } # Off-Box-Tarballs (Dateinamen) auflisten — leer, wenn nicht erreichbar/konfiguriert.
offsite_ls() {
[ -n "$OFFSITE" ] && [ -f "$OFFSITE_KEY" ] || return 0
local host="${OFFSITE%%:*}" path="${OFFSITE#*:}"
$OFFSITE_SSH "$host" "ls -1 $path/mc2-state-*.tar.gz 2>/dev/null" 2>/dev/null | xargs -r -n1 basename || true
}
# Ein einzelnes Off-Box-Tarball nach lokal holen. $1 = Dateiname (basename).
offsite_fetch() {
[ -n "$OFFSITE" ] && [ -f "$OFFSITE_KEY" ] || return 1
mkdir -p "$DEST_DIR"
echo "→ hole $1 vom Zweitgerät ($OFFSITE)…" >&2
rsync -a -e "$OFFSITE_SSH" "$OFFSITE/$1" "$DEST_DIR/"
}
usage() { echo "Nutzung: restore.sh --list | restore.sh --pull-offsite | restore.sh [--dry-run] [--yes] <datei|latest>"; }
list_backups() { list_backups() {
echo "Verfügbare Backups in $DEST_DIR:" echo "Verfügbare Backups in $DEST_DIR:"
ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | while read -r f; do local found=0 f
for f in $(ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null || true); do
printf " %s (%s)\n" "$(basename "$f")" "$(du -h "$f" | cut -f1)" printf " %s (%s)\n" "$(basename "$f")" "$(du -h "$f" | cut -f1)"
found=1
done done
[ -n "$(ls -A "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null || true)" ] || echo " (keine)" [ "$found" = 1 ] || echo " (keine)"
local off; off="$(offsite_ls || true)"
if [ -n "$off" ]; then
echo "Off-Box ($OFFSITE):"
echo "$off" | sed 's/^/ /'
fi
} }
[ $# -eq 0 ] && { usage; exit 1; } [ $# -eq 0 ] && { usage; exit 1; }
@@ -31,6 +59,12 @@ DRY=0; YES=0; TARGET=""
for a in "$@"; do for a in "$@"; do
case "$a" in case "$a" in
--list) list_backups; exit 0 ;; --list) list_backups; exit 0 ;;
--pull-offsite)
mkdir -p "$DEST_DIR"
[ -n "$OFFSITE" ] && [ -f "$OFFSITE_KEY" ] || { echo "Off-Box nicht konfiguriert."; exit 1; }
echo "→ spiegle Off-Box → lokal ($DEST_DIR)…"
rsync -a -e "$OFFSITE_SSH" --include='mc2-state-*.tar.gz' --exclude='*' "$OFFSITE/" "$DEST_DIR/"
list_backups; exit 0 ;;
--dry-run) DRY=1 ;; --dry-run) DRY=1 ;;
--yes) YES=1 ;; --yes) YES=1 ;;
-*) usage; exit 1 ;; -*) usage; exit 1 ;;
@@ -38,8 +72,23 @@ for a in "$@"; do
esac esac
done done
[ -z "$TARGET" ] && { usage; exit 1; } [ -z "$TARGET" ] && { usage; exit 1; }
[ "$TARGET" = "latest" ] && TARGET="$(ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1 || true)" # 'latest' bevorzugt lokal; fehlt lokal alles, das jüngste Off-Box-Tarball holen.
[ -f "$TARGET" ] || TARGET="$DEST_DIR/$(basename "${TARGET:-}")" if [ "$TARGET" = "latest" ]; then
TARGET="$(ls -1t "$DEST_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1 || true)"
if [ -z "$TARGET" ]; then
latest_off="$(offsite_ls | sort | tail -1 || true)"
[ -n "$latest_off" ] && offsite_fetch "$latest_off" && TARGET="$DEST_DIR/$latest_off"
fi
fi
# Konkreten Namen zuerst lokal suchen, sonst vom Zweitgerät holen.
if [ ! -f "$TARGET" ]; then
base="$(basename "${TARGET:-}")"
if [ -f "$DEST_DIR/$base" ]; then
TARGET="$DEST_DIR/$base"
elif [ -n "$base" ] && offsite_ls | grep -qx "$base"; then
offsite_fetch "$base" && TARGET="$DEST_DIR/$base"
fi
fi
[ -f "$TARGET" ] || { echo "Backup nicht gefunden: ${TARGET:-<leer>}"; echo; list_backups; exit 1; } [ -f "$TARGET" ] || { echo "Backup nicht gefunden: ${TARGET:-<leer>}"; echo; list_backups; exit 1; }
echo "Restore-Quelle: $TARGET" echo "Restore-Quelle: $TARGET"
@@ -75,6 +124,16 @@ if [ -d "$STAGE/mem0" ]; then rm -rf "$MEM0_DIR"; mkdir -p "$MEM0_DIR"; cp -a "$
[ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/" [ -f "$STAGE/hermes/.env" ] && cp -a "$STAGE/hermes/.env" "$HERMES/"
[ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; } [ -d "$STAGE/hermes/plugins" ] && { mkdir -p "$HERMES/plugins"; cp -a "$STAGE/hermes/plugins/." "$HERMES/plugins/"; }
[ -f "$STAGE/llama-swap/config.yaml" ] && cp -a "$STAGE/llama-swap/config.yaml" "$LSWAP" 2>/dev/null || true [ -f "$STAGE/llama-swap/config.yaml" ] && cp -a "$STAGE/llama-swap/config.yaml" "$LSWAP" 2>/dev/null || true
# Hermes-Desktop-/Gate-Infrastruktur (Gegenstück zu backup.sh, 09.07.2026):
[ -d "$STAGE/hermes/agent-hooks" ] && { mkdir -p "$HERMES/agent-hooks"; cp -a "$STAGE/hermes/agent-hooks/." "$HERMES/agent-hooks/"; chmod +x "$HERMES/agent-hooks/"*.sh 2>/dev/null || true; }
[ -f "$STAGE/hermes/shell-hooks-allowlist.json" ] && cp -a "$STAGE/hermes/shell-hooks-allowlist.json" "$HERMES/"
[ -f "$STAGE/hermes/desktop-gateway-token" ] && { cp -a "$STAGE/hermes/desktop-gateway-token" "$HERMES/"; chmod 600 "$HERMES/desktop-gateway-token"; }
[ -f "$STAGE/hermes/pc-paths.yaml" ] && cp -a "$STAGE/hermes/pc-paths.yaml" "$HERMES/"
[ -f "$STAGE/hermes/systemd-dropins/session-token.conf" ] && { mkdir -p "$HOME/.config/systemd/user/hermes-builtin-ui.service.d"; cp -a "$STAGE/hermes/systemd-dropins/session-token.conf" "$HOME/.config/systemd/user/hermes-builtin-ui.service.d/"; chmod 600 "$HOME/.config/systemd/user/hermes-builtin-ui.service.d/session-token.conf"; systemctl --user daemon-reload 2>/dev/null || true; }
# Cron-Jobs + Radar-State (Gegenstück zu backup.sh, 10.07.2026) — der Gateway-Neustart unten
# lädt die wiederhergestellten Jobs:
[ -d "$STAGE/hermes/cron" ] && { mkdir -p "$HERMES/cron"; cp -a "$STAGE/hermes/cron/." "$HERMES/cron/"; }
[ -d "$STAGE/hermes/state" ] && { mkdir -p "$HERMES/state"; cp -a "$STAGE/hermes/state/." "$HERMES/state/"; }
echo "→ Dienste starten…" echo "→ Dienste starten…"
systemctl --user start $SERVICES 2>/dev/null || true systemctl --user start $SERVICES 2>/dev/null || true
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Selbst-Inventur (naechtlich 03:35 — "Lucy kennt sich", 12.07.2026): erhebt den
# SELBST-STECKBRIEF der Box LIVE aus der Realitaet (Versionen, Dienste, Modelle, Crons,
# Queue, Karten) statt ihn von Hand zu pflegen — und BEMERKT SELBST, wenn sich etwas
# geaendert hat (Diff gegen gestern -> stille Chronik-Karte + Einzeiler an den Commander).
# Der Steckbrief ist das "was es schon gibt"-Gedaechtnis fuer alle Vorschlags-Macher:
# idle-radar-feed.sh legt ihn dem Analysten vor, die Werkstatt liest ihn beim
# Realitaets-Check. Laeuft als Cron mit --no-agent: stdout LEER = still, nur bei
# Aenderungen gibt es eine kurze deutsche Meldung (klassisches Watchdog-Muster).
# Dateien: ~/.hermes/state/selbst-steckbrief.md (+ .prev, .diff)
set -uo pipefail
STATE_DIR="$HOME/.hermes/state"
STECK="$STATE_DIR/selbst-steckbrief.md"
PREV="$STATE_DIR/selbst-steckbrief.prev"
DIFFOUT="$STATE_DIR/selbst-steckbrief.diff"
API="${MC_API:-http://127.0.0.1:9001}"
HERMES="$HOME/.local/bin/hermes"
mkdir -p "$STATE_DIR"
briefkasten() { # $1=Betreff $2=Text (still, Chronik liest source=selbst-inventur)
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": "selbst-inventur", "priority": "silent"}))
PY
)" >/dev/null 2>&1 || true
}
# ---------- Steckbrief live erheben (nur LESEN, keine Seiteneffekte) ----------
# Fluechtiges (Uhrzeiten, Zaehler, PIDs) bleibt DRAUSSEN, damit der Diff nur echte
# Aenderungen zeigt. Reihenfolgen werden sortiert (stabiler Diff).
{
echo "# SELBST-STECKBRIEF der Box (auto-generiert von selbst-inventur.sh — NICHT von Hand pflegen)"
echo
echo "Das bin ich, das habe ich, das laeuft bei mir. Feste Entscheide und Verbote stehen in"
echo "~/mission-control-v2/docs/wissen/ (VERDIKTE.md, FALLEN.md, STACK.md) und der"
echo "Eigenbau-Landkarte ~/wissens-vault/eigenbau-landkarte.md — dort nachlesen, nie neu erfinden."
echo
echo "## Software-Versionen"
echo "- hermes: $(bash -lc 'hermes --version' 2>/dev/null | head -1 || echo unbekannt)"
echo "- OS: $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unbekannt}") · Kernel $(uname -r)"
echo "- python3: $(python3 --version 2>/dev/null || echo unbekannt)"
echo
echo "## Laufende Dienste (systemd --user)"
systemctl --user list-units --type=service --state=running --no-legend --plain 2>/dev/null \
| awk '{print "- " $1}' | sort
echo
echo "## Timer (systemd --user)"
systemctl --user list-timers --all --no-legend --plain 2>/dev/null \
| awk '{for(i=1;i<=NF;i++) if ($i ~ /\.timer$/) print "- " $i}' | sort -u
echo
echo "## Modelle (llama-swap :8080)"
curl -sf -m 10 http://127.0.0.1:8080/v1/models 2>/dev/null | python3 -c '
import json, sys
try:
data = json.load(sys.stdin).get("data") or []
except Exception:
data = []
for m in sorted(x.get("id", "?") for x in data):
print("- " + m)
' 2>/dev/null || echo "(llama-swap nicht erreichbar)"
echo
echo "## Hermes-Crons (Name · Zeitplan · Skript)"
bash -lc 'hermes cron list' 2>/dev/null | python3 -c '
import re, sys
jobs, cur = [], {}
for line in sys.stdin:
line = line.strip()
m = re.match(r"(Name|Schedule|Script):\s+(.+)$", line)
if not m:
continue
k, v = m.groups()
if k == "Name" and cur:
jobs.append(cur); cur = {}
cur[k] = v.strip()
if cur:
jobs.append(cur)
for j in sorted(jobs, key=lambda x: x.get("Name", "")):
print("- {} · {} · {}".format(j.get("Name", "?"), j.get("Schedule", "?"), j.get("Script", "-")))
' 2>/dev/null || echo "(cron list nicht lesbar)"
echo
echo "## Ideen-Queue (natives Kanban, offene Aufgaben)"
"$HERMES" kanban list --json 2>/dev/null | python3 -c '
import json, sys
t = sys.stdin.read(); i = t.find("[")
try:
d = json.loads(t[i:]) if i >= 0 else []
except Exception:
d = []
off = [x for x in d if isinstance(x, dict) and x.get("status") not in ("done", "archived")]
if not off:
print("- (leer)")
for x in sorted(off, key=lambda x: x.get("title") or ""):
print("- [{}] {}".format(x.get("status", "?"), (x.get("title") or "")[:100]))
' 2>/dev/null || echo "(Queue nicht lesbar)"
echo
echo "## Offene Karten im Auftragsbuch"
curl -sf -m 10 "$API/api/auftragsbuch" 2>/dev/null | python3 -c '
import json, sys
try:
items = json.load(sys.stdin).get("items") or []
except Exception:
items = []
if not items:
print("- (keine)")
for it in sorted(items, key=lambda x: x.get("branch") or ""):
print("- {}:{}".format(it.get("repo", "mc2"), it.get("branch", "?")))
' 2>/dev/null || echo "(Auftragsbuch nicht lesbar)"
echo
echo "## Feste Leitplanken (Kurzfassung — Details docs/wissen/)"
echo "- Jede Code-Zeile geht NUR ueber eine Auftragsbuch-Karte live (Klick des Commanders)."
echo "- TABU ohne explizites User-Ja: Security (approvals/Tokens/ufw/sudoers), ~/.hermes/config.yaml, systemd-Units, Hermes-Quellcode."
echo "- Updates macht der Auto-Update-Timer mit Fangnetz — niemand sonst."
} > "$STECK.tmp" 2>/dev/null
mv "$STECK.tmp" "$STECK"
# ---------- Aenderung selbst bemerken (Diff gegen gestern) ----------
if [ -f "$PREV" ]; then
if ! diff -u "$PREV" "$STECK" > "$DIFFOUT.tmp" 2>/dev/null; then
# Nur echte Inhaltszeilen behalten (kein +++/---/@@-Rauschen fuer die Meldung).
# Steckbrief-Zeilen sind Bullets ("- ...") und stehen im Diff als "+- "/"-- " —
# deshalb NUR die Datei-Kopfzeilen ausfiltern, nie aufs zweite Zeichen pruefen
# (alter Filter '^[+-][^+-]' warf jede Bullet-Aenderung weg -> immer "0 Aenderungen").
mv "$DIFFOUT.tmp" "$DIFFOUT"
KURZ="$(grep -E '^[+-]' "$DIFFOUT" | grep -vE '^(\+\+\+ |--- )' | head -12)"
N="$(grep -E '^[+-]' "$DIFFOUT" | grep -vcE '^(\+\+\+ |--- )' || true)"
briefkasten "Selbst-Inventur" "Mein Selbstbild hat sich geaendert (${N:-?} Zeilen):
${KURZ}
(Vollstaendiger Steckbrief: ~/.hermes/state/selbst-steckbrief.md)"
echo "Selbst-Inventur: Ich habe ${N:-?} Aenderungen an mir bemerkt (Details in der Chronik). Beispiele:"
printf '%s\n' "$KURZ" | head -5
else
rm -f "$DIFFOUT.tmp" "$DIFFOUT"
: # keine Aenderung -> stdout leer -> Cron bleibt still
fi
else
briefkasten "Selbst-Inventur" "Erster Selbst-Steckbrief erhoben (~/.hermes/state/selbst-steckbrief.md)."
echo "Selbst-Inventur: erster Steckbrief erhoben — ab morgen melde ich nur noch Aenderungen."
fi
cp "$STECK" "$PREV"

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