13 Commits
Author SHA1 Message Date
HitonabiandClaude Opus 5 1c8f285151 Merge: v3-Umbau P0-P6 — Frontend-Architektur, Sicherheit, Werkzeug-Verlauf
Ampel / ampel (push) Successful in 35s
Zehn Commits vom Zweig umbau/v3-p0-ballast, Ampel dort gruen.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Nichts brach

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

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

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

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

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

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

## Ehrlich zum Nutzen

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

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

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

## Die Sicherheitsregel ist verdrahtet, nicht nur beschrieben

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

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

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

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

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

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

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

## Verifiziert im Browser

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

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

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

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

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

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

## Es hat sich beim ersten Blick bezahlt gemacht

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

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

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

## Was die Ansicht NICHT verspricht

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

## Umsetzung

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

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

## Verifiziert

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

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

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

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

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

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

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

## Der Schnitt

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

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

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

## Was BEWUSST noch nicht geschnitten ist

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

## Nebenbefund beim Pruefen: die eigene Anzeige log

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

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

## Verifiziert

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

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

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

## Backend: /api/stream

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

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

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

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

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

## Was bewusst FEHLT

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

## Frontend

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

## Gedrosselt statt abgeschaltet — eine Korrektur am eigenen Entwurf

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

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

## Verifiziert

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

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

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

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

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

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

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

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

## Statusleiste (Spezifikation §4.1)

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

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

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

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

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

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

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

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

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

## Bahnbreiten-Deckel (Befund B-10)

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

## Nebenbei

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

## Verifiziert im Browser

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

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

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

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

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

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

## Alte Lesezeichen brechen nicht

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

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

## Fehlergrenze pro Route (Befund B-13)

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

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

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

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

## Nebenbei

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

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

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

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

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

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

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

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

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

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

## HuggingFace-Token: liegt jetzt auf der Box

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

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

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

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

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

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

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

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

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

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

## Nachgemessen

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

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

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

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

Vier Eingriffe:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:27:49 +02:00
226 changed files with 14118 additions and 8358 deletions
+55 -37
View File
@@ -1,37 +1,55 @@
{ {
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "configurations": [
{ {
"name": "mc2", "name": "mc2",
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe", "runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
"runtimeArgs": [ "runtimeArgs": [
"-m", "-m",
"uvicorn", "uvicorn",
"app:app", "app:app",
"--app-dir", "--app-dir",
"F:\\Coding Stuff\\mission-control-2\\backend", "F:\\Coding Stuff\\mission-control-2\\backend",
"--port", "--port",
"9000" "9000"
], ],
"port": 9000 "port": 9000
}, },
{ {
"name": "frontend", "name": "frontend",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"], "runtimeArgs": [
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "run",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" }, "dev",
"autoPort": false, "--",
"port": 5180 "--port",
}, "5180",
{ "--strictPort"
"name": "frontend-mock", ],
"runtimeExecutable": "npm", "cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"], "env": {
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend", "MC_API_TARGET": "http://192.168.178.151:9001"
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" }, },
"autoPort": false, "autoPort": false,
"port": 5181 "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
}
]
}
+61 -2
View File
@@ -9,7 +9,8 @@
# #
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte # 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) + # Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis. # Frontend-Build inkl. TypeScript-Typecheck (tsc) + Frontend-Tests (vitest/jsdom) + ESLint
# + Buendel-Budget. Rot ist ein Ergebnis, kein Aergernis.
name: Ampel name: Ampel
on: [push, pull_request] on: [push, pull_request]
@@ -42,7 +43,65 @@ jobs:
rot=1 rot=1
else else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1 ( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
# Frontend-Tests + Linter (v3-Umbau P1, 28.08.2026).
# Bis dahin lief das Frontend als einziger Teil des Stacks voellig ungeprueft
# durch das Gate — 10 500 Zeilen, null Tests. Die Begruendung weiter oben
# ("echte Tests sind der Pruefstand mit LIVE-Diensten") gilt fuer die
# ML-schweren Python-Dienste, nicht fuer Frontend-Unit-Tests: die laufen in
# jsdom, brauchen weder Modell noch GPU und sind in Sekunden durch.
# Der Linter meldet 0 Fehler / ~90 Warnungen; rot wird nur bei Fehlern.
if [ $rot -eq 0 ]; then
echo "-- Vitest"
( cd frontend && npm test --silent ) || rot=1
echo "-- ESLint (Warnungen sind erlaubt, Fehler nicht)"
( cd frontend && npm run lint --silent ) || rot=1
fi
# Bündel-Budget (v3-Umbau P0, 28.08.2026): Der Start-Chunk ist das, was der
# Nutzer VOR dem ersten Bild lädt. Er lag bei 220 kB gzip, weil das eifrig
# geladene Cockpit Recharts mitzog; dist lag bei 27 MB wegen eines verwaisten
# Avatar-Modells. Ohne Deckel wächst beides unbemerkt zurück. Gemessen wird der
# FRISCHE Build im Runner — kein Vergleich mit dem committeten dist (das wäre
# über Node-Versionen hinweg flatterhaft und würde dauerhaft rot leuchten).
if [ $rot -eq 0 ] && [ -d frontend/dist/assets ]; then
# 28.08.2026, React 18 -> 19: Der Sprung kostete GEMESSEN 14 645 B gzip
# (118 762 -> 133 407) und riss das bis dahin geltende Budget von 125 000.
# Das Gate hat also getan, was es soll. Angehoben wurde es TROTZDEM —
# bewusst und einmalig, nicht weil es im Weg stand:
# · Der Zuwachs IST die Plattform, nicht Wildwuchs. Es gibt hier nichts
# wegzulassen, so wie beim Router (P2), wo Schublade und Palette
# hinter lazy() wanderten und das Budget scharf blieb.
# · MC2 ist eine LAN-Appliance. 14 kB sind ueber Gigabit-Ethernet keine
# messbare Wartezeit; das Budget existiert gegen DRIFT (ein 24-MB-Avatar,
# eine 95-kB-Diagramm-Bibliothek auf der Startseite), nicht gegen einen
# ueberlegten Plattform-Schritt.
# Der Abstand zum Budget bleibt derselbe wie vorher (~5 %).
budget_gz=140000 # Stand nach React 19: 133 407 B gzip
budget_dist=3145728 # Stand nach P0: 1 477 852 B
# Den Einstiegs-Chunk aus index.html lesen, NICHT per Glob raten:
# Rollup nennt auch kleine geteilte Module "index-*.js" (gemessen: ein
# 67-Byte-Chunk neben dem 375-kB-Einstieg). `ls | head -1` haette je nach
# Hash den falschen erwischt — und das Budget waere still immer gruen.
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' frontend/dist/index.html | head -1)
haupt="frontend/dist/$einstieg"
if [ -n "$haupt" ]; then
gz=$(gzip -c "$haupt" | wc -c)
echo "-- Start-Chunk: $gz B gzip (Budget $budget_gz)"
if [ "$gz" -gt "$budget_gz" ]; then
echo "❌ Start-Bündel über Budget. Meist eine neue Bibliothek, die über"
echo " das eifrig geladene Cockpit hereinkommt — hinter lazy() legen."
rot=1
fi
fi
gesamt=$(du -sb frontend/dist | cut -f1)
echo "-- dist gesamt: $gesamt B (Budget $budget_dist)"
if [ "$gesamt" -gt "$budget_dist" ]; then
echo "❌ dist über Budget — Ballast? (Schrift-Subsets, Medien, WOFF 1)"
rot=1
fi
fi
if [ $rot -eq 0 ]; then if [ $rot -eq 0 ]; then
echo "== Frontend: Push nach release-dist ==" echo "== Frontend: Push nach release-dist =="
git config --global user.name "Gitea Actions" git config --global user.name "Gitea Actions"
+19 -24
View File
@@ -1,24 +1,19 @@
# Python # Python
backend/.venv/ backend/.venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# Node / Vite # Node / Vite
frontend/node_modules/ frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ # frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git. # Env / local
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git. *.env
frontend/public/avatar.vrm .DS_Store
frontend/dist/avatar.vrm
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
# Env / local box_recon*
*.env gemma_swap*
.DS_Store
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo) frontend/tsconfig.tsbuildinfo
box_recon*
gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
+67 -68
View File
@@ -1,68 +1,67 @@
# AGENTS.md — Mission Control 2.0 # AGENTS.md — Mission Control 2.0
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei). 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/`. Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
## Was das ist ## Was das ist
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten: Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) · Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind. **Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):** **Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
- **System:** Bosgame M5 (AMD Strix Halo APU) - **System:** Bosgame M5 (AMD Strix Halo APU)
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar). - **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs. - **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht. **Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
## Sprache (nicht verhandelbar) ## Sprache (nicht verhandelbar)
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen, - **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare. Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht). - **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. Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
## Build & Deploy (die häufigste Falle) ## Build & Deploy (die häufigste Falle)
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die - **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand. **das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.) - **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht). - **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Agentic IDE & Vibe Coding
## Agentic IDE & Vibe Coding - **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
- **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.
- 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.
- 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.
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
## Zeit & Umgebung
## Zeit & Umgebung - **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen (siehe `backend/services/reminders.py`).
(siehe `backend/services/reminders.py`).
## Backend-Konventionen
## Backend-Konventionen - Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
- 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
(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`);
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`); keine zweite Allowlist in einem Router duplizieren.
keine zweite Allowlist in einem Router duplizieren. - **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen. - Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap = System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen. - ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht `hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also, was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst. Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still. (Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.) - Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
## Grenzen (Verdikt, eingehalten)
## Grenzen (Verdikt, eingehalten) - **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
- **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.**
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.** - Alles reversibel halten: Branch + Backup + Pin.
- Alles reversibel halten: Branch + Backup + Pin.
## Gitea
## Gitea Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
+11
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import agent_aktivitaet
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")
@@ -39,3 +40,13 @@ def set_brain(body: SetBrainReq) -> dict:
def set_brain_model(body: BrainReq) -> dict: def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model) ok = update_brain_model(body.model)
return {"ok": ok} return {"ok": ok}
@router.get("/agent/aktivitaet")
def aktivitaet(limit: int = 60) -> dict:
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was
NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht.
"""
return agent_aktivitaet.uebersicht(max(1, min(limit, 300)))
+85 -37
View File
@@ -1,25 +1,30 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt. """Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden Zwei Endpunkte, ein Sammler:
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare), GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus · `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token- · `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern nur `/api/events` kennt. Danach entfernen.
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
Sekunden), Modell-Quelle aus der Karte übernommen.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource `/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
selbst und bis dahin pollt die UI wie bisher). sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
Frontend-Gegenstück: frontend/src/lib/events.ts
""" """
import asyncio import asyncio
@@ -35,7 +40,8 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen) METRIK_S = 1.0 # Takt der Messpunkte
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
@@ -66,6 +72,13 @@ def _fingerprints() -> dict[str, object]:
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models())) fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception: except Exception:
pass pass
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
from services import jobengine
fp["jobs"] = json.dumps(
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs()]
)
except Exception:
pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes # Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"), fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json")) _mtime(MODELS_DIR / "mc2-announce-branches.json"))
@@ -73,28 +86,63 @@ def _fingerprints() -> dict[str, object]:
return fp return fp
@router.get("/events") async def _strom(request: Request, mit_metrik: bool):
async def events(request: Request) -> StreamingResponse: """Gemeinsamer Kern beider Endpunkte.
async def strom():
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) — Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken. ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints() """
yield ": verbunden\n\n" alt = _fingerprints()
seit_ping = 0.0 yield ": verbunden\n\n"
while True:
if await request.is_disconnected(): seit_abdruck = 0.0
return seit_ping = 0.0
await asyncio.sleep(TICK_S) takt = METRIK_S if mit_metrik else ABDRUCK_S
seit_ping += TICK_S
while True:
if await request.is_disconnected():
return
await asyncio.sleep(takt)
seit_abdruck += takt
seit_ping += takt
if mit_metrik:
try:
from services.system import metrik_punkt
yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n"
seit_ping = 0.0
except Exception:
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
if seit_abdruck >= ABDRUCK_S:
seit_abdruck = 0.0
neu = _fingerprints() neu = _fingerprints()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]] keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu) alt.update(neu)
if keys: if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n" yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0 seit_ping = 0.0
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
return StreamingResponse(strom(), media_type="text/event-stream", if seit_ping >= KEEPALIVE_S:
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) yield ": ping\n\n"
seit_ping = 0.0
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
"""Der aktuelle Kanal: Anstöße UND Messpunkte."""
return StreamingResponse(_strom(request, mit_metrik=True),
media_type="text/event-stream", headers=_KOPF)
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
"""Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor
dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen."""
return StreamingResponse(_strom(request, mit_metrik=False),
media_type="text/event-stream", headers=_KOPF)
+46 -23
View File
@@ -1,19 +1,28 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.""" """Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
from fastapi import APIRouter, Header, HTTPException v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
mit einem im Browser geparkten Geheimnis übertüncht.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import maintenance from services import geheimnisse, maintenance
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class SudoReq(BaseModel):
sudo_password: str | None = None
class RestartReq(BaseModel): class RestartReq(BaseModel):
service: str service: str
sudo_password: str | None = None
class GeheimnisReq(BaseModel):
schluessel: str
wert: str | None = None
@router.get("/maintenance/updates") @router.get("/maintenance/updates")
@@ -28,32 +37,32 @@ def update_details(kind: str) -> dict:
return maintenance.update_details(kind) return maintenance.update_details(kind)
@router.post("/maintenance/check-updates") @router.post("/maintenance/check-updates")
def check_updates(body: SudoReq) -> dict: def check_updates() -> dict:
res = maintenance.check_updates_job(body.sudo_password) res = maintenance.check_updates_job()
if isinstance(res, dict) and not res.get("ok", True): if isinstance(res, dict) and not res.get("ok", True):
return res return res
return res return res
@router.post("/maintenance/os-update") @router.post("/maintenance/os-update")
def os_update(body: SudoReq) -> dict: def os_update() -> dict:
res = maintenance.os_update_job(body.sudo_password) res = maintenance.os_update_job()
if isinstance(res, dict) and not res.get("ok", True): if isinstance(res, dict) and not res.get("ok", True):
return res return res
return res return res
@router.post("/maintenance/engine-update") @router.post("/maintenance/engine-update")
def engine_update(body: SudoReq) -> dict: def engine_update() -> dict:
res = maintenance.engine_update_job(body.sudo_password) res = maintenance.engine_update_job()
if not res: if not res:
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).") raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
return res return res
@router.post("/maintenance/swap-update") @router.post("/maintenance/swap-update")
def swap_update(body: SudoReq) -> dict: def swap_update() -> dict:
res = maintenance.swap_update_job(body.sudo_password) res = maintenance.swap_update_job()
if not res: if not res:
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).") raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
return res return res
@@ -65,20 +74,34 @@ def hermes_update() -> dict:
@router.post("/maintenance/update-all") @router.post("/maintenance/update-all")
def update_all(body: SudoReq) -> dict: def update_all() -> dict:
return maintenance.update_all_job(body.sudo_password) return maintenance.update_all_job()
@router.post("/maintenance/reboot") @router.post("/maintenance/reboot")
def reboot(body: SudoReq) -> dict: def reboot() -> dict:
return maintenance.reboot(body.sudo_password) return maintenance.reboot()
@router.post("/maintenance/restart") @router.post("/maintenance/restart")
def restart(body: RestartReq) -> dict: def restart(body: RestartReq) -> dict:
return maintenance.restart_service(body.service, body.sudo_password) return maintenance.restart_service(body.service)
@router.get("/maintenance/logs") @router.get("/maintenance/logs")
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict: def logs(service: str, lines: int = 200) -> dict:
return maintenance.logs(service, lines, x_sudo_password) return maintenance.logs(service, lines)
@router.get("/maintenance/geheimnisse")
def geheimnisse_status() -> dict:
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
return geheimnisse.status()
@router.post("/maintenance/geheimnisse")
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
if not geheimnisse.setzen(body.schluessel, body.wert):
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
return {"ok": True, **geheimnisse.status()}
+6 -4
View File
@@ -4,7 +4,7 @@ import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from services import budget, discover, hf, jobengine, llamaswap from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@@ -77,7 +77,9 @@ class InstallReq(BaseModel):
quant: str = "Q4_K_M" quant: str = "Q4_K_M"
ctx: int | None = None ctx: int | None = None
jinja: bool = False jinja: bool = False
hf_token: str | None = None # Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
@router.get("/hf/search") @router.get("/hf/search")
@@ -144,8 +146,8 @@ def install(req: InstallReq) -> dict:
args.append(info["mmproj"]) args.append(info["mmproj"])
args += ["--local-dir", str(target)] args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV) env = dict(HF_DOWNLOAD_ENV)
if req.hf_token: if token := geheimnisse.hf_token():
env["HF_TOKEN"] = req.hf_token env["HF_TOKEN"] = token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env, job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
on_done=_apply_role if role else None) on_done=_apply_role if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
+176
View File
@@ -0,0 +1,176 @@
"""Werkzeug-Verlauf des Agenten (v3-Umbau P6).
WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah
dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die
Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode
zu patchen.
Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes
protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen
ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem
Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft
nützlicher als der Fließtext ihrer Gedanken.
WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines
Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie
halten kann.
ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit
mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine
Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre.
"""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
log = logging.getLogger(__name__)
# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset.
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
LOG_PFAD = Path(os.path.expanduser(
os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log")))
# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen
# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts.
LESE_BYTES = 512 * 1024
# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht
# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen.
#
# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars)
# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars)
# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…}
# INFO agent.tool_executor: tool web_extract failed (0.17s): {…}
_ZEIT = r"(?P<zeit>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+"
_LAUF = r"(?:\[(?P<lauf>[^\]]+)\] )?"
_FERTIG = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) completed "
r"\((?P<dauer>[\d.]+)s, (?P<zeichen>\d+) chars\)")
_FEHLER = re.compile(
_ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P<werkzeug>\S+) "
r"(?:failed|returned error) \((?P<dauer>[\d.]+)s\)(?::\s*(?P<detail>.*))?")
def _schwanz(pfad: Path, n: int) -> list[str]:
"""Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und
wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser."""
try:
groesse = pfad.stat().st_size
with pfad.open("rb") as f:
f.seek(max(0, groesse - n))
roh = f.read()
zeilen = roh.decode("utf-8", errors="replace").splitlines()
return zeilen[1:] if groesse > n and zeilen else zeilen
except OSError:
return []
def _kurz(detail: str | None) -> str | None:
"""Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein
JSON ab; interessant ist daran nur das `error`-Feld."""
if not detail:
return None
treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail)
text = treffer.group(1) if treffer else detail.strip()
return (text[:297] + "") if len(text) > 300 else text
def rufe(limit: int = 60) -> list[dict]:
"""Die jüngsten Werkzeug-Rufe, ältester zuerst."""
ergebnis: list[dict] = []
for zeile in _schwanz(LOG_PFAD, LESE_BYTES):
m = _FERTIG.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": int(m.group("zeichen")),
"ok": True,
"fehler": None,
})
continue
m = _FEHLER.match(zeile)
if m:
ergebnis.append({
"zeit": _iso(m.group("zeit")),
"lauf": m.group("lauf"),
"werkzeug": m.group("werkzeug"),
"dauer_s": float(m.group("dauer")),
"zeichen": None,
"ok": False,
"fehler": _kurz(m.group("detail")),
})
return ergebnis[-limit:]
def _iso(s: str) -> str:
"""`2026-08-28 07:03:18` → ISO MIT Zeitzone.
Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen
wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine
Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten
(AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer
über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine
Falle, die erst zur Zeitumstellung zuschnappt."""
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat()
except ValueError:
return s
def uebersicht(limit: int = 60) -> dict:
"""Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die
wiederkehrenden Fehler zusammengefasst.
Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet
in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht."""
liste = rufe(limit)
if not LOG_PFAD.exists():
return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [],
"werkzeuge": [], "laeufe": []}
bilanz: dict[str, dict] = {}
for r in liste:
b = bilanz.setdefault(r["werkzeug"], {
"werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0,
"dauer_summe": 0.0, "letzter_fehler": None,
})
b["rufe"] += 1
b["dauer_summe"] += r["dauer_s"]
if not r["ok"]:
b["fehler"] += 1
b["letzter_fehler"] = r["fehler"]
werkzeuge = sorted(
({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)}
for b in bilanz.values()),
key=lambda b: (-b["fehler"], -b["rufe"]),
)
# Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung —
# die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen).
laeufe: list[dict] = []
gesehen: dict[str, dict] = {}
for r in liste:
schluessel = r["lauf"] or "(interaktiv)"
if schluessel not in gesehen:
gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"],
"rufe": 0, "fehler": 0}
laeufe.append(gesehen[schluessel])
e = gesehen[schluessel]
e["bis"] = r["zeit"]
e["rufe"] += 1
if not r["ok"]:
e["fehler"] += 1
return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste,
"werkzeuge": werkzeuge, "laeufe": laeufe}
+100
View File
@@ -0,0 +1,100 @@
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
gleichbedeutend mit Root auf der Box gewesen.
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
erreicht — nicht, dass die Datei unlesbar wäre.
"""
import json
import logging
import os
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
ERLAUBT = frozenset({"hf_token"})
def _lesen() -> dict[str, str]:
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
try:
daten = json.loads(PFAD.read_text(encoding="utf-8"))
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
except (OSError, ValueError):
return {}
def _schreiben(daten: dict[str, str]) -> bool:
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
tmp = PFAD.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
tmp.replace(PFAD)
return True
except OSError as exc:
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
return False
def hf_token() -> str | None:
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
def setzen(schluessel: str, wert: str | None) -> bool:
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
if schluessel not in ERLAUBT:
return False
daten = _lesen()
if wert:
daten[schluessel] = wert
else:
daten.pop(schluessel, None)
return _schreiben(daten)
def status() -> dict[str, bool]:
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
daten = _lesen()
return {
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
"schreibbar": _schreibbar(),
}
def _schreibbar() -> bool:
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
try:
PFAD.parent.mkdir(parents=True, exist_ok=True)
return os.access(PFAD.parent, os.W_OK)
except OSError:
return False
+12 -23
View File
@@ -57,39 +57,29 @@ def _pump_output(job: dict, stream) -> None:
commit() commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None): def _run_job(job_id: str, args: list[str], env: dict | None = None):
"""Job-Prozess starten und mitschreiben.
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
job = JOBS[job_id] job = JOBS[job_id]
job["state"] = "running" job["state"] = "running"
try: try:
actual_args = list(args)
if sudo_password is not None:
for i, arg in enumerate(actual_args):
if isinstance(arg, str):
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
proc = subprocess.Popen( proc = subprocess.Popen(
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if sudo_password is not None else None, stdin=subprocess.DEVNULL,
bufsize=0, bufsize=0,
env={**os.environ, **(env or {})}, env={**os.environ, **(env or {})},
) )
_PROCS[job_id] = proc _PROCS[job_id] = proc
if sudo_password is not None and proc.stdin:
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
proc.stdin.flush()
proc.stdin.close()
_pump_output(job, proc.stdout) _pump_output(job, proc.stdout)
proc.wait() proc.wait()
job["returncode"] = proc.returncode job["returncode"] = proc.returncode
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed") job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
# Check if failed due to sudo authorization failure
if proc.returncode != 0 and job["log"]:
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: except Exception as exc:
_append_log(job, f"[mc] Fehler: {exc}") _append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed" job["state"] = "failed"
@@ -148,9 +138,8 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
sudo_password: str | None = None, group: str | None = None) -> str: group: str | None = None) -> str:
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
# Mask password in log if present in args
log_args = list(args) log_args = list(args)
JOBS[job_id] = { JOBS[job_id] = {
"id": job_id, "label": label, "state": "queued", "group": group, "id": job_id, "label": label, "state": "queued", "group": group,
@@ -159,7 +148,7 @@ def start_job(args: list[str], label: str, env: dict | None = None, on_done=None
} }
if on_done: if on_done:
JOBS[job_id]["_on_done"] = on_done JOBS[job_id]["_on_done"] = on_done
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start() threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
return job_id return job_id
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -20,7 +20,7 @@ import time
import httpx import httpx
import psutil import psutil
from config import HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL from config import HERMES_API_KEY, HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap from services import announce, llamaswap
@@ -34,14 +34,26 @@ REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90")) DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
def _reach(url: str, path: str = "/health") -> bool: def _reach(url: str, path: str = "/health", headers: dict[str, str] | None = None) -> bool:
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
"laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert.
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht —
und er haette einen echten Auth-Fehler unter sich begraben."""
try: try:
with httpx.Client(timeout=5.0) as c: with httpx.Client(timeout=5.0) as c:
return c.get(f"{url}{path}").status_code < 500 return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
except Exception: except Exception:
return False return False
def _hermes_kopf() -> dict[str, str]:
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
def _check_engine() -> bool: def _check_engine() -> bool:
return llamaswap.engine_reachable() return llamaswap.engine_reachable()
@@ -70,7 +82,7 @@ CHECKS: dict[str, tuple] = {
"brain": (_check_brain, "brain": (_check_brain,
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.", "Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
"Mein Gehirn ist wieder geladen. Alles klar bei mir."), "Mein Gehirn ist wieder geladen. Alles klar bei mir."),
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models"), "hermes": (lambda: _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()),
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.", "Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
"Der Agent-Dienst läuft wieder."), "Der Agent-Dienst läuft wieder."),
"voice": (lambda: _reach(VOICE_SERVICE_URL), "voice": (lambda: _reach(VOICE_SERVICE_URL),
+55
View File
@@ -10,6 +10,7 @@ import glob
import os import os
import subprocess import subprocess
import threading import threading
import time
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
@@ -197,5 +198,59 @@ def system_status() -> dict:
"gpu": _gpu_sysfs(), "gpu": _gpu_sysfs(),
"temp": _temps(), "temp": _temps(),
"disk": disk, "disk": disk,
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
"uptime_s": _uptime_s(),
"versions": check_versions_cached(), "versions": check_versions_cached(),
} }
def metrik_punkt() -> dict:
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
verfaelscht nichts."""
vm = psutil.virtual_memory()
temp = _temps() or {}
gpu = _gpu_sysfs() or {}
try:
from services.token_stats import get_stats
tok = get_stats()
except Exception:
tok = {}
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
disk = None
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.percent,
"ram_used": vm.used,
"ram_total": vm.total,
"gpu": gpu.get("busy_percent"),
"disk": disk,
"temp_cpu": temp.get("cpu"),
"temp_gpu": temp.get("gpu"),
"uptime_s": _uptime_s(),
"tok_p": tok.get("prompt_tokens", 0),
"tok_c": tok.get("completion_tokens", 0),
}
def _uptime_s() -> int | None:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
try:
return int(time.time() - psutil.boot_time())
except Exception:
return None
+16
View File
@@ -67,6 +67,22 @@ jobs:
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; } (cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
# Buendel-Budget: das Start-Chunk ist, was der Nutzer VOR dem ersten Bild laedt.
# Ohne Deckel waechst es unbemerkt zurueck (eine eifrig geladene Diagramm-Lib
# reicht). Gemessen wird der frische Build, nicht das committete dist.
if [ -d "$d/dist/assets" ]; then
# Einstiegs-Chunk aus index.html lesen, nicht per Glob raten: Rollup nennt
# auch kleine geteilte Module "index-*.js", und dann misst der Glob den
# falschen — das Budget waere still immer gruen.
einstieg=$(grep -o 'assets/index-[A-Za-z0-9_-]*\.js' "$d/dist/index.html" | head -1)
haupt="$d/$einstieg"
if [ -n "$haupt" ]; then
gz=$(gzip -c "$haupt" | wc -c)
echo "-- Start-Chunk: $gz B gzip (Budget 200000)"
[ "$gz" -gt 200000 ] && { echo "❌ Start-Buendel ueber Budget — hinter lazy() legen."; rot=1; }
fi
fi
done <<< "$pkg_dateien" done <<< "$pkg_dateien"
fi fi
+325 -328
View File
@@ -1,328 +1,325 @@
# Konzept: Bare-Metal-Wiederaufbau + First-Run-Wizard (VOLLSTÄNDIG) # Konzept: Bare-Metal-Wiederaufbau + First-Run-Wizard (VOLLSTÄNDIG)
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04, > **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein. > AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, 3D-Avatare, Voice/Klonstimme, > **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, Voice/Klonstimme,
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard: > Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
> **1:1 zurück** · **neu/Default** · **weglassen**. > **1:1 zurück** · **neu/Default** · **weglassen**.
> >
> Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut > Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut
> noch nichts. Stand: 2026-06-28. > noch nichts. Stand: 2026-06-28.
--- ---
## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt ## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt
Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**: Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
| Modus | Bedeutung | | Modus | Bedeutung |
|---|---| |---|---|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). | | 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme). |
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). | | 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil). |
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. | | ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
entweder den exakten alten Stand oder kann gezielt entrümpeln. entweder den exakten alten Stand oder kann gezielt entrümpeln.
--- ---
## 2. Was es schon gibt (wiederverwenden) ## 2. Was es schon gibt (wiederverwenden)
| Asset | Datei | Deckt ab | | Asset | Datei | Deckt ab |
|---|---|---| |---|---|---|
| Code-Deploy | `deploy/deploy.sh` | git pull + venvs + Units + Restart. **Setzt Engine/llama-swap/Dirs/venvs voraus.** | | Code-Deploy | `deploy/deploy.sh` | git pull + venvs + Units + Restart. **Setzt Engine/llama-swap/Dirs/venvs voraus.** |
| Zustands-Backup | `deploy/backup.sh` + `mc2-backup.{service,timer}` | **Aktuell** (live): mem0, `~/.hermes/{config.yaml,.env,plugins}`, llama-swap-config. Retention 14. **Für echtes 1:1 zu erweitern** — fertiges Snippet in **Anhang B** (§5). | | Zustands-Backup | `deploy/backup.sh` + `mc2-backup.{service,timer}` | **Aktuell** (live): mem0, `~/.hermes/{config.yaml,.env,plugins}`, llama-swap-config. Retention 14. **Für echtes 1:1 zu erweitern** — fertiges Snippet in **Anhang B** (§5). |
| Restore | `deploy/restore.sh` | Spielt Tarball zurück (mit Pre-Restore-Sicherung). | | Restore | `deploy/restore.sh` | Spielt Tarball zurück (mit Pre-Restore-Sicherung). |
| Engine | `deploy/provision-engine.sh` | llama.cpp Vulkan/RADV-Build (root). | | Engine | `deploy/provision-engine.sh` | llama.cpp Vulkan/RADV-Build (root). |
| Voice-Setup | `voice_service/install.sh` | venv + Piper-Binary + dt. Piper-Stimmen + Chatterbox (best-effort). | | Voice-Setup | `voice_service/install.sh` | venv + Piper-Binary + dt. Piper-Stimmen + Chatterbox (best-effort). |
| Updates | `deploy/update-engine.sh`, `update-swap.sh` | Laufende Engine/Router-Updates. | | Updates | `deploy/update-engine.sh`, `update-swap.sh` | Laufende Engine/Router-Updates. |
| Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. | | Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. |
--- ---
## 3. VOLLSTÄNDIGER Komponenten-Katalog ## 3. VOLLSTÄNDIGER Komponenten-Katalog
> **Scan-verifiziert (2026-06-28)** gegen `backend/config.py`, alle `backend/services/*` & `routers/*`, > **Scan-verifiziert (2026-06-28)** gegen `backend/config.py`, alle `backend/services/*` & `routers/*`,
> `frontend/src/{nav.ts,views,components/voice}`, `voice_service/app.py`, `mem0_service/`, `deploy/*`. > `frontend/src/{nav.ts,views,components/voice}`, `voice_service/app.py`, `mem0_service/`, `deploy/*`.
> Alle persistenten Schreibpfade, Dienste, Secrets und Env-Vars sind unten erfasst. > Alle persistenten Schreibpfade, Dienste, Secrets und Env-Vars sind unten erfasst.
Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git · Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git ·
🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt. 🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt.
„Im Backup?" = deckt der **aktuelle** `backup.sh` es ab. „Im Backup?" = deckt der **aktuelle** `backup.sh` es ab.
### A · OS & System (L0, root/sudo, einmalig) ### A · OS & System (L0, root/sudo, einmalig)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a | | Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a |
| System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein | | System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein |
| Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein | | Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein |
| Chrome-Libs (Browser): `agent-browser install --with-deps` | — | ⬇️ apt | nein | | Chrome-Libs (Browser): `agent-browser install --with-deps` | — | ⬇️ apt | nein |
| Verzeichnis-Layout: `/srv/models/{,mem0,mc2-backups,drafts}`, `/etc/llama-swap/` | — | ⬇️ mkdir+chown | nein | | Verzeichnis-Layout: `/srv/models/{,mem0,mc2-backups,drafts}`, `/etc/llama-swap/` | — | ⬇️ mkdir+chown | nein |
### B · Inferenz-Engine + Router (L1, root) ### B · Inferenz-Engine + Router (L1, root)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| llama.cpp (Vulkan) → `llama-server` | `/opt/llamacpp-vulkan`, `/usr/local/bin/llama-server` | ⬇️ provision-engine.sh / update-engine.sh (ggml-org Release) | nein (re-build) | | llama.cpp (Vulkan) → `llama-server` | `/opt/llamacpp-vulkan`, `/usr/local/bin/llama-server` | ⬇️ provision-engine.sh / update-engine.sh (ggml-org Release) | nein (re-build) |
| **llama-swap** Binary (Router `:8080`) | `/usr/local/bin/llama-swap` | ⬇️ **bekannt:** `mostlygeek/llama-swap`-Release (Rezept in `update-swap.sh`) | nein (re-download) | | **llama-swap** Binary (Router `:8080`) | `/usr/local/bin/llama-swap` | ⬇️ **bekannt:** `mostlygeek/llama-swap`-Release (Rezept in `update-swap.sh`) | nein (re-download) |
| **llama-swap systemd-System-Unit** | `/etc/systemd/system/llama-swap.service` (+ `.d/` Drop-ins) | ⬇️ Bootstrap legt sie an — **kompletter Unit-Inhalt in Anhang A** (von der Box abgegriffen; Drop-ins via provision-engine.sh) | nein | | **llama-swap systemd-System-Unit** | `/etc/systemd/system/llama-swap.service` (+ `.d/` Drop-ins) | ⬇️ Bootstrap legt sie an — **kompletter Unit-Inhalt in Anhang A** (von der Box abgegriffen; Drop-ins via provision-engine.sh) | nein |
| llama-swap-Config (Modelle/Rollen) | `/etc/llama-swap/config.yaml` | 📦 | **ja** | | llama-swap-Config (Modelle/Rollen) | `/etc/llama-swap/config.yaml` | 📦 | **ja** |
| Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein | | Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein |
### C · MC2-App (L2, userspace) ### C · MC2-App (L2, userspace)
| Komponente | Ort | Quelle | Im Backup? | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| |---|---|---|---|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a | | MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) | | Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
| Frontend (gebaut, inkl. **3D-Avatar `avatar.vrm`**) | `frontend/dist`, `frontend/public/avatar.vrm` | 🌐 Git | n/a | | Frontend (gebaut) | `frontend/dist` | 🌐 Git | n/a |
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein | | systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
### D · 3D-Avatar (Sprechen-Tab) ### D · 3D-Avatar — **ausgebaut (28.08.2026)**
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| MC2 hat keinen Avatar mehr. `frontend/public/avatar.vrm` (24,5 MB) lag im Auslieferungsordner,
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a | wurde aber von keiner Zeile des Frontends referenziert — der Renderer `Avatar3D.tsx` war schon
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 Git | n/a | vorher verschwunden. Beides ist beim v3-Umbau (Etappe P0) entfernt worden; damit fällt auch die
| **Eigene/zusätzliche Avatare** (falls Nutzer welche ablegt) | **TODO: Ablageort definieren** (z.B. `/srv/models/avatars/` + DB-Verweis) | 📦/🆕 | **nein (Lücke)** | `.gitignore`-Sonderregel und der Direkt-Deploy-Schritt weg. **Nichts wiederherzustellen.**
> Heute ist der Avatar **fest** (`avatar.vrm`, kommt mit dem Git-Frontend zurück). Wenn künftig ### E · Voice-Sidecar (STT + TTS + Klonstimme)
> Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht. | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### E · Voice-Sidecar (STT + TTS + Klonstimme) | Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) |
| Komponente | Ort | Quelle | Im Backup? | | STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein |
|---|---|---|---| | Piper-Binary + dt. Stimmen (thorsten/kerstin) | `~/.voice/piper`, `~/.voice/voices` | ⬇️ install.sh | nein |
| Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) | | Chatterbox (Premium-TTS, CPU-torch) | venv | ⬇️ install.sh (best-effort) | nein |
| STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein | | **Klonstimme / Voice-Referenz-Audio** (Nutzer) | `~/.voice/refs/ref.wav` (`VOICE_REFS_DIR`, `/api/voice/reference`) | 📦 | **nein → Anhang B** |
| Piper-Binary + dt. Stimmen (thorsten/kerstin) | `~/.voice/piper`, `~/.voice/voices` | ⬇️ install.sh | nein | | ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja |
| Chatterbox (Premium-TTS, CPU-torch) | venv | ⬇️ install.sh (best-effort) | nein | | Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a |
| **Klonstimme / Voice-Referenz-Audio** (Nutzer) | `~/.voice/refs/ref.wav` (`VOICE_REFS_DIR`, `/api/voice/reference`) | 📦 | **nein → Anhang B** | | Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein |
| ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja |
| Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a | ### F · Mem0 (Langzeitgedächtnis)
| Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### F · Mem0 (Langzeitgedächtnis) | Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) |
| Komponente | Ort | Quelle | Im Backup? | | **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** |
|---|---|---|---| | Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja |
| Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) | | Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein |
| **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** |
| Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja | ### G · Hermes-Agent (das Herz)
| Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### G · Hermes-Agent (das Herz) | Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) |
| Komponente | Ort | Quelle | Im Backup? | | Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein |
|---|---|---|---| | Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein |
| Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) | | **`config.yaml` 1:1** (Toolsets, MCP, Engine, Browser, Personalities, alles) | `~/.hermes/config.yaml` | 📦 | **ja** |
| Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein | | **`.env` (Secrets: TELEGRAM_BOT_TOKEN, API_SERVER_KEY, ElevenLabs …)** | `~/.hermes/.env` | 📦/🔑 | **ja** |
| Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein | | Plugins | `~/.hermes/plugins/` | 📦 | ja |
| **`config.yaml` 1:1** (Toolsets, MCP, Engine, Browser, Personalities, alles) | `~/.hermes/config.yaml` | 📦 | **ja** | | **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** |
| **`.env` (Secrets: TELEGRAM_BOT_TOKEN, API_SERVER_KEY, ElevenLabs …)** | `~/.hermes/.env` | 📦/🔑 | **ja** | | Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** |
| Plugins | `~/.hermes/plugins/` | 📦 | ja | | Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | ⏭️ skip | nein |
| **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** | | **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** |
| Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** | | Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein |
| Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | skip | nein | | Desktop-Gateway `hermes-builtin-ui` (`:9119`) | systemd-User | deploy.sh | nein |
| **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** | | Desktop-Gateway-Token-Drop-in | `~/.config/systemd/user/hermes-builtin-ui.service.d/session-token.conf` | 🔑 neu erzeugen (Wert auch in `~/.hermes/desktop-gateway-token`, chmod 600) | **nein** |
| Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein | | Desktop-Gateway-Token-Kopie | `~/.hermes/desktop-gateway-token` (chmod 600) | 🔑 | **nein** |
| Desktop-Gateway `hermes-builtin-ui` (`:9119`) | systemd-User | ⬇️ deploy.sh | nein |
| Desktop-Gateway-Token-Drop-in | `~/.config/systemd/user/hermes-builtin-ui.service.d/session-token.conf` | 🔑 neu erzeugen (Wert auch in `~/.hermes/desktop-gateway-token`, chmod 600) | **nein** | ### H · MCP-Server (4)
| Desktop-Gateway-Token-Kopie | `~/.hermes/desktop-gateway-token` (chmod 600) | 🔑 | **nein** | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### H · MCP-Server (4) | `mission-control-memory`, `-stack` (MC2-venv) | `~/mission-control-v2/mcp/*.py` | 🌐 Git | über config.yaml |
| Komponente | Ort | Quelle | Im Backup? | | `hermes-pc-control`, `hermes-web-fetch` (Hermes-venv) | dito | 🌐 Git | über config.yaml |
|---|---|---|---| | MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja |
| `mission-control-memory`, `-stack` (MC2-venv) | `~/mission-control-v2/mcp/*.py` | 🌐 Git | über config.yaml |
| `hermes-pc-control`, `hermes-web-fetch` (Hermes-venv) | dito | 🌐 Git | über config.yaml | ### I · Browser-Stack
| MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### I · Browser-Stack | agent-browser (npm global) | `~/.local/...`, symlink `~/.hermes/node/bin` | ⬇️ npm i -g | nein |
| Komponente | Ort | Quelle | Im Backup? | | Chrome (engine) + System-Libs | `~/.agent-browser/browsers/` + apt-Libs | ⬇️ install --with-deps | nein |
|---|---|---|---| | lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein |
| agent-browser (npm global) | `~/.local/...`, symlink `~/.hermes/node/bin` | ⬇️ npm i -g | nein | | Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja |
| Chrome (engine) + System-Libs | `~/.agent-browser/browsers/` + apt-Libs | ⬇️ install --with-deps | nein |
| lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein | ### J · Modelle (GGUF — der große Brocken)
| Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### J · Modelle (GGUF — der große Brocken) | GGUF-Modelle (Brain, fast, heavy, coder, vision, embedding …) | `/srv/models/` | ⬇️ Re-Download aus llama-swap-Manifest | **nein (zu groß, bewusst)** |
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---| ### K · Extern (anderer Rechner)
| GGUF-Modelle (Brain, fast, heavy, coder, vision, embedding …) | `/srv/models/` | ⬇️ Re-Download aus llama-swap-Manifest | **nein (zu groß, bewusst)** | | Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
### K · Extern (anderer Rechner) | PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) |
| Komponente | Ort | Quelle | Im Backup? | | Hermes Desktop (Windows-App) | `%LOCALAPPDATA%\hermes` + `connection.json` in `%APPDATA%\Hermes\` | ⬇️ Hermes-Setup.exe + Remote-URL/Token neu eintragen | n/a (anderer Host) |
|---|---|---|---|
| PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) | ---
| Hermes Desktop (Windows-App) | `%LOCALAPPDATA%\hermes` + `connection.json` in `%APPDATA%\Hermes\` | ⬇️ Hermes-Setup.exe + Remote-URL/Token neu eintragen | n/a (anderer Host) |
## 4. Ziel-Architektur
---
### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf
## 4. Ziel-Architektur - **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`).
- **Getrennt nach sudo-Bedarf**: `bootstrap-root.sh` (L0+L1, bewusst mit sudo) + `bootstrap.sh`
### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf (L2+, sudo-frei = Kern ist `deploy.sh`). Passt zum Nordstern „Runtime ohne sudo".
- **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`). - **Mündet in den Wizard**: startet MC2 im First-Run-Modus, gibt Wizard-URL aus.
- **Getrennt nach sudo-Bedarf**: `bootstrap-root.sh` (L0+L1, bewusst mit sudo) + `bootstrap.sh`
(L2+, sudo-frei = Kern ist `deploy.sh`). Passt zum Nordstern „Runtime ohne sudo". Phasen: `0 Vorflug → 1[sudo] System+Dirs → 2[sudo] Engine+llama-swap → 3 Code(MC2+Hermes+Node)
- **Mündet in den Wizard**: startet MC2 im First-Run-Modus, gibt Wizard-URL aus. → 4 venvs(backend/mem0/voice/hermes) → 5 Browser → 6 deploy.sh(Units/enable/restart)
→ 7 Restore(optional) → 8 Wizard hoch + postcheck`.
Phasen: `0 Vorflug → 1[sudo] System+Dirs → 2[sudo] Engine+llama-swap → 3 Code(MC2+Hermes+Node)
→ 4 venvs(backend/mem0/voice/hermes) → 5 Browser → 6 deploy.sh(Units/enable/restart) ### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert)
→ 7 Restore(optional) → 8 Wizard hoch + postcheck`. MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte:
### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert) 1. **Systemcheck** — Live-Ampel je Komponente aus §3 (`GET /api/setup/status` + `…/system/services`).
MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte: 2. **Restore-Quelle wählen** — Backup-Tarball erkennen → globaler Modus „Alles 1:1" / „selektiv" / „frisch".
3. **Komponenten-Auswahl (Kernstück)** — pro Katalog-Eintrag aus §3 eine Kachel mit
1. **Systemcheck** — Live-Ampel je Komponente aus §3 (`GET /api/setup/status` + `…/system/services`). 📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden.
2. **Restore-Quelle wählen** — Backup-Tarball erkennen → globaler Modus „Alles 1:1" / „selektiv" / „frisch". 4. **Netzwerk & Identität** — Box-IP (→ `HERMES_TERMINAL_URL`, `PC_EXECUTOR_URL`), Hostname.
3. **Komponenten-Auswahl (Kernstück)** — pro Katalog-Eintrag aus §3 eine Kachel mit 5. **Secrets** 🔑 — `TELEGRAM_BOT_TOKEN`, erlaubte User-ID, `API_SERVER_KEY` (oder generieren),
📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden. ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
4. **Netzwerk & Identität** — Box-IP (→ `HERMES_TERMINAL_URL`, `PC_EXECUTOR_URL`), Hostname. 6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
5. **Secrets** 🔑 — `TELEGRAM_BOT_TOKEN`, erlaubte User-ID, `API_SERVER_KEY` (oder generieren), an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup. 7. **Voice** — Stimme/Klonstimme
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser (📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`. 8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen). 9. **Externe Checkliste** — PC-Executor (Windows-Task), Hermes Desktop (App + Token) als Haken.
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`, 10. **Verifikation & Abschluss**`stack-postcheck.sh` + `hermes-postcheck.sh` → grün/rot-Liste → Dashboard.
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
9. **Externe Checkliste** — PC-Executor (Windows-Task), Hermes Desktop (App + Token) als Haken. **Technik:** neuer Router `backend/routers/setup.py`
10. **Verifikation & Abschluss**`stack-postcheck.sh` + `hermes-postcheck.sh` → grün/rot-Liste → Dashboard. (`GET /api/setup/status`, `POST /api/setup/{secrets,network,hermes,components,finish}`).
First-Run-Gate: MC2 prüft beim Boot Marker `~/.mc2-setup-done` bzw. Pflicht-Secrets → leitet auf `/setup`.
**Technik:** neuer Router `backend/routers/setup.py`
(`GET /api/setup/status`, `POST /api/setup/{secrets,network,hermes,components,finish}`). ---
First-Run-Gate: MC2 prüft beim Boot Marker `~/.mc2-setup-done` bzw. Pflicht-Secrets → leitet auf `/setup`.
## 5. Backup-Scope für echtes 1:1 ERWEITERN (umzusetzen — Snippet in Anhang B)
---
Der **aktuelle** `backup.sh` (live auf der Box, unverändert) reicht für „1:1" nicht. Für die Bau-Session
## 5. Backup-Scope für echtes 1:1 ERWEITERN (umzusetzen — Snippet in Anhang B) liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarball um:
Der **aktuelle** `backup.sh` (live auf der Box, unverändert) reicht für „1:1" nicht. Für die Bau-Session - **Voice-Klonstimme** → `~/.voice/refs/` (`ref.wav`)
liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarball um: - **Token-/Ersparnis-Statistik** → `~/.hermes/token_stats.json`
- **Hermes-Skills** → `~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen)
- **Voice-Klonstimme** → `~/.voice/refs/` (`ref.wav`) - **Hermes-Sessions/History** → `~/.hermes/sessions/`
- **Token-/Ersparnis-Statistik** → `~/.hermes/token_stats.json`
- **Hermes-Skills** → `~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen) Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
- **Hermes-Sessions/History** → `~/.hermes/sessions/` mit neu starten.
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service` **Offen bleibt:** nichts — der letzte offene Punkt (eigene Avatare) ist mit dem Avatar-Ausbau erledigt (§3·D).
mit neu starten.
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D). `/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache, → Aufgabe der Bau-Session: `backup.sh` + `restore.sh` um diese Pfade erweitern (mit klarer
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle. „opt-in für große/optionale Teile"-Logik), und den MANIFEST-Inhalt entsprechend.
→ Aufgabe der Bau-Session: `backup.sh` + `restore.sh` um diese Pfade erweitern (mit klarer ---
„opt-in für große/optionale Teile"-Logik), und den MANIFEST-Inhalt entsprechend.
## 6. Secrets-Strategie
--- - Single Source: `~/.hermes/.env` (chmod 600), im Tarball (selbst 600).
- Rebuild ohne Backup → Wizard-Schritt 5 erzeugt sie. `API_SERVER_KEY` generierbar; Telegram/ElevenLabs liefert Nutzer.
## 6. Secrets-Strategie - Nie ins Git, nie in Logs, nie in die MC2-DB. **Off-Box-Spiegelung des Tarballs noch offen** ([[mc2-backup-restore]]).
- Single Source: `~/.hermes/.env` (chmod 600), im Tarball (selbst 600).
- Rebuild ohne Backup → Wizard-Schritt 5 erzeugt sie. `API_SERVER_KEY` generierbar; Telegram/ElevenLabs liefert Nutzer. ## 7. Modell-Strategie
- Nie ins Git, nie in Logs, nie in die MC2-DB. **Off-Box-Spiegelung des Tarballs noch offen** ([[mc2-backup-restore]]). - **A (empfohlen):** `/etc/llama-swap/config.yaml` (im Backup) listet alle Modelle → Wizard leitet Repos/Quants ab und lädt automatisch (`/api/models/install`). „Ein Klick, lädt über Nacht."
- **B:** geführte Discover-Neuauswahl je Rolle. Reihenfolge Brain → heavy → Rest, danach `warmup.sh`.
## 7. Modell-Strategie
- **A (empfohlen):** `/etc/llama-swap/config.yaml` (im Backup) listet alle Modelle → Wizard leitet Repos/Quants ab und lädt automatisch (`/api/models/install`). „Ein Klick, lädt über Nacht." ---
- **B:** geführte Discover-Neuauswahl je Rolle. Reihenfolge Brain → heavy → Rest, danach `warmup.sh`.
## 8. Implementierungs-Reihenfolge (neue Session)
--- 1.**Box-Verifikation erledigt (2026-06-28, nur gelesen — nichts verändert):** llama-swap.service-Inhalt
in **Anhang A**; skills/sessions/refs/token_stats existieren (Pfade in §3 verifiziert); Backup-Erweiterung
## 8. Implementierungs-Reihenfolge (neue Session) als fertiges Snippet in **Anhang B**. **Keine offenen Box-Fragen mehr** außer §10·36. → mit Schritt 2 starten.
1.**Box-Verifikation erledigt (2026-06-28, nur gelesen — nichts verändert):** llama-swap.service-Inhalt 2. `backup.sh`/`restore.sh` um die 1:1-Lücken erweitern (§5).
in **Anhang A**; skills/sessions/refs/token_stats existieren (Pfade in §3 verifiziert); Backup-Erweiterung 3. `deploy/bootstrap.sh` + `bootstrap-root.sh` (Phasen, State-Marker); llama-swap-Install skripten.
als fertiges Snippet in **Anhang B**. **Keine offenen Box-Fragen mehr** außer §10·36. → mit Schritt 2 starten. 4. `backend/routers/setup.py` + First-Run-Gate.
2. `backup.sh`/`restore.sh` um die 1:1-Lücken erweitern (§5). 5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views
3. `deploy/bootstrap.sh` + `bootstrap-root.sh` (Phasen, State-Marker); llama-swap-Install skripten. (Cockpit/AgentView/Sprechen) wiederverwenden.
4. `backend/routers/setup.py` + First-Run-Gate. 6. Modell-Restore-aus-Manifest.
5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views 7. **Abnahme in frischer VM** (§9).
(Cockpit/AgentView/Sprechen) wiederverwenden.
6. Modell-Restore-aus-Manifest. Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
7. **Abnahme in frischer VM** (§9).
## 9. Abnahmekriterien
Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR. - Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), Browser funktionieren.
## 9. Abnahmekriterien - „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen. - Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), 3D-Avatar, Browser funktionieren. - Idempotenz: zweiter Lauf = No-op.
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem. ## 10. Offene Entscheidungen (vor dem Bau)
- Idempotenz: zweiter Lauf = No-op. 1. ~~llama-swap systemd-Unit-Inhalt~~**geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
2. ~~Voice-Referenz-Audio-Ort~~**geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
## 10. Offene Entscheidungen (vor dem Bau) 3. ~~**Eigene Avatare**~~ — entfallen: Avatar am 28.08.2026 ausgebaut (§3·D).
1. ~~llama-swap systemd-Unit-Inhalt~~**geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen). 4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
2. ~~Voice-Referenz-Audio-Ort~~**geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen). 5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
3. **Eigene Avatare**: künftiger Upload-/Ablage-Mechanismus + Backup-Pfad (einziger offener 1:1-Daten-Punkt). 6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?). ## 11. Referenzen
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo. - `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]]
- [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor)
## 11. Referenzen - [[voice-sprechen-feature]] (Voice + 3D-Avatar), [[mem0-memory-architecture]], [[mc2-backup-restore]]
- `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]] - `deploy/{deploy,backup,restore,provision-engine,stack-postcheck,warmup}.sh`, `voice_service/install.sh`
- [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor)
- [[voice-sprechen-feature]] (Voice + 3D-Avatar), [[mem0-memory-architecture]], [[mc2-backup-restore]] ---
- `deploy/{deploy,backup,restore,provision-engine,stack-postcheck,warmup}.sh`, `voice_service/install.sh`
## Anhang A — `llama-swap.service` (1:1 von der Box abgegriffen, 2026-06-28)
---
Base-Unit nach `/etc/systemd/system/llama-swap.service` (root). User/GFX sind boxspezifisch
## Anhang A — `llama-swap.service` (1:1 von der Box abgegriffen, 2026-06-28) (hitonabi, gfx1151 → HSA 11.5.1) — auf anderer Hardware anpassen. Die `.d/`-Drop-ins
(`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an.
Base-Unit nach `/etc/systemd/system/llama-swap.service` (root). User/GFX sind boxspezifisch
(hitonabi, gfx1151 → HSA 11.5.1) — auf anderer Hardware anpassen. Die `.d/`-Drop-ins ```ini
(`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an. [Unit]
Description=llama-swap (lokaler LLM Router)
```ini After=network-online.target
[Unit] Wants=network-online.target
Description=llama-swap (lokaler LLM Router)
After=network-online.target [Service]
Wants=network-online.target Type=simple
User=hitonabi
[Service] Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1
Type=simple Environment=PATH=/usr/local/bin:/usr/bin:/bin
User=hitonabi ExecStart=/usr/local/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:8080 --watch-config
Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1 Restart=on-failure
Environment=PATH=/usr/local/bin:/usr/bin:/bin RestartSec=3
ExecStart=/usr/local/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:8080 --watch-config
Restart=on-failure [Install]
RestartSec=3 WantedBy=multi-user.target
```
[Install] ```ini
WantedBy=multi-user.target # /etc/systemd/system/llama-swap.service.d/vulkan.conf
``` [Service]
```ini Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan
# /etc/systemd/system/llama-swap.service.d/vulkan.conf ```
[Service] ```ini
Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan # /etc/systemd/system/llama-swap.service.d/warmup.conf
``` [Service]
```ini ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh
# /etc/systemd/system/llama-swap.service.d/warmup.conf ```
[Service]
ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh **Erstinstall-Reihenfolge:** `bash deploy/update-swap.sh` (Binary) → Unit kopieren →
``` `sudo bash deploy/provision-engine.sh` (Engine+Drop-ins) → `sudo systemctl enable --now llama-swap`.
**Erstinstall-Reihenfolge:** `bash deploy/update-swap.sh` (Binary) → Unit kopieren → ---
`sudo bash deploy/provision-engine.sh` (Engine+Drop-ins) → `sudo systemctl enable --now llama-swap`.
## Anhang B — Backup-Scope-Erweiterung für echtes 1:1 (fertiges Snippet)
---
In `deploy/backup.sh` ergänzen (Variablen oben: `VOICE="${VOICE_HOME:-$HOME/.voice}"`,
## Anhang B — Backup-Scope-Erweiterung für echtes 1:1 (fertiges Snippet) Stage zusätzlich `"$STAGE/voice"`):
In `deploy/backup.sh` ergänzen (Variablen oben: `VOICE="${VOICE_HOME:-$HOME/.voice}"`, ```bash
Stage zusätzlich `"$STAGE/voice"`): # 1:1-Erweiterung (scan-verifiziert 2026-06-28):
[ -f "$HERMES/token_stats.json" ] && cp -a "$HERMES/token_stats.json" "$STAGE/hermes/" || true
```bash [ -d "$HERMES/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true
# 1:1-Erweiterung (scan-verifiziert 2026-06-28): [ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true
[ -f "$HERMES/token_stats.json" ] && cp -a "$HERMES/token_stats.json" "$STAGE/hermes/" || true [ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true
[ -d "$HERMES/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true ```
[ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern:
[ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true ```bash
``` tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" .
tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern: ```
```bash In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und
tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" . `voice-service` mit neustarten:
``` ```bash
In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und VOICE="${VOICE_HOME:-$HOME/.voice}"
`voice-service` mit neustarten: SERVICES="mem0-service voice-service mission-control-2 hermes-gateway"
```bash [ -f "$STAGE/hermes/token_stats.json" ] && cp -a "$STAGE/hermes/token_stats.json" "$HERMES/"
VOICE="${VOICE_HOME:-$HOME/.voice}" [ -d "$STAGE/hermes/skills" ] && { mkdir -p "$HERMES/skills"; cp -a "$STAGE/hermes/skills/." "$HERMES/skills/"; }
SERVICES="mem0-service voice-service mission-control-2 hermes-gateway" [ -d "$STAGE/hermes/sessions" ] && { mkdir -p "$HERMES/sessions"; cp -a "$STAGE/hermes/sessions/." "$HERMES/sessions/"; }
[ -f "$STAGE/hermes/token_stats.json" ] && cp -a "$STAGE/hermes/token_stats.json" "$HERMES/" [ -d "$STAGE/voice/refs" ] && { mkdir -p "$VOICE/refs"; cp -a "$STAGE/voice/refs/." "$VOICE/refs/"; }
[ -d "$STAGE/hermes/skills" ] && { mkdir -p "$HERMES/skills"; cp -a "$STAGE/hermes/skills/." "$HERMES/skills/"; } ```
[ -d "$STAGE/hermes/sessions" ] && { mkdir -p "$HERMES/sessions"; cp -a "$STAGE/hermes/sessions/." "$HERMES/sessions/"; }
[ -d "$STAGE/voice/refs" ] && { mkdir -p "$VOICE/refs"; cp -a "$STAGE/voice/refs/." "$VOICE/refs/"; }
```
+3
View File
@@ -0,0 +1,3 @@
dist
node_modules
package-lock.json
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"printWidth": 110,
"trailingComma": "all",
"arrowParens": "always"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{u,ah as m,k as x,j as s,y as n,ai as b,b as p,n as f,q as h}from"./index-DNt66je4.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J}; import{u,ag as m,k as x,j as s,y as n,ah as b,b as p,n as f,q as h}from"./index-l6kyKCL9.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
-1
View File
@@ -1 +0,0 @@
import{I as b,u as f,K as h,r as d,j as e,b as c,J as g,T as p,R as j,Q as N,n as v,U as w,q as x}from"./index-DNt66je4.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
+1
View File
@@ -0,0 +1 @@
import{O as h,u as g,P as p,r as d,j as e,b as x,T as j,S as N,n as v,U as m,V as w,q as f}from"./index-l6kyKCL9.js";import{E as k}from"./external-link-ClFd8iN1.js";import{R as y}from"./refresh-cw-BV1qZMSR.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[n===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(j,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:b,disabled:o,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{K as KonsoleView};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
import{c as s,j as e}from"./index-DNt66je4.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=s("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);function c({icon:a,children:t}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(a,{className:"h-3.5 w-3.5"})," ",t]})}export{r as H,c as S};
+1
View File
@@ -0,0 +1 @@
import{j as e}from"./index-l6kyKCL9.js";function n({icon:t,children:s}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(t,{className:"h-3.5 w-3.5"})," ",s]})}export{n as S};
@@ -1,6 +1,6 @@
var M=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||M("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?M("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a9 as B,aa as F,ab as E,ac as I,ad as P,u as A,r as f,ae as J,af as Q,c as T,ag as U,j as s,G as D,T as H,b as K,L,M as G,q as V,n as R}from"./index-DNt66je4.js";import{J as _}from"./JobsBar-DG8VDiAV.js";var c,g,o,h,m,w,C,q,z=(q=class extends B{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),F(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&E(t.mutationKey)!==E(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??I();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=A(),[n]=f.useState(()=>new z(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(J)},[n]);if(l.error&&Q(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/** var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?E("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a8 as Q,a9 as A,aa as M,ab as B,ac as P,u as J,r as f,ad as F,ae as I,c as T,af as U,j as s,J as D,T as H,b as K,L,Q as V,q as _,n as R}from"./index-l6kyKCL9.js";import{J as z}from"./JobsBar-HflSaqor.js";var c,g,o,h,m,w,C,q,G=(q=class extends Q{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),A(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??B();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=J(),[n]=f.useState(()=>new G(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(F)},[n]);if(l.error&&I(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree. * See the LICENSE file in the root directory of this source tree.
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=A(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:V.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(_,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(G,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft ":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView}; */const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=J(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:_.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(z,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(V,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft ":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
import{c as r}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=r("ArrowRight",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]);export{o as A};
@@ -1,4 +1,4 @@
import{c as o}from"./index-DNt66je4.js";/** import{c as o}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+6
View File
@@ -0,0 +1,6 @@
import{c}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=c("Clock",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["polyline",{points:"12 6 12 12 16 14",key:"68esgv"}]]);export{o as C};
+11
View File
@@ -0,0 +1,11 @@
import{c as a}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const c=a("Camera",[["path",{d:"M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z",key:"1tc9qg"}],["circle",{cx:"12",cy:"13",r:"3",key:"1vg3eu"}]]);/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const o=a("CloudDownload",[["path",{d:"M12 13v8l-4-4",key:"1f5nwf"}],["path",{d:"m12 21 4-4",key:"1lfcce"}],["path",{d:"M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284",key:"ui1hmy"}]]);export{o as C,c as a};
@@ -1,4 +1,4 @@
import{c as e}from"./index-DNt66je4.js";/** import{c as e}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC * @license lucide-react v0.460.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+6
View File
@@ -0,0 +1,6 @@
import{c as a}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const e=a("ExternalLink",[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]]);export{e as E};
+6
View File
@@ -0,0 +1,6 @@
import{c as e}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const t=e("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);export{t as F};
+6
View File
@@ -0,0 +1,6 @@
import{c as a}from"./index-l6kyKCL9.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const m=a("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);export{m as H};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{aA as r}from"./index-l6kyKCL9.js";var o=r();export{o as r};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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