33 Commits

Author SHA1 Message Date
Hitonabi 76d2d7c74b deploy: neues Curator-Monitoring-Skript mit 7-Tage-Schwellenwert
Ampel / ampel (push) Failing after 22s
2026-08-02 04:04:21 +02:00
Hitonabi 7638f0244d fix(cron): correct implementation for cron spam prevention via wrapper script and deploy origin
Ampel / ampel (push) Failing after 21s
2026-07-27 17:12:39 +02:00
Hitonabi 4985977492 fix(cron): use --name flag for hermes cron create
Ampel / ampel (push) Failing after 22s
2026-07-27 17:10:43 +02:00
Hitonabi f65ff48599 fix(cron): pipe alle nächtlichen crons durch notify.sh zur verhinderung von telegram spam
Ampel / ampel (push) Failing after 22s
2026-07-27 17:09:26 +02:00
Hitonabi 87f446c5ae feat(pruefstand): Etappe E5 Autonomie für Auto-Adoption und täglichen Rhythmus
Ampel / ampel (push) Failing after 22s
2026-07-26 21:28:50 +02:00
Hitonabi f524ef4375 chore(prompt): Morgen-Digest detailreicher und gehaltvoller gemacht
Ampel / ampel (push) Successful in 22s
2026-07-26 21:16:02 +02:00
Hitonabi 605c9d7dd1 feat: Morgen-Digest für gebündelte nächtliche Telegram-Nachrichten
Ampel / ampel (push) Successful in 22s
2026-07-26 21:10:31 +02:00
Hitonabi 9791f44644 UI: GovernorCard aus dem Cockpit entfernt
Ampel / ampel (push) Successful in 22s
2026-07-26 21:03:47 +02:00
Hitonabi 1e3e0066af Governor: Hard ceiling respects tool chains (OpenCode fix)
Ampel / ampel (push) Successful in 23s
2026-07-26 20:50:45 +02:00
Hitonabi 2365f7aac6 Warm-Waechter lief 11 Tage im Leerlauf: Reranker war nie erreichbar
Ampel / ampel (push) Successful in 22s
Beim Nachsehen wegen einer haengenden Gitea-Karte im Steward-Log gefunden:
600 vergebliche warmup.sh-Laeufe in 24 h, alle 90 Sekunden. Aelteste Meldung
dieser Art: 15.07.2026 — also elf Tage, nicht erst seit dem Umbau.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:39:32 +02:00
Auftragsbuch 00f2b71a63 Auftragsbuch: 'doku/curator-autos' angenommen (Ein-Klick-Gate) 2026-07-25 04:10:55 +02:00
Hitonabi 2cecd06c5b doku: Curator Auto-Archivierung Cron dokumentieren 2026-07-25 03:53:02 +02:00
Auftragsbuch 080eaa5ff5 Auftragsbuch: 'doku/pruefstand-coder-backend-24-07' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:13 +02:00
Auftragsbuch 7dd44a3630 Auftragsbuch: 'feature/curator-auto-archiv' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:12 +02:00
Hitonabi 554db10a6f VERDIKTE: Pruefstand 24.07 — Vulkan bleibt (ROCm-Recheck erledigt), Coder-Next bleibt (Devstral 4-8x langsamer), Reliabilitaet=Harness 2026-07-24 14:44:22 +02:00
Hitonabi ce030d6817 feat: Curator Auto-Archivierung (14-Tage-Inaktivität) 2026-07-24 13:46:49 +02:00
Auftragsbuch 8996c0d012 Auftragsbuch: 'wartung/curator-idle-watchdog' angenommen (Ein-Klick-Gate) 2026-07-24 09:58:38 +02:00
Hitonabi a1de1ec2ac feat: Curator Idle Watchdog implementieren
- Skript deploy/curator-idle-watchdog.sh für 14-Tage-Idle-Erkennung
- Spezifikation deploy/specs/curator-idle-watchdog.md
- Wöchentliche Prüfung (Sonntag 00:00), Trigger bei Überschreitung
2026-07-24 08:53:19 +02:00
75 changed files with 3702 additions and 1099 deletions
+4
View File
@@ -11,6 +11,10 @@
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
deploy/agent-hooks/*.py text eol=lf
deploy/*.py text eol=lf
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
deploy/**/*.py text eol=lf
# Windows-Batch-Wrapper bleiben CRLF.
*.cmd text eol=crlf
+3
View File
@@ -20,3 +20,6 @@ frontend/dist/avatar.vrm
box_recon*
gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
+128 -50
View File
@@ -1,72 +1,150 @@
# Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren**
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
„Werkstatt"), nicht mehr manuelle Releases.
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
## Die drei Bahnen
## Status: **live + eingefroren (MC2 Final)**
| Bahn | Was sie tut | Wo |
|---|---|---|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
ohne Betreuung monatelang weiter):
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
Bau-Sitzung und wird nie unterbrochen.
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
Branch → Gate → Deploy selbst).
## Ports & Dienste
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
| Port | Dienst | Erreichbar |
|---|---|---|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
| `8080` | **llama-swap** — Modell-Router | LAN |
| `9010` | `mc2-gateway``/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
## Der Weg einer Idee
```
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Zed bauen → Ampel → main
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
```
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
Wächtern geboren**:
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
## Modelle & Rollen
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
| Rolle | Modell | Tempo | Aufgabe |
|---|---|---|---|
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
## Qualitäts-Tore
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
2. **Prüf-Tor**`VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
Kontext-Komprimierung löscht still die Regeln mit.
## Selbsterhaltung
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
## API (Auswahl)
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
## Entwickeln
**Backend:**
```bash
# Backend
cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m uvicorn app:app --port 9000
# Frontend (Dev, proxyt /api → :9000)
cd frontend && npm install && npm run dev # http://localhost:5173
# Frontend (Build → wird vom Backend ausgeliefert)
cd frontend && npm run build # → frontend/dist
```
**Frontend (Dev, proxyt /api → :9000):**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
## Env-Vars (Auswahl)
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
| `MC_PORT` | `9000` | MC-Backend-Port |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
| `MC_V1_UPSTREAM` | | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
[`deploy/governor/README.md`](deploy/governor/README.md).
## Weiterlesen
| Dokument | Inhalt |
|---|---|
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
+2
View File
@@ -28,6 +28,7 @@ from routers import (
eigenleben,
events,
gateway_proxy,
governor,
health,
hermes_ui,
ideen,
@@ -141,6 +142,7 @@ app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
+11
View File
@@ -55,6 +55,17 @@
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
}
]
}
+40
View File
@@ -0,0 +1,40 @@
"""Governor — Fenster auf den Token-Waechter (:8100).
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
zweiten Port ausweichen muss.
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
"""
import os
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/api")
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
@router.get("/governor")
async def governor_status() -> dict:
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
try:
async with httpx.AsyncClient(timeout=3.0) as c:
r = await c.get(f"{GOVERNOR_URL}/governor/status")
r.raise_for_status()
data = r.json()
data["reachable"] = True
return data
except Exception as exc:
return {
"ok": False,
"reachable": False,
"error": f"{type(exc).__name__}: {exc}",
"url": GOVERNOR_URL,
}
+25
View File
@@ -26,6 +26,15 @@ class AntwortIn(BaseModel):
antwort: str
class ArchivAlleIn(BaseModel):
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
projekt: str = ""
class EinordnenIn(BaseModel):
text: str
@router.get("/ideen")
def list_queue() -> dict:
return ideen.list_queue()
@@ -114,6 +123,22 @@ def archive(body: TaskIn) -> dict:
return res
@router.post("/ideen/einordnen")
def einordnen(body: EinordnenIn) -> dict:
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
und die Oberfläche behält ihre Voreinstellung."""
return ideen.einordnen(body.text)
@router.post("/ideen/archivieren-alle")
def archive_alle(body: ArchivAlleIn) -> dict:
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
res = ideen.archive_done(body.projekt)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
@router.get("/ideen/{id}/log")
def get_log(id: str) -> dict:
return ideen.log_of(id)
+98
View File
@@ -22,6 +22,9 @@ import subprocess
import time
from pathlib import Path
import httpx
from config import GATEWAY_URL, V1_UPSTREAM
log = logging.getLogger(__name__)
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
@@ -509,6 +512,101 @@ def archive_task(task_id: str) -> dict:
return {"ok": True}
_EINORDNEN_SYS = (
"Du ordnest eine frisch getippte Projekt-Idee ein. Antworte AUSSCHLIESSLICH mit einem "
"JSON-Objekt, ohne Prosa, ohne Codezaun:\n"
'{"wer":"box"|"zed","tiefe":"schlank"|"gruendlich","nur_denken":true|false}\n\n'
"wer=box: Aufgabe am eigenen Heimlabor des Nutzers — seine Server, seine bestehenden "
"Dienste (Mission Control, Lucy, Rippy), Wartung, Doku, Automatisierung. Die Box baut selbst.\n"
"wer=zed: ein NEUES eigenstaendiges Programm/Werkzeug/App, das der Nutzer danach selbst "
"programmieren will. Er bekommt nur ein vorbereitetes Repo.\n"
"tiefe=gruendlich: gross, vage oder mit vielen offenen Entwurfsfragen. "
"tiefe=schlank: klein und klar umrissen.\n"
"nur_denken=true: der Text stellt eine FRAGE oder waegt ab (\"lohnt sich\", \"waere es sinnvoll\", "
"\"brauche ich\") statt einen Auftrag zu geben."
)
def einordnen(text: str) -> dict:
"""Grober Einordnungs-VORSCHLAG für eine frisch getippte Idee.
Nur eine Vorauswahl in der Oberfläche — der Mensch sieht und überschreibt sie. Deshalb
bewusst anspruchslos: ein einziger kurzer Aufruf ans dauerwarme Hirn (69,6 t/s gemessen),
Schnellspur-Marker gegen das Nachdenken, hartes Timeout. Scheitert irgendetwas, kommt
`{"ok": False}` zurück und die Oberfläche behält schlicht ihre Voreinstellung — eine
Einordnung darf das Tippen NIE blockieren oder eine Fehlermeldung erzeugen.
"""
text = (text or "").strip()
if len(text) < 12:
return {"ok": False}
ziel_url = (V1_UPSTREAM or GATEWAY_URL).rstrip("/")
marker = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
try:
r = httpx.post(f"{ziel_url}/v1/chat/completions", timeout=12.0, json={
"model": "hermes", "temperature": 0, "max_tokens": 60, "stream": False,
"messages": [
{"role": "system", "content": _EINORDNEN_SYS},
{"role": "user", "content": f"{marker} {text[:600]}"},
],
})
r.raise_for_status()
inhalt = (r.json()["choices"][0]["message"]["content"] or "").strip()
except Exception:
log.debug("ideen: einordnen fehlgeschlagen", exc_info=True)
return {"ok": False}
# Modelle packen JSON gern in einen Codezaun oder schreiben einen Satz davor.
m = re.search(r"\{.*\}", inhalt, re.DOTALL)
if not m:
return {"ok": False}
try:
d = json.loads(m.group(0))
except Exception:
return {"ok": False}
wer = d.get("wer") if d.get("wer") in ("box", "zed") else None
tiefe = d.get("tiefe") if d.get("tiefe") in ("schlank", "gruendlich") else None
if wer is None and tiefe is None:
return {"ok": False}
return {"ok": True, "wer": wer or "zed", "tiefe": tiefe or "schlank",
"nur_denken": bool(d.get("nur_denken"))}
def archive_done(projekt: str = "") -> dict:
"""Alle FERTIGEN Karten wegräumen — optional nur die eines Projekts.
Sammel-Aufräumen (25.07.2026): Bei fünf abgeschlossenen Projekt-Ketten wären das
sonst ~18 Einzelklicks. Wie beim Einzel-Archivieren wird nichts gelöscht — die
Karten wandern ins Kanban-Archiv und bleiben dort lesbar.
‼️ Bewusst NUR `done`. Laufende, hängende, wartende und in Ausarbeitung befindliche
Karten werden nie angefasst, auch nicht bei leerem `projekt`-Filter — ein Sammel-
Knopf darf niemals versehentlich Arbeit abräumen.
"""
if not _available():
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
q = list_queue()
if not q.get("available"):
return {"ok": False, "error": q.get("fehler") or "Queue nicht lesbar."}
ziel = [i for i in q.get("items", [])
if i.get("status") == "done" and (not projekt or i.get("projekt") == projekt)]
if not ziel:
return {"ok": True, "archiviert": 0, "fehlgeschlagen": []}
ok, fehler = 0, []
for item in ziel:
r = archive_task(item.get("id", ""))
if r.get("ok"):
ok += 1
else:
# Die Items aus list_queue tragen "titel" (deutsch), nicht das rohe "title".
fehler.append({"id": item.get("id"), "titel": _kurz(item.get("titel") or "", 60),
"error": r.get("error", "")})
_list_cache["ts"] = 0.0
return {"ok": True, "archiviert": ok, "fehlgeschlagen": fehler}
def ergebnis_of(task_id: str) -> dict:
"""Das ERGEBNIS einer fertigen Aufgabe — die Abschluss-Zusammenfassung des Workers.
+12 -1
View File
@@ -73,7 +73,18 @@ def _run_warmup() -> bool:
def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
"""Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
nur die erste ausdrücken.
"""
explizit = os.environ.get("MC_WARMSET", "").split()
if explizit:
return set(explizit)
try:
from services import llamaswap
groups = llamaswap.list_groups() or {}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# curator-archive-watchdog.sh
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
# Python-Skript ausführen
python3 "$PYTHON_SCRIPT"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# curator-idle-watchdog.sh
# Prüft den Curator-Idle-Status und triggert Reaktivierung nach 14 Tagen
set -euo pipefail
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
LOG_FILE="$LOG_DIR/idle-watchdog-$(date +%Y%m%d).log"
THRESHOLD_DAYS=14
mkdir -p "$LOG_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Curator-Status auslesen
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
log "FEHLER: hermes curator status fehlgeschlagen"
exit 1
}
# Prüfen, ob Curator ENABLED ist
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
exit 0
fi
# Letzten Lauf extrahieren (Format: "last run: 17d ago")
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
if [[ -z "$LAST_RUN_LINE" ]]; then
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
exit 1
fi
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
# Prüfen, ob "d ago" vorhanden ist
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
else
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen) → überspringe"
exit 0
fi
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
# Threshold prüfen
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage) → kein Trigger nötig"
exit 0
fi
# Reaktivierung triggern
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS) → triggere 'hermes curator run'"
if hermes curator run 2>&1 | tee -a "$LOG_FILE"; then
log "ERFOLG: Curator-Reaktivierung gestartet"
else
log "FEHLER: Curator-Reaktivierung fehlgeschlagen"
exit 1
fi
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# curator-monitor.sh
# Prüft den Curator-Idle-Status mit 7-Tage-Schwellenwert.
# GIBT AUS: PASS oder FAIL mit Detaillierung.
# EXIT 0 = OK (Curator aktiv innerhalb von 7 Tagen)
# EXIT 1 = FAIL (Curator >7 Tage idle) Monitoring-Skript erkannt Problem
#
# --dry-run DAYS Simuliert eine >DAYS-alte Curator-Auszeit (für Tests)
set -euo pipefail
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
LOG_FILE="$LOG_DIR/monitor-$(date +%Y%m%d).log"
THRESHOLD_DAYS=7
mkdir -p "$LOG_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# --- Argumente parsen ---
DRY_RUN=false
DRY_RUN_DAYS=""
for arg in "$@"; do
case "$arg" in
--dry-run)
DRY_RUN=true
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
DRY_RUN_DAYS="$2"
shift
else
DRY_RUN_DAYS=8 # Default-Testwert
fi
;;
esac
done
# --- LIVE-PRÜFUNG oder DRY-RUN ---
if [[ "$DRY_RUN" == "true" ]]; then
log "DRY-RUN: Simuliere Curator-Auszeit von $DRY_RUN_DAYS Tagen"
DAYS_SINCE_LAST_RUN="$DRY_RUN_DAYS"
else
# Live-Status abfragen
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
log "FEHLER: hermes curator status fehlgeschlagen"
echo "FAIL: hermes curator status fehlgeschlagen"
exit 1
}
# Prüfen, ob Curator ENABLED ist
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
echo "PASS: Curator ist nicht ENABLED (keine Aktivität nötig)"
exit 0
fi
# Letzten Lauf extrahieren
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
if [[ -z "$LAST_RUN_LINE" ]]; then
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
echo "FAIL: Konnte 'last run' nicht extrahieren"
exit 1
fi
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
# Tage extrahieren
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
else
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen)"
echo "PASS: Curator ist aktuell aktiv oder <1 Tag idle"
exit 0
fi
fi
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
# --- MONITORING-LOGIK (keine Reaktivierung!) ---
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage)"
echo "PASS: Curator ist aktiv ($DAYS_SINCE_LAST_RUN Tage idle)"
exit 0
else
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS)"
echo "FAIL: Curator-Cron ist für $DAYS_SINCE_LAST_RUN Tage stumm (>= $THRESHOLD_DAYS-Tage-Schwellenwert)"
exit 1
fi
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Curator Auto-Archivierung: Archiviert Skills nach 14 Tagen Inaktivität.
Liest `.curator_state` und `.usage.json` aus ~/.hermes/skills/, prüft
auf Inaktivität (>14 Tage last_used_at), schließt aus (pinned, system)
und verschiebt in .archive/<Kategorie>/.
"""
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
CURATOR_STATE_FILE = HERMES_SKILLS_DIR / ".curator_state"
USAGE_JSON_FILE = HERMES_SKILLS_DIR / ".usage.json"
ARCHIVE_DIR = HERMES_SKILLS_DIR / ".archive"
LOG_DIR = Path.home() / ".hermes" / "logs" / "curator"
def load_json(path: Path) -> dict:
"""JSON-Datei laden."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(path: Path, data: dict) -> None:
"""JSON-Datei speichern (mit Pretty-Print)."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def is_stale(skill_usage: dict, threshold_days: int = 14) -> bool:
"""Prüft, ob ein Skill älter als threshold_days ist."""
last_used = skill_usage.get("last_used_at")
if not last_used:
return False
# ISO-Format mit optionaler Zeitzone
last_used_str = last_used.replace("Z", "+00:00")
try:
last_used_dt = datetime.fromisoformat(last_used_str)
except ValueError:
return False
now = datetime.now(timezone.utc)
delta = now - last_used_dt
return delta.days >= threshold_days
def is_excluded(skill_name: str, excluded: list) -> bool:
"""Prüft, ob ein Skill ausgeschlossen ist."""
return skill_name in excluded
def is_pinned(skill_usage: dict) -> bool:
"""Prüft, ob ein Skill angepinnt ist."""
return skill_usage.get("pinned", False) is True
def get_skill_category(skill_name: str) -> str:
"""
Ermittelt die Kategorie eines Skills basierend auf dem Verzeichnisnamen.
Gibt 'unknown' zurück, wenn keine Kategorie gefunden wird.
"""
# Versuche, die Kategorie aus dem Verzeichnis zu ermitteln
# z.B. 'betrieb-playbook' → 'betrieb-playbook' (direkt unter skills/)
# 'apple' (in .archive/apple/) → 'apple'
# Falls der Skill direkt in skills/ liegt ohne Unterverzeichnis:
return "unknown"
def ensure_archive_category(category: str) -> Path:
"""Erstellt das Archiv-Verzeichnis für eine Kategorie, falls nötig."""
category_path = ARCHIVE_DIR / category
category_path.mkdir(parents=True, exist_ok=True)
return category_path
def move_skill_to_archive(skill_name: str, category: str) -> Path:
"""
Verschiebt ein Skill-Verzeichnis in das Archiv.
Gibt den neuen Pfad zurück.
"""
source = HERMES_SKILLS_DIR / skill_name
target_category = ensure_archive_category(category)
target = target_category / skill_name
if source.exists():
shutil.move(str(source), str(target))
return target
def archive_skill(skill_name: str, usage_data: dict) -> dict:
"""
Archiviert einen Skill:
- Setzt state auf 'archived'
- Setzt archived_at auf aktuelle Zeit
- Verschiebt das Verzeichnis nach .archive/
Gibt das aktualisierte Usage-Dict zurück.
"""
usage_data[skill_name]["state"] = "archived"
usage_data[skill_name]["archived_at"] = datetime.now(timezone.utc).isoformat()
return usage_data
def log_archive_action(skill_name: str, category: str, success: bool, message: str) -> None:
"""Protokolliert eine Archivierungsaktion."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"archive-{datetime.now(timezone.utc).date().isoformat()}.log"
timestamp = datetime.now(timezone.utc).isoformat()
status = "SUCCESS" if success else "FAILED"
log_line = f"[{timestamp}] {status} | {skill_name} ({category}) | {message}\n"
with open(log_file, "a", encoding="utf-8") as f:
f.write(log_line)
def main() -> None:
"""Hauptfunktion: Archiviert stale Skills."""
# 1. Config laden
if not CURATOR_STATE_FILE.exists():
print(f"Fehler: {CURATOR_STATE_FILE} nicht gefunden.")
return
curator_state = load_json(CURATOR_STATE_FILE)
archivierung = curator_state.get("archivierung", {})
if not archivierung.get("enabled", False):
print("Archivierung ist nicht aktiviert.")
return
threshold_days = archivierung.get("threshold_days", 14)
excluded_skills = archivierung.get("excluded_skills", [])
# 2. Usage-Daten laden
if not USAGE_JSON_FILE.exists():
print(f"Fehler: {USAGE_JSON_FILE} nicht gefunden.")
return
usage_data = load_json(USAGE_JSON_FILE)
# 3. Skills prüfen
archived_count = 0
skipped_count = 0
for skill_name, skill_usage in usage_data.items():
# Ausschlusskriterien prüfen
if is_excluded(skill_name, excluded_skills):
print(f"Übersprungen (Exkludiert): {skill_name}")
skipped_count += 1
continue
if is_pinned(skill_usage):
print(f"Übersprungen (Gepinnt): {skill_name}")
skipped_count += 1
continue
# Inaktivität prüfen
if not is_stale(skill_usage, threshold_days):
print(f"Übersprungen (Aktiv): {skill_name}")
skipped_count += 1
continue
# Archivierung durchführen
category = get_skill_category(skill_name) or "unknown"
print(f"Archiviere: {skill_name}{category}")
try:
# Usage aktualisieren
usage_data = archive_skill(skill_name, usage_data)
# Verzeichnis verschieben
move_skill_to_archive(skill_name, category)
# Erfolg protokollieren
log_archive_action(skill_name, category, True, "Skill archiviert")
archived_count += 1
except Exception as e:
log_archive_action(skill_name, category, False, str(e))
print(f"Fehler beim Archivieren von {skill_name}: {e}")
# 4. Usage-Daten speichern
save_json(USAGE_JSON_FILE, usage_data)
# 5. Archivierungs-Zeitstempel aktualisieren
curator_state["archivierung"]["last_archive_run_at"] = datetime.now(timezone.utc).isoformat()
save_json(CURATOR_STATE_FILE, curator_state)
print(f"\nArchivierung abgeschlossen: {archived_count} archiviert, {skipped_count} übersprungen.")
if __name__ == "__main__":
main()
+6
View File
@@ -89,6 +89,9 @@ cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.t
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
# Morgen-Digest (Nächtliche Telegram-Zusammenfassung, 26.07.2026): fasst alle Meldungen zwischen 0 und 7 Uhr zusammen.
cp "$SRC/deploy/mc2-morgen-digest.service" "$HOME/.config/systemd/user/mc2-morgen-digest.service"
cp "$SRC/deploy/mc2-morgen-digest.timer" "$HOME/.config/systemd/user/mc2-morgen-digest.timer"
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
mkdir -p "$HOME/.hermes/scripts"
@@ -105,6 +108,8 @@ cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
# Wrapper für nächtliche Crons, um Telegram-Spam in den Morgen-Digest zu leiten.
cp "$SRC/deploy/night-cron-wrapper.sh" "$HOME/.hermes/scripts/night-cron-wrapper.sh"
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
@@ -248,6 +253,7 @@ systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-morgen-digest.timer >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -e
# Alte Crons löschen
hermes cron remove traum || true
hermes cron remove chef-gutachter || true
hermes cron remove release-radar || true
hermes cron remove idle-radar || true
hermes cron remove selbst-inventur || true
hermes cron remove karten-gutachter || true
hermes cron remove pruefstand || true
hermes cron remove trend-radar || true
# Neue Crons registrieren mit Wrapper und --deliver origin.
# Dadurch schweigt Hermes cron selbst, und der Wrapper nutzt notify.sh für den Morgen-Digest.
hermes cron create --name traum --deliver origin "15 3 * * *" "Traum dreaming-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name chef-gutachter --deliver origin "30 4 * * *" "Chef-Gutachter chef-gutachter-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name release-radar --deliver origin "15 5 * * 1" "Release-Radar hermes-release-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name idle-radar --deliver origin "45 3 * * *" "Idle-Radar idle-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name selbst-inventur --deliver origin "35 3 * * *" "Selbst-Inventur selbst-inventur.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name karten-gutachter --deliver origin "0 4 * * *" "Karten-Gutachter karten-gutachter.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name pruefstand --deliver origin "0 2 * * *" "Prüfstand pruefstand.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name trend-radar --deliver origin "15 5 * * 6" "Trend-Radar trend-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
echo "Crons erfolgreich gefixt!"
+23 -12
View File
@@ -79,26 +79,37 @@ case "$CODE" in
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
# dedizierte Anlage-Token hat u.U. nur write:user).
AMPEL="$REALHOME/mission-control-v2/deploy/ampel-ci.yml"
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
if [ -r "$AMPEL" ]; then
SEED_CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/.gitea/workflows/ci.yml" \
# saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
saat () {
local QUELLE="$1" ZIEL="$2" MSG="$3" NAME="$4" CODE
if [ ! -r "$QUELLE" ]; then
echo "WARNUNG: Vorlage fehlt ($QUELLE) — Repo ohne $NAME angelegt." >&2
return
fi
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/$ZIEL" \
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
--data "$(python3 - "$AMPEL" <<'PY'
--data "$(python3 - "$QUELLE" "$MSG" <<'PY'
import base64, json, sys
inhalt = open(sys.argv[1], "rb").read()
print(json.dumps({"content": base64.b64encode(inhalt).decode(),
"message": "CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)"}))
print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
PY
)")"
if [ "$SEED_CODE" = "201" ]; then
echo "CI-Ampel eingepflanzt (.gitea/workflows/ci.yml)."
if [ "$CODE" = "201" ]; then
echo "$NAME eingepflanzt ($ZIEL)."
else
echo "WARNUNG: CI-Ampel-Seed antwortete HTTP $SEED_CODE (Repo ist trotzdem da)." >&2
echo "WARNUNG: $NAME-Seed antwortete HTTP $CODE (Repo ist trotzdem da)." >&2
fi
else
echo "WARNUNG: Ampel-Vorlage fehlt ($AMPEL) — Repo ohne CI-Ampel angelegt." >&2
fi
}
# JEDES neue Repo wird mit beiden Wächtern geboren:
# ci.yml = die AUSSEN-Prüfung (Gitea Actions nach dem Push, Wasserdicht-Runde 22.07.)
# VERIFY = die INNEN-Prüfung (das OpenCode-Plugin führt sie nach jeder Etappe aus und
# gibt rote Tests dem Agenten sofort zurück, statt sie erst der CI zu zeigen)
# Defensiv: scheitert ein Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
saat "$REALHOME/mission-control-v2/deploy/ampel-ci.yml" ".gitea/workflows/ci.yml" \
"CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)" "CI-Ampel"
saat "$REALHOME/mission-control-v2/deploy/opencode/VERIFY.template" "VERIFY" \
"Pruef-Tor (automatisch bei Repo-Anlage eingepflanzt)" "Pruef-Tor"
echo "Repo '$FULL' angelegt (privat, main initialisiert)."
echo "CLONE ${CLONE}"
exit 0 ;;
+246 -98
View File
@@ -1,34 +1,52 @@
#!/usr/bin/env python3
"""Governor — duenner, zustandsloser Token-Waechter-Proxy (Phase 0).
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
Sitzt zwischen einem Coding-Agenten (Aider) und dem Modell-Endpoint (llama-swap
:8080). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
/v1/chat/completions: er schaetzt die Token-Groesse der Anfrage (= Sessiongroesse,
weil die ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
est >= SCHWELLE (soft): haengt eine Stopp-Anweisung als letzte User-Nachricht an
("SAVEPOINT.md finalisieren + stoppen") und leitet weiter. Das Modell schreibt
EINEN ehrlichen Abschluss-Savepoint. Loggt FIRED.
est >= HART-DECKEL (optional): antwortet SELBST mit einer kurzen Stopp-Nachricht,
OHNE das Modell zu fragen. Verhindert, dass ueber die Grenze hinaus
weitergearbeitet wird — genau das erzeugte in Tests eine Fassade. Loggt HARDSTOP.
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
einem einmal geratenen Wert festzuhaengen.
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
und Lucys `loop_status`.
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
Bewusst zustandslos: jede Anfrage wird fuer sich bewertet; keine Sitzungs-DB.
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
Konfiguration per Umgebungsvariablen (alle optional):
GOV_PORT Listen-Port (Default 8100)
GOV_HOST Listen-Adresse (Default 0.0.0.0)
GOV_UPSTREAM Modell-Endpoint (Default http://127.0.0.1:8080)
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 25000)
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
GOV_CHARS_PER_TOKEN Heuristik Zeichen->Token (Default 3.5, kalibriert)
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
GOV_DIRECTIVE Text des Soft-Einschubs (sonst Default unten)
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort (sonst Default unten)
GOV_ANNOUNCE_URL Lucy-Sprach-Signal-Endpunkt; "" = aus (Default :9001/api/voice/announce)
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
GOV_ANNOUNCE_TEXT Text des Sprach-Signals (sonst Default)
GOV_DIRECTIVE Text des Soft-Einschubs
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
"""
import http.client
@@ -37,6 +55,7 @@ import os
import re
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
@@ -44,16 +63,22 @@ from urllib.parse import urlparse
PORT = int(os.environ.get("GOV_PORT", "8100"))
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:8080")
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "25000"))
# Hart-Deckel: Default AN (Soft + 5000 = ein Finalisier-Zug Luft), weil der weiche
# Schnitt allein bei Weiterarbeit ueber die Grenze eine Fassade erzeugt (Befund P0).
# Explizit setzbar; GOV_HARD_CEILING=0 schaltet ihn aus.
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
_hard_env = os.environ.get("GOV_HARD_CEILING")
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.5"))
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
EXEMPT_MODELS = {m.strip().lower() for m in
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
DEFAULT_DIRECTIVE = (
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
@@ -76,12 +101,8 @@ DEFAULT_HARDSTOP = (
)
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
# Sprach-Signal an Lucy (Phase 2): beim Feuern POSTet der Governor eine Meldung an die
# vorhandene MC2-Announce-Pipeline (:9001). Lucy pollt sie ohnehin, dedupliziert und
# spricht sie (gated durch ihren "Box-Meldungen laut"-Schalter). Best-effort, gedrosselt
# gegen die Pro-Runde-Feuerung. GOV_ANNOUNCE_URL="" schaltet das Signal ab.
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300")) # Sekunden
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
DEFAULT_ANNOUNCE = (
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
"Stand im Savepoint; am besten fangen wir gleich frisch an."
@@ -100,9 +121,21 @@ HOP_BY_HOP = {
_log_lock = threading.Lock()
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
_announce_lock = threading.Lock()
_last_announce = 0.0 # Zeitstempel der letzten Meldung (Drossel)
_last_announce = 0.0
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
# ---- Zustand (klein, im Speicher) ------------------------------------------
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
_state_lock = threading.Lock()
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
_cpt_n: dict = {} # modell -> Anzahl Messungen
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
def log(line: str) -> None:
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
@@ -117,28 +150,77 @@ def log(line: str) -> None:
pass
def estimate_tokens(messages) -> int:
"""Grobe, aber stabile Heuristik: Zeichen aller Nachrichteninhalte / CPT.
def cpt_for(model: str) -> float:
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
with _state_lock:
return _cpt.get(model, CHARS_PER_TOKEN)
Gegen die echten prompt_tokens aus der Antwort kalibriert (CPT=3.5 traf am
24.07. auf ~1-3 % genau). Zaehlt Text in String- und Multimodal-Listen-Inhalten;
kleiner Aufschlag je Nachricht fuer Rollen-/Template-Overhead.
def calibrate(model: str, chars: int, exact: int) -> None:
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
return
ratio = chars / exact
if not (CPT_MIN <= ratio <= CPT_MAX):
return
with _state_lock:
n = _cpt_n.get(model, 0)
old = _cpt.get(model, CHARS_PER_TOKEN)
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
w = max(0.08, 1.0 / (n + 2))
_cpt[model] = old * (1 - w) + ratio * w
_cpt_n[model] = n + 1
def body_chars(data: dict) -> int:
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
Wir serialisieren einfach alles, was ans Modell geht.
"""
chars = 0
for m in messages or []:
chars += 4
content = m.get("content") if isinstance(m, dict) else None
if isinstance(content, str):
chars += len(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
chars += len(part["text"])
return int(chars / CHARS_PER_TOKEN)
payload = {k: v for k, v in data.items()
if k in ("messages", "tools", "tool_choice", "system", "functions")}
try:
return len(json.dumps(payload, ensure_ascii=False))
except (TypeError, ValueError):
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
chars = 0
for m in data.get("messages") or []:
c = m.get("content") if isinstance(m, dict) else None
if isinstance(c, str):
chars += len(c) + 4
elif isinstance(c, list):
for p in c:
if isinstance(p, dict) and isinstance(p.get("text"), str):
chars += len(p["text"])
return chars
def safe_to_append(messages) -> bool:
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
"""
if not isinstance(messages, list) or not messages:
return False
last = messages[-1]
if not isinstance(last, dict):
return False
role = last.get("role")
if role == "tool":
return False
return not (role == "assistant" and last.get("tool_calls"))
def _post_announce(est) -> None:
"""POSTet die Meldung an die MC2-Announce-Pipeline. Laeuft im Hintergrund-Thread."""
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
try:
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
@@ -158,8 +240,7 @@ def _post_announce(est) -> None:
def maybe_announce(est) -> None:
"""Sprach-Signal an Lucy ausloesen — gedrosselt, damit die Pro-Runde-Feuerung
nicht spammt (eine Aeusserung je Ueberschreitungs-Episode). Best-effort."""
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
if not _an:
return
global _last_announce
@@ -171,14 +252,35 @@ def maybe_announce(est) -> None:
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
def status_payload() -> dict:
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
with _state_lock:
return {
"ok": True,
"upstream": UPSTREAM,
"soft": THRESHOLD,
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
"uptime_s": int(time.time() - _counters["started"]),
"counters": {k: v for k, v in _counters.items() if k != "started"},
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
for m, v in _cpt.items()},
"calibration_default": CHARS_PER_TOKEN,
"exempt_models": sorted(EXEMPT_MODELS),
"recent": list(_recent),
}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "Governor/0.2"
server_version = "Governor/2.0"
def log_message(self, *args):
pass
def do_GET(self):
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
self._send_json(200, status_payload())
return
self._proxy()
def do_POST(self):
@@ -194,6 +296,19 @@ class Handler(BaseHTTPRequestHandler):
self._proxy()
# -- Kern ---------------------------------------------------------------
def _send_json(self, status: int, obj) -> None:
try:
data = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
except OSError:
pass
def _read_body(self) -> bytes:
length = self.headers.get("Content-Length")
if length is None:
@@ -206,8 +321,6 @@ class Handler(BaseHTTPRequestHandler):
def _proxy(self) -> None:
body = self._read_body()
path = self.path
# Query-String vor der Endpunkt-Erkennung abschneiden (sonst umgeht
# z. B. ?api-version=... den Governor).
clean_path = path.split("?", 1)[0]
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
@@ -215,19 +328,23 @@ class Handler(BaseHTTPRequestHandler):
est = None
streaming = False
model = ""
chars = 0
if is_chat and body:
action, body, est, streaming, model = self._decide(body)
action, body, est, streaming, model, chars = self._decide(body)
if action in ("soft", "hard"):
maybe_announce(est) # Sprach-Signal an Lucy (gedrosselt)
maybe_announce(est)
# HARTER STOPP: selbst antworten, Upstream nie fragen.
if action == "hard":
self._send_canned_stop(model, streaming, est)
log(f"chat est={est} thr={THRESHOLD} hard={HARD_CEILING} HARDSTOP "
f"stream={streaming} status=200")
with _state_lock:
_counters["chat"] += 1
_counters["hard"] += 1
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
"exact": None, "action": "hard"})
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
f"HARDSTOP stream={streaming} status=200")
return
# Header fuer Upstream aufbereiten.
out_headers = {}
for k, v in self.headers.items():
kl = k.lower()
@@ -242,7 +359,7 @@ class Handler(BaseHTTPRequestHandler):
conn = None
try:
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=600)
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
conn.request(self.command, path, body=body or None, headers=out_headers)
resp = conn.getresponse()
except (OSError, http.client.HTTPException) as exc:
@@ -255,7 +372,6 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(resp.status)
for k, v in resp.getheaders():
kl = k.lower()
# hop-by-hop + Laenge raus; Date/Server setzt send_response schon selbst.
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
continue
self.send_header(k, v)
@@ -266,8 +382,7 @@ class Handler(BaseHTTPRequestHandler):
try:
while True:
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
# Durchreichen). read() wuerde bis 64 KB oder Stream-Ende puffern
# und streamendes Aider die ganze Generierung haengen lassen.
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
chunk = resp.read1(65536)
if not chunk:
break
@@ -283,41 +398,82 @@ class Handler(BaseHTTPRequestHandler):
exact = self._scan_prompt_tokens(tail)
if is_chat:
if exact:
calibrate(model, chars, exact)
with _state_lock:
_counters["chat"] += 1
_counters["soft" if action == "soft" else "passthrough"] += 1
if exact:
_counters["tokens_prompt"] += exact
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
"exact": exact, "action": action})
exact_s = str(exact) if exact is not None else "-"
flag = "FIRED" if action == "soft" else "ok"
log(f"chat est={est} exact={exact_s} thr={THRESHOLD} {flag} "
f"stream={streaming} status={resp.status}")
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
def _decide(self, body: bytes):
"""Aktion bestimmen: passthrough | soft (Einschub) | hard (Selbstantwort).
Rueckgabe: (action, body, est, streaming, model).
"""
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
try:
data = json.loads(body)
except (ValueError, UnicodeDecodeError):
return "passthrough", body, None, False, ""
return "passthrough", body, None, False, "", 0
if not isinstance(data, dict):
return "passthrough", body, None, False, ""
return "passthrough", body, None, False, "", 0
messages = data.get("messages")
streaming = bool(data.get("stream"))
model = data.get("model", "") or ""
est = estimate_tokens(messages if isinstance(messages, list) else [])
model = (data.get("model") or "").strip()
chars = body_chars(data)
est = int(chars / max(cpt_for(model), 0.1))
if HARD_CEILING > 0 and est >= HARD_CEILING:
return "hard", body, est, streaming, model
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
base = model.split("/")[-1].lower()
if base in EXEMPT_MODELS:
return "passthrough", body, est, streaming, model, chars
if est >= THRESHOLD and isinstance(messages, list):
# Sichere Substitution statt str.format: ein operator-gesetzter
# GOV_DIRECTIVE mit { } (JSON/Code-Beispiel) darf nicht crashen.
directive = (DIRECTIVE.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
messages.append({"role": "user", "content": directive})
data["messages"] = messages
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model
if not isinstance(messages, list):
return "passthrough", body, est, streaming, model, chars
return "passthrough", body, est, streaming, model
has_warned = False
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
for m in reversed(messages):
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
has_warned = True
break
is_safe = safe_to_append(messages)
# 1. Absolutes Not-Aus bei komplettem Amoklauf
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
return "hard", body, est, streaming, model, chars
# 2. Sind wir am Limit?
if est >= THRESHOLD:
if not is_safe:
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
return "defer", body, est, streaming, model, chars
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
if HARD_CEILING > 0 and est >= HARD_CEILING:
if has_warned:
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
return "hard", body, est, streaming, model, chars
else:
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
pass
# Soft-Limit greift
if not has_warned:
directive = (DIRECTIVE.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
messages.append({"role": "user", "content": directive})
data["messages"] = messages
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
return "passthrough", body, est, streaming, model, chars
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
@@ -366,16 +522,7 @@ class Handler(BaseHTTPRequestHandler):
pass
def _safe_error(self, status: int, msg: str) -> None:
try:
data = json.dumps({"error": msg}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
except OSError:
pass
self._send_json(status, {"error": msg})
@staticmethod
def _scan_prompt_tokens(tail: bytearray):
@@ -405,8 +552,9 @@ def main() -> int:
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.daemon_threads = True
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
log(f"Governor startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
f"Hart={hard} | CPT={CHARS_PER_TOKEN} | Log={LOG_PATH}")
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
try:
server.serve_forever()
except KeyboardInterrupt:
+27
View File
@@ -0,0 +1,27 @@
[Unit]
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
After=network-online.target mission-control-2.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/governor
ExecStart=/usr/bin/python3 %h/governor/governor.py
Restart=always
RestartSec=3
# --- Schwellen -------------------------------------------------------------
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
Environment=GOV_THRESHOLD=45000
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
Environment=GOV_LOG=%h/governor/governor.log
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
[Install]
WantedBy=default.target
+15 -5
View File
@@ -72,12 +72,22 @@ fi
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
if [ -n "$API_KEY" ]; then
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
# Mit Retry — GENAU wie der Voice-Smoke darunter, und aus demselben Grund: direkt nach
# einem Update ist das Modell kalt. Ein Kaltstart kostet ~20-30 s Laden PLUS ~37 s
# Prefill für Hermes' ~70-KB-Systemprompt (33 Tool-Schemas) — zusammen mit zwei
# Inferenz-Runden sprengt das die 90 s des ersten Versuchs. Warm gemessen: 12 s.
# Ein Versuch allein war ein Fehlalarm-Generator (live erlebt 25.07., Job faf1137a).
TOOL_RESP=""
for try in 1 2 3; do
TOOL_RESP=$(curl -sf -m 120 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke-$try" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
echo "$TOOL_RESP" | grep -qi "postcheck-ok" && break
[ "$try" -lt 3 ] && sleep 10
done
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis"
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis (Versuch $try)"
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
else
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=MC2 Morgen-Digest (Nächtliche Telegram-Zusammenfassung)
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/morgen-digest.sh
TimeoutStartSec=300
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Timer für MC2 Morgen-Digest (07:00 Uhr)
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Morgen-Digest: Sammelt die zurückgehaltenen nächtlichen Meldungen (00:00 - 06:59)
# und lässt Lucy (LLM) eine Telegram-Zusammenfassung daraus bauen.
set -uo pipefail
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
if [ ! -f "$NIGHT_QUEUE" ]; then
# Keine nächtlichen Meldungen
exit 0
fi
# Wenn die Datei existiert, aber leer ist, löschen und beenden.
if [ ! -s "$NIGHT_QUEUE" ]; then
rm -f "$NIGHT_QUEUE"
exit 0
fi
EVIDENCE="$(cat "$NIGHT_QUEUE")"
PROMPT="Du bist Lucy, die KI-Assistenz der Mission-Control-Box.
Hier sind die Systemmeldungen der vergangenen Nacht.
Der Commander möchte ordentlich Futter und Details zum Frühstück! Fasse die Meldungen zu einem gehaltvollen Morgen-Digest zusammen.
Beachte dabei:
- Nenne konkrete Fakten, Namen und Versionen.
- Welche Modelle wurden z.B. vom Radar entdeckt oder evaluiert? Was waren die Ergebnisse?
- Welche Updates liefen genau und was haben sie Neues gebracht? Was muss sich der Commander noch anschauen?
- Strukturiere die Nachricht übersichtlich (z.B. mit kleinen Emojis oder Bulletpoints), aber bleibe in deiner Rolle als Lucy.
Nächtliche Meldungen:
$EVIDENCE"
# LLM aufrufen (llama-swap / gpt-oss-120b)
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
MODEL="${CHEF_MODEL:-gpt-oss-120b}"
REQ="$(jq -n --arg m "$MODEL" --arg sys "Du bist Lucy." --arg usr "$PROMPT" --argjson mt 1200 \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.4}')"
DIGEST="$(curl -s -m 180 "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$REQ" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
except Exception:
pass'
)"
if [ -n "$DIGEST" ]; then
# Sende den Digest via hermes send (Telegram)
bash -lc 'hermes send --to telegram --subject "[🌅 Morgen-Digest]" -- "$1"' _ "$DIGEST"
if [ $? -eq 0 ]; then
# Erfolgreich gesendet, Queue leeren
rm -f "$NIGHT_QUEUE"
else
echo "Fehler beim Senden des Morgen-Digests an Telegram. Queue bleibt erhalten." >&2
fi
else
echo "Leerer Digest vom LLM. Queue bleibt erhalten." >&2
fi
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Erwartet den Job-Namen und das Skript als ein String oder zwei Argumente
if [ $# -eq 1 ]; then
read -r JOB_NAME SCRIPT_NAME <<< "$1"
else
JOB_NAME="$1"
SCRIPT_NAME="$2"
fi
if [ -z "$SCRIPT_NAME" ]; then
SCRIPT_NAME="$JOB_NAME"
fi
# Pfad zusammenbauen
SCRIPT_PATH="$HOME/mission-control-v2/deploy/$SCRIPT_NAME"
if [ ! -f "$SCRIPT_PATH" ]; then
# Versuche pruefstand/
SCRIPT_PATH="$HOME/mission-control-v2/deploy/pruefstand/$SCRIPT_NAME"
fi
bash "$SCRIPT_PATH" | bash "$HOME/mission-control-v2/deploy/notify.sh" -s "$JOB_NAME"
+19 -2
View File
@@ -22,8 +22,8 @@ shift $((OPTIND - 1))
MSG="${1:-}"
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
if [ -z "$MSG" ]; then
echo "usage: notify.sh [-s subject] <nachricht>" >&2
exit 2
# Bei leerer Pipe-Eingabe lautlos beenden (wichtig für Cron-Skripte ohne Ausgabe)
exit 0
fi
TS="$(date '+%Y-%m-%d %H:%M:%S')"
@@ -41,6 +41,23 @@ PY
)" >/dev/null 2>&1 || true
fi
# Nachtweiche: Zwischen 00:00 und 06:59 Uhr (inklusive) fangen wir Telegram-Nachrichten ab
# und sammeln sie für den Morgen-Digest, ES SEI DENN es ist ein Alarm.
HOUR="$(date +%H)"
if [ "$HOUR" -ge 0 ] && [ "$HOUR" -lt 7 ]; then
case "${SUBJECT,,}" in
*alarm*|*notfall*)
;; # Alarme gehen immer sofort durch
*)
# Sammeln für Morgen-Digest
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
echo "- ${SUBJECT:+$SUBJECT: }$MSG" >> "$NIGHT_QUEUE"
echo "$TS QUEUED für Morgen-Digest: $MSG" >> "$LOG"
exit 0
;;
esac
fi
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
if [ -n "$SUBJECT" ]; then
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# opencode-lauf.sh — EIN begrenzter Agentenlauf auf der Box, mit denselben Regeln wie in Zed.
#
# Das ist die Nacht-Seite von „ein Regelwerk, zwei Ausloeser": tagsueber tippst du in Zed,
# nachts ruft ein Hermes-Cron dieses Skript. Beide Wege benutzen
# * dieselbe OpenCode-Version,
# * dieselbe Mannschaft (~/.config/opencode/opencode.json: coder/hermes/kritiker),
# * dasselbe Plugin (~/.config/opencode/plugin/mc2-governor.ts: Zaun + Pruef-Tor),
# * denselben Token-Waechter (:8100).
#
# Unterschied zu deploy/worker.sh: worker.sh ist EIN zustandsloser Completion-Aufruf
# (Hermes schreibt die Dateien selbst). Hier laeuft ein ECHTER Agent mit Datei-Haenden,
# Subagenten und Pruef-Tor — fuer ganze Karten statt fuer Schnipsel.
#
# Nutzung:
# opencode-lauf.sh <repo-pfad> "<auftrag>"
# opencode-lauf.sh ~/projekte/foo "Baue X. Halte dich an AGENTS.md."
#
# Env:
# LAUF_TIMEOUT Sekunden Hoechstdauer (Default 3600)
# LAUF_LAUT 1 = Lucy spricht mit (Default 0 = still, Nachtbetrieb)
# LAUF_AGENT OpenCode-Agent (Default build)
set -uo pipefail
REPO="${1:-}"
AUFTRAG="${2:-}"
TIMEOUT="${LAUF_TIMEOUT:-3600}"
AGENT="${LAUF_AGENT:-build}"
OC="$HOME/.opencode/bin/opencode"
ANNOUNCE="${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}"
melde () { # melde <betreff> <text> [prioritaet]
curl -sf -m 5 -X POST "$ANNOUNCE" -H 'Content-Type: application/json' \
--data "$(python3 -c 'import json,sys; print(json.dumps({"subject":sys.argv[1],"text":sys.argv[2],"source":"loop","priority":sys.argv[3]}))' \
"$1" "$2" "${3:-silent}")" >/dev/null 2>&1 || true
}
if [ -z "$REPO" ] || [ -z "$AUFTRAG" ]; then
echo "Nutzung: $0 <repo-pfad> \"<auftrag>\"" >&2
exit 2
fi
if [ ! -d "$REPO" ]; then
echo "FEHLER: '$REPO' ist kein Verzeichnis." >&2
exit 2
fi
if [ ! -x "$OC" ]; then
echo "FEHLER: OpenCode nicht gefunden ($OC)." >&2
exit 2
fi
# Der Governor MUSS stehen — ohne ihn liefe der Lauf ohne Sitzungs-Bremse.
if ! curl -sf -m 5 -o /dev/null "http://127.0.0.1:8100/governor/status"; then
echo "FEHLER: Governor (:8100) antwortet nicht — Lauf abgebrochen (keine Sitzungs-Bremse)." >&2
melde "[Lauf]" "Ich habe einen Nachtlauf abgebrochen: der Token-Waechter antwortet nicht." "normal"
exit 3
fi
# Nachts still: Meldungen landen im Briefkasten, Lucy spricht sie aber nicht aus.
# Das Plugin liest diese Variable; die Morgen-Zusammenfassung kommt vom Daily-Briefing.
if [ "${LAUF_LAUT:-0}" = "1" ]; then export MC2_LOOP_SILENT=0; else export MC2_LOOP_SILENT=1; fi
LOGDIR="$HOME/.hermes/logs"; mkdir -p "$LOGDIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG="$LOGDIR/opencode-lauf-$STAMP.log"
MAXRUNDEN="${LAUF_MAX_RUNDEN:-3}"
cd "$REPO" || exit 2
NAME="$(basename "$REPO")"
melde "[Lauf]" "Ich fange an zu bauen: $NAME." "silent"
echo "=== Lauf $STAMP · Repo $REPO · Agent $AGENT · Timeout ${TIMEOUT}s ===" | tee "$LOG"
# Verify-Befehl des Projekts lesen (gleiche Datei und gleiche Regeln wie im Plugin).
verify_cmd () {
[ -r "$REPO/VERIFY" ] || return 1
grep -vE '^\s*(#|$)' "$REPO/VERIFY" | paste -sd' && ' -
}
START=$(date +%s)
CODE=0
RUNDE=0
AUFGABE="$AUFTRAG"
# ── Bau-Schleife ────────────────────────────────────────────────────────────
# Warum hier UND im Plugin? Das Plugin haengt am Ereignis `session.idle` — in Zed
# laeuft der Prozess weiter und alles ist gut. Bei `opencode run` beendet sich der
# Prozess aber, bevor das Pruef-Tor fertig ist (gemessen 25.07.). Fuer unbeaufsichtigte
# Laeufe muss die Schleife deshalb HIER liegen, wo sie den Prozess ueberlebt.
while :; do
RUNDE=$((RUNDE + 1))
echo "--- Runde $RUNDE/$MAXRUNDEN ---" | tee -a "$LOG"
timeout "$TIMEOUT" "$OC" run --agent "$AGENT" "$AUFGABE" >>"$LOG" 2>&1
CODE=$?
[ "$CODE" -eq 124 ] && { echo "ZEITUEBERSCHREITUNG" | tee -a "$LOG"; break; }
VCMD="$(verify_cmd)" || { echo "Kein VERIFY — Pruef-Tor aus, Lauf endet." | tee -a "$LOG"; break; }
echo "--- Pruef-Tor: $VCMD ---" | tee -a "$LOG"
VOUT="$(cd "$REPO" && eval "$VCMD" 2>&1)"; VCODE=$?
printf '%s\n' "$VOUT" | tail -20 >> "$LOG"
if [ "$VCODE" -eq 0 ]; then
echo "PRUEF-TOR GRUEN" | tee -a "$LOG"
melde "[Pruefung]" "$NAME: Tests gruen nach $RUNDE Runde(n)." "normal"
CODE=0
break
fi
if [ "$RUNDE" -ge "$MAXRUNDEN" ]; then
echo "PRUEF-TOR ROT — Reparaturrunden aufgebraucht." | tee -a "$LOG"
melde "[Pruefung]" "$NAME: Tests bleiben rot nach $RUNDE Runden. Hier komme ich allein nicht weiter." "normal"
CODE=1
break
fi
echo "PRUEF-TOR ROT — Runde $((RUNDE + 1)) folgt." | tee -a "$LOG"
melde "[Pruefung]" "$NAME: Tests rot, ich repariere selbst weiter (Runde $((RUNDE + 1))/$MAXRUNDEN)." "silent"
AUFGABE="[MC2-PRUEFTOR] Der Verify-Befehl des Projekts ist fehlgeschlagen.
Befehl: $VCMD
Ausgabe (Ende):
$(printf '%s' "$VOUT" | tail -c 3000)
Behebe die URSACHE — nicht das Symptom. Schalte keinen Test ab und aendere keine Tests.
Urspruenglicher Auftrag war: $AUFTRAG"
done
DAUER=$(( $(date +%s) - START ))
# Nachweis statt Behauptung: was hat der Lauf im Arbeitsbaum tatsaechlich veraendert?
GEAENDERT="$(git -C "$REPO" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
case "$CODE" in
0) ERG="fertig, Pruefung bestanden" ;;
124) ERG="ZEITUEBERSCHREITUNG nach ${TIMEOUT}s" ;;
*) ERG="Pruefung NICHT bestanden (Exitcode $CODE)" ;;
esac
echo "=== Ergebnis: $ERG · ${DAUER}s · $RUNDE Runde(n) · $GEAENDERT geaenderte Dateien · Log $LOG ===" | tee -a "$LOG"
melde "[Lauf]" "$NAME: $ERG nach $((DAUER/60)) Minuten, $RUNDE Runde(n), $GEAENDERT Dateien angefasst." "silent"
exit "$CODE"
+82
View File
@@ -0,0 +1,82 @@
# Persönliche Regeln (gelten für ALLE Projekte)
## So arbeitest du mit mir
- Antworte auf **Deutsch**, Fazit zuerst, Technik in Alltagssprache. Ich bin **kein Entwickler**.
- **Plan vor Code** bei allem Größeren: erst Vorgehen zeigen, auf mein **„los"** warten.
- **Kleine Schritte**, eine Sache zur Zeit. Nichts Ungefragtes ausführen; **frag**, bevor du löschst
oder überschreibst.
- **Beweisen statt behaupten** — am Ende live testen, nicht „sollte gehen".
- Nimm **Standard-Wege**, keine exotische Technik, außer ich will es ausdrücklich anders.
- **Geheimnisse** (Schlüssel/Passwörter/Tokens) nie in den Code — in `.env` (+ `.gitignore`); warnen,
bevor so etwas in einen Commit wandert.
- **Nicht festfahren:** klemmt dieselbe Sache nach 2 Versuchen, anhalten, sichern, erklären und fragen.
- **Bau-Prinzipien sind DEIN Job, nicht meine Frage:** KISS, YAGNI, Zuständigkeiten trennen, DRY,
sprechende Namen, schlank starten. Frag mich nie danach — frag mich nur nach Zielen in Alltagssprache.
- **Faulheits-Leiter vor jeder neuen Zeile/Abhängigkeit:** (1) muss das überhaupt sein? (2) gibt's das
schon (wiederverwenden)? (3) reicht Bordmittel/Standard-Bibliothek? (4) erst dann minimal selbst bauen.
Faul bei Lösungen, nie beim Verstehen; Sicherheitsnetze (Prüfung/Fehlerbehandlung/keine Geheimnisse)
bleiben. „Prüf auf Über-Engineering" = Diff auf unnötige Abhängigkeiten/Abstraktion/Verdachts-Features durchsehen.
## Deine Mannschaft — delegiere, statt alles selbst zu tun
- **`explore`** (Subagent): Code durchsuchen, Dateien finden, Fragen zum Bestand beantworten. Läuft auf
dem ohnehin warmen Hirn und kostet praktisch nichts. **Nutze ihn**, statt dich selbst durch den Baum
zu wühlen — dein eigener Kontext bleibt dadurch sauber und du hältst länger durch.
- **`review`** (Subagent): kritisches Gegenlesen nach jeder Etappe. Läuft bewusst auf einer **fremden
Modellfamilie**, damit er andere blinde Flecken hat als du. Nimm seine Befunde ernst.
- **`plan`**: nur lesen, keine Änderungen.
- Es gibt **keinen Modellwechsel mehr**. Früher galt „zum Bauen oben auf Coder umschalten" — Planen und
Bauen laufen inzwischen auf demselben Modell. Bitte mich nie, etwas umzustellen.
## Das Prüf-Tor (`VERIFY`)
- Im Projekt liegt eine Datei `VERIFY` mit **einer Zeile: wie man dieses Projekt testet**. Sobald du
„fertig" meldest, wird sie automatisch ausgeführt.
- **Rot heißt nicht fertig.** Der Fehler kommt zu dir zurück, du behebst die **Ursache** — nicht das
Symptom. Einen Test abschwächen, überspringen oder umschreiben, damit er grün wird, gilt als Betrug.
- Fehlt die Datei, ist das Prüf-Tor aus. Dann **frag mich**, was der richtige Testbefehl wäre, statt
ungeprüft weiterzubauen.
## Kontext-Wächter
- Ein Token-Wächter zählt jede Sitzung mit und schneidet sie bei ~45.000 Tokens. Du musst nichts messen
— die Ansage kommt von allein.
- Kommt sie: **`SAVEPOINT.md` ehrlich fertigschreiben und anhalten.** Ehrlich heißt: nur was **wirklich
im Code steht**. Nichts aus Absicht, Plan oder git-Nachrichten ableiten. Dazu der genaue nächste
Schritt, offene Fragen und Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung
allein damit weitermachen kann.
- Merkst du vorher schon, dass du dich wiederholst oder abdriftest: sag es proaktiv und schlag einen
frischen Chat vor.
## Wenn niemand da ist (unbeaufsichtigte Läufe)
Nachts startet ein Cron dieselbe Umgebung ohne Menschen davor. Erkennst du das — der Auftrag kam als
ein Block, auf Rückfragen antwortet niemand: **warte nicht auf ein „los"**. Halte dich an `KONZEPT`/
`SAVEPOINT.md`, bau in kleinen Etappen und lass das Prüf-Tor entscheiden. Was du ohne Antwort nicht
sauber entscheiden kannst, schreibst du als offene Frage in `SAVEPOINT.md`, statt zu raten.
## Was du nie tust
`git push`, Historie umschreiben, rekursiv löschen, `sudo`, Pakete veröffentlichen. Das ist gezäunt und
wird geblockt — nicht weil du es falsch machst, sondern weil Veröffentlichen meine Entscheidung ist.
Brauchst du so etwas wirklich: sag es mir, ich mache es selbst.
## Bestehendes Projekt fortsetzen?
Ordner nicht leer / neue Sitzung: zuerst **`SAVEPOINT.md` + `AGENTS.md` lesen**, mir in 2 Sätzen sagen
„hier stehen wir / nächster Schritt", dann weitermachen — nicht neu planen.
## Neues Projekt? → Projekt-Start-Ablauf
Wenn ein **neues** Projekt beginnt (leerer/neuer Ordner, „bau mir…", „neues Projekt…", „ich hätte
gern…"): folge diesem Ablauf. **Keine Datei, kein Code vor meiner Freigabe.**
1. **Verstehen:** erst herausfinden, *was für ein* Projekt (Web-App / kleines Skript /
Box-Lucy-Erweiterung / Experiment / Datensache), dann **36 typ-passende Fragen** + „woran ist
es fertig?".
2. **Plan vorlegen** (Ziel · Vorgehen · Technik + warum · Fertig-Kriterien · Risiken) → **STOPP**,
auf mein „los" warten.
3. **Erst dann Gerüst:** `git init` + die Projektdateien (`AGENTS.md`, `SAVEPOINT.md`, `.aiexclude`,
`VERIFY`) + `README`, dann **Sicherungspunkt „start"** (erster Commit).
4. In **kleinen Schritten** bauen, an jedem Meilenstein + vor riskanten Änderungen einen
**Sicherungspunkt** (Commit mit klarem Namen) setzen, am Ende **live testen**.
## Bei großem Meilenstein UND Session-Ende („Feierabend"/„sichern")
Immer fortschreiben: **`SAVEPOINT.md`** (Stand · Fertiges · nächste Schritte · offene Fragen — die
Übergabe an die nächste Sitzung), **`AGENTS.md`** (falls Konventionen sich änderten), **`.aiexclude`**
(neue Geheimnisse/große Dateien), **`VERIFY`** (falls sich der Testbefehl geändert hat), dann
Sicherungspunkt setzen.
Volle Fassung des Projekt-Start-Ablaufs: `F:\Coding Stuff\projekt-start\SKILL.md`
+75
View File
@@ -0,0 +1,75 @@
# OpenCode-Seite: ein Regelwerk, zwei Auslöser
Tagsüber tippst du in **Zed** (PC), nachts ruft ein **Hermes-Cron** dasselbe (Box).
Beide Wege benutzen dieselbe OpenCode-Version, dieselbe Mannschaft, dasselbe Plugin
und denselben Token-Wächter. Der einzige Unterschied ist die Adresse des Governors.
## Was wohin gehört
| Datei hier | Ziel auf dem PC | Ziel auf der Box |
|---|---|---|
| `opencode.pc.json` | `~/.config/opencode/opencode.json` | — |
| `opencode.box.json` | — | `~/.config/opencode/opencode.json` |
| `AGENTS.global.md` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode/AGENTS.md` |
| `plugin/mc2-governor.ts` | `~/.config/opencode/plugin/` | `~/.config/opencode/plugin/` |
| `VERIFY.template` | — | wird von `gitea-repo-create.sh` in **jedes neue Repo** als `VERIFY` gesät |
### Warum die Regeln hier liegen und nicht in Zed
Die persönlichen Arbeitsregeln standen bis 25.07.2026 in `AppData\Roaming\Zed\AGENTS.md` — und
haben dort **nie den Coding-Agenten erreicht**. Zeds eigene Doku ist eindeutig: `AGENTS.md` und
Skills gelten **nur für Zeds nativen Agenten**, nicht für ACP-Agenten wie OpenCode. Da hier über
ACP gearbeitet wird, liefen die Regeln jahrelang ins Leere.
OpenCode liest stattdessen (in dieser Reihenfolge): `AGENTS.md` im Projekt (aufwärts gesucht) und
`~/.config/opencode/AGENTS.md` global. Beide gelten zusammen — Projektregeln ergänzen die globalen.
Beim Umzug bewusst geändert:
- **Zeds Commit-Vorlage entfernt.** Sie war in dieselbe Datei gerutscht und enthielt „Only return
the commit message in your response" — in einer dauerhaft aktiven Regeldatei ein Fehlerherd.
- **Modellwähler-Absatz gestrichen.** „Zum Bauen oben auf Coder umschalten" stimmt seit der
Mannschafts-Umstellung nicht mehr: `plan` und `build` laufen auf demselben Modell.
- **Kontext-Wächter umgeschrieben** auf den Governor, der den Füllstand wirklich kennt.
- **Neu:** Prüf-Tor (`VERIFY`), die Subagenten-Mannschaft und ein Abschnitt für **unbeaufsichtigte
Läufe** — nachts sagt niemand „los", also darf der Agent dort nicht auf Freigabe warten.
Der Governor selbst liegt in `../governor/` (Proxy + systemd-Unit), der unbeaufsichtigte
Läufer in `../opencode-lauf.sh`.
## Die Mannschaft
| Rolle | Modell | Gemessen (25.07.2026) | Warum |
|---|---|---|---|
| `plan` + `build` | `coder` — Qwen3-Coder-Next | **51,5 t/s** | Hält den Faden, verteilt Zuarbeit. MoE mit 3B aktiv → schnell auf Strix Halo. |
| `explore` | `hermes` — Qwen3.6-35B | **69,6 t/s** | Ohnehin dauerwarm → kostet **null** zusätzlichen Speicher. Sucht, liest, meldet kurz zurück. |
| `review` | `kritiker` — Devstral-Small-2 | **15,0 t/s** | Bewusst eine **fremde Modellfamilie** (Mistral statt Qwen) → andere blinde Flecken. Dicht = langsam beim Schreiben, aber ein Kritiker liest viel und schreibt wenig. |
`heavy` (gpt-oss-120b, 63 GB) ist **nicht** mehr in der Tagesrolle: es würde beim Laden
das ganze warme Set verdrängen. Es bleibt der Nacht-Gutachter (4:30-Cron).
## Nach einer Änderung
Die Dateien hier sind **Vorlagen**, keine Live-Konfiguration. Nach einer Änderung
verteilen:
```bash
# Box
scp deploy/opencode/opencode.box.json hitonabi@192.168.178.151:~/.config/opencode/opencode.json
scp deploy/opencode/plugin/*.ts hitonabi@192.168.178.151:~/.config/opencode/plugin/
# PC (aus dem Repo heraus)
cp deploy/opencode/opencode.pc.json ~/.config/opencode/opencode.json
cp deploy/opencode/plugin/*.ts ~/.config/opencode/plugin/
```
**Zed muss danach neu gestartet werden** — OpenCode liest seine Konfiguration nur beim Start.
## Fallen, die Zeit gekostet haben
- **Kein `_comment`-Schlüssel in `opencode.json`.** OpenCode validiert streng und
verweigert den Start mit „Unrecognized key". Kommentare gehören in dieses README.
- **Das Plugin läuft auf Windows UND Linux.** Deshalb `node:fs` statt `cat` und Buns
`${{ raw: cmd }}` statt `bash -lc` — beides fehlt auf Windows bzw. verschluckt den Befehl.
- **Bei `opencode run` beendet sich der Prozess, bevor `session.idle` fertig ist**
(gemessen 25.07.). Für unbeaufsichtigte Läufe liegt die Prüf-Schleife deshalb
zusätzlich in `opencode-lauf.sh`, wo sie den Prozess überlebt. In Zed greift das Plugin.
- **Plugin-Verzeichnis:** `plugin/` und `plugins/` werden beide erkannt; wir nutzen `plugin/`.
+21
View File
@@ -0,0 +1,21 @@
# VERIFY — wie man dieses Projekt prueft.
#
# Diese Datei ist das Pruef-Tor. Das MC2-Governor-Plugin fuehrt sie aus, sobald der
# Agent "fertig" sagt. Gruen -> Etappe gilt als fertig. Rot -> der Fehler geht
# automatisch als naechster Auftrag an den Agenten zurueck, bis zu 3 Runden.
#
# Regeln:
# * Eine Zeile = ein Befehl. Alle Zeilen werden mit && verkettet.
# * Zeilen mit # sind Kommentare.
# * KEINE Datei VERIFY im Projekt = Pruef-Tor aus (nichts passiert).
# * Der Befehl muss ohne Rueckfragen durchlaufen und mit 0 enden, wenn alles gut ist.
#
# Beispiele (unzutreffende Zeilen loeschen):
#
# Python: ruff check . && pytest -q
# Node/TS: npm run lint && npm test
# Frontend: npm run build
# Nur Syntax: python -m compileall -q .
# Nichts da: git diff --stat (laeuft immer gruen — Platzhalter)
git diff --stat
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"aibox": {
"npm": "@ai-sdk/openai-compatible",
"name": "AI-Box ueber Governor (lokal)",
"options": {
"baseURL": "http://127.0.0.1:8100/v1",
"apiKey": "local"
},
"models": {
"coder": {
"name": "coder — Bauen + Planen (Qwen3-Coder-Next, 51,5 t/s)",
"limit": {
"context": 131072,
"output": 16384
}
},
"hermes": {
"name": "hermes — Suchen (Qwen3.6-35B, immer warm, 69,6 t/s)",
"limit": {
"context": 65536,
"output": 8192
}
},
"kritiker": {
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
"limit": {
"context": 16384,
"output": 4096
}
},
"heavy": {
"name": "heavy — Nacht-Gutachter (gpt-oss-120b, verdraengt das warme Set!)",
"limit": {
"context": 32768,
"output": 8192
}
}
}
}
},
"model": "aibox/coder",
"small_model": "aibox/hermes",
"agent": {
"plan": {
"model": "aibox/coder",
"permission": {
"edit": "deny",
"bash": "deny"
}
},
"build": {
"model": "aibox/coder",
"permission": {
"edit": "allow",
"webfetch": "allow",
"bash": {
"*": "allow",
"ssh *": "deny",
"scp *": "deny",
"sftp *": "deny",
"ssh arcane@192.168.178.162 *": "allow",
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
"scp *arcane@192.168.178.162*": "allow"
}
}
},
"explore": {
"mode": "subagent",
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, laeuft auf dem immer warmen Hirn",
"model": "aibox/hermes",
"permission": {
"edit": "deny",
"bash": "deny"
}
},
"review": {
"mode": "subagent",
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, aendert nichts. Laeuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
"model": "aibox/kritiker",
"permission": {
"edit": "deny",
"bash": "deny"
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"aibox": {
"npm": "@ai-sdk/openai-compatible",
"name": "AI-Box ueber Governor (192.168.178.151:8100)",
"options": {
"baseURL": "http://192.168.178.151:8100/v1",
"apiKey": "local"
},
"models": {
"heavy": {
"name": "heavy — Planer (gpt-oss-120b, 32k)",
"limit": {
"context": 32768,
"output": 8192
}
},
"coder": {
"name": "coder — Bauen (Qwen3-Coder-Next, 131k)",
"limit": {
"context": 131072,
"output": 16384
}
},
"hermes": {
"name": "hermes — Erkunden (Qwen3.6, immer warm, 69,6 t/s)",
"limit": {
"context": 65536,
"output": 8192
}
},
"kritiker": {
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
"limit": {
"context": 16384,
"output": 4096
}
}
}
}
},
"model": "aibox/coder",
"small_model": "aibox/hermes",
"agent": {
"plan": {
"model": "aibox/coder",
"permission": {
"edit": "deny",
"bash": "deny"
}
},
"build": {
"model": "aibox/coder",
"permission": {
"edit": "allow",
"webfetch": "allow",
"bash": {
"*": "allow",
"ssh *": "deny",
"scp *": "deny",
"sftp *": "deny",
"ssh arcane@192.168.178.162 *": "allow",
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
"scp *arcane@192.168.178.162*": "allow"
}
}
},
"explore": {
"mode": "subagent",
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, läuft auf dem immer warmen Hirn",
"model": "aibox/hermes",
"permission": {
"edit": "deny",
"bash": "deny"
}
},
"review": {
"mode": "subagent",
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, ändert nichts. Läuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
"model": "aibox/kritiker",
"permission": {
"edit": "deny",
"bash": "deny"
}
}
}
}
+248
View File
@@ -0,0 +1,248 @@
/**
* MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten.
*
* Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die
* Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden
* Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge:
*
* 1. WERKZEUG-ZAUN (tool.execute.before)
* Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push,
* Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser
* Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 —
* nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen.
*
* 2. PRUEF-TOR + SCHLEIFE (session.idle)
* Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei
* `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler
* geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu
* MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse.
*
* 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted)
* Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper
* "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md
* zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat.
*
* 4. STIMME (MC2 /api/voice/announce)
* Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten.
* Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code.
*
* Schalter (Umgebungsvariablen):
* MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001)
* MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an)
* MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3)
* MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an)
* MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen
* MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle)
*
* Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt —
* am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser.
*/
const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001"
const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0"
const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10)
const SILENT = process.env.MC2_LOOP_SILENT === "1"
const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0"
const FENCE_OFF = process.env.MC2_FENCE_OFF === "1"
/**
* Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile —
* ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt.
* Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen
* Entgleisen, keine Abwehr gegen einen boesartigen Akteur.
*/
const FENCE: Array<{ rx: RegExp; why: string }> = [
{ rx: /\bgit\s+push\b/, why: "git push — Veroeffentlichen ist Sache des Menschen (oder der CI-Ampel)." },
{ rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." },
{ rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." },
{ rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." },
{ rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." },
{ rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." },
{ rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." },
{ rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." },
{ rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." },
{ rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." },
{ rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." },
]
/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */
const rounds = new Map<string, number>()
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
const busy = new Set<string>()
async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") {
if (!ANNOUNCE_ON) return
try {
await fetch(`${BOX_URL}/api/voice/announce`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject,
text,
source: "loop",
priority: SILENT ? "silent" : priority,
}),
signal: AbortSignal.timeout(4000),
})
} catch {
/* best effort — eine stumme Lucy darf den Bau nie aufhalten */
}
}
export const MC2Governor = async ({ client, $, directory, worktree }: any) => {
const root: string = worktree || directory || process.cwd()
/**
* Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS.
* Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und
* nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig.
*/
async function readVerify(): Promise<string | null> {
try {
const { readFile } = await import("node:fs/promises")
const { join } = await import("node:path")
const raw = await readFile(join(root, "VERIFY"), "utf8")
const cmd = raw
.split("\n")
.map((l: string) => l.trim())
.filter((l: string) => l && !l.startsWith("#"))
.join(" && ")
return cmd || null
} catch {
return null
}
}
/**
* Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt.
* `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales
* `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen.
* Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt.
*/
async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> {
try {
const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet()
const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}`
return { ok: res.exitCode === 0, out: out.slice(-4000) }
} catch (e: any) {
return { ok: false, out: String(e?.message ?? e).slice(-4000) }
}
}
/** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */
async function sendPrompt(sessionID: string, text: string): Promise<boolean> {
try {
await client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text }] },
})
return true
} catch {
return false
}
}
return {
// ── 1. Werkzeug-Zaun ───────────────────────────────────────────────────
"tool.execute.before": async (input: any, output: any) => {
if (FENCE_OFF) return
if (input?.tool !== "bash") return
const cmd: string = output?.args?.command ?? ""
if (!cmd) return
for (const rule of FENCE) {
if (rule.rx.test(cmd)) {
await announce(
"[Zaun]",
`Ich habe einen Befehl geblockt: ${rule.why}`,
"silent",
)
// Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem
// Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders.
throw new Error(
`[MC2-ZAUN] Blockiert: ${rule.why}\n` +
`Befehl war: ${cmd}\n` +
`Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` +
`er macht es selbst.`,
)
}
}
},
// ── 2.-4. Ereignisse ───────────────────────────────────────────────────
event: async ({ event }: any) => {
const type: string = event?.type ?? ""
const props: any = event?.properties ?? event ?? {}
const sessionID: string = props.sessionID || props.sessionId || props.id || ""
// ── Savepoint statt Zusammenfassen ──────────────────────────────────
if (type === "session.compacted" || type === "experimental.session.compacting") {
await announce(
"[Sitzung]",
"Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.",
"silent",
)
if (sessionID) {
await sendPrompt(
sessionID,
"[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " +
"Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " +
"erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " +
"Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.",
)
}
return
}
// ── Pruef-Tor + Selbstreparatur ─────────────────────────────────────
if (type !== "session.idle" || !sessionID) return
if (busy.has(sessionID)) return
const cmd = await readVerify()
if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus.
busy.add(sessionID)
try {
const { ok, out } = await runVerify(cmd)
const round = rounds.get(sessionID) ?? 0
if (ok) {
rounds.delete(sessionID)
await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal")
return
}
if (!AUTOFIX || round >= MAX_ROUNDS) {
rounds.delete(sessionID)
await announce(
"[Pruefung]",
`Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` +
`Hier komme ich allein nicht weiter, Commander.`,
"normal",
)
return
}
rounds.set(sessionID, round + 1)
await announce(
"[Pruefung]",
`Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`,
"silent",
)
await sendPrompt(
sessionID,
`[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` +
`Projekts ist fehlgeschlagen.\n\n` +
`Befehl: ${cmd}\n\n` +
`Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` +
`Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` +
`Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` +
`(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`,
)
} finally {
busy.delete(sessionID)
}
},
}
}
export default MC2Governor
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
import sys, re, shutil, time
if len(sys.argv) < 5:
print("Nutzung: auto_adopt.py <config_path> <rolle> <new_hf_id> <new_gguf_path> [new_mmproj_path]")
sys.exit(1)
config_path = sys.argv[1]
rolle = sys.argv[2]
new_hf_id = sys.argv[3]
new_gguf_path = sys.argv[4]
new_mmproj_path = sys.argv[5] if len(sys.argv) > 5 else ""
shutil.copy(config_path, config_path + f".bak.{time.strftime('%Y%m%d%H%M%S')}")
content = open(config_path, "r", encoding="utf-8").read()
blocks = re.split(r'\n ([a-zA-Z0-9_.-]+):\n', "\n" + content)
new_content = blocks[0].lstrip("\n")
adopted = False
for i in range(1, len(blocks), 2):
m_name = blocks[i]
m_content = blocks[i+1]
if re.search(r'aliases:\s*\n(?:\s+- [^\n]+\n)*\s+- ' + re.escape(rolle) + r'\b', m_content):
m_content = re.sub(r'(-m\s+)[^\s]+', r'\g<1>' + new_gguf_path, m_content)
if new_mmproj_path:
if "--mmproj" in m_content:
m_content = re.sub(r'(--mmproj\s+)[^\s]+', r'\g<1>' + new_mmproj_path, m_content)
else:
m_content = re.sub(r'(-m\s+[^\s]+)', r'\g<1> --mmproj ' + new_mmproj_path, m_content)
else:
m_content = re.sub(r'\s*--mmproj\s+[^\s]+', '', m_content)
adopted = True
print(f"Auto-Adoption: {rolle} -> {new_hf_id} (ersetzt in {m_name})")
new_content += f" {m_name}:\n{m_content}"
with open(config_path, "w", encoding="utf-8") as f:
f.write(new_content)
if not adopted:
print(f"Warnung: Rolle {rolle} in der config nicht gefunden. Auto-Adoption übersprungen.")
sys.exit(1)
+40 -8
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Prüfstand (17.07.2026): das "volle Programm" für Modell-Kandidaten — echte Coding-,
# Agenten-, Recherche-, Deutsch- und Vision-Prüfungen (harness.py) plus Tempo, verglichen
# gegen die BASELINE der Amtsinhaber. Läuft als Cron (So 01:00, --no-agent: stdout wird
# wörtlich zugestellt; LEERER stdout = stille Nacht).
# Prüfstand-Runner (Täglich 02:00, ehemals Wöchentlich): Baut die Umgebung für den
# Harness (Modell-Sandkasten, GPU-RAM-Check) und zieht die Fäden.
# Läuft als Cron (Täglich 02:00, nach Radar/Checkout), nimmt Kandidaten aus der Queue
# und vergleicht sie gegen die BASELINE der Amtsinhaber.
#
# Modi:
# (ohne Argument) Cron-Modus: Baseline fehlt → Baseline messen; sonst ältesten
@@ -27,7 +27,7 @@ BENCH_BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
BENCH_PORT="${BENCH_PORT:-5899}"
LIVE="http://127.0.0.1:8080/v1"
JUDGE_MODEL="${PRUEFSTAND_JUDGE:-gpt-oss-120b}"
DECKEL_GB="${PRUEFSTAND_DECKEL_GB:-35}"
DECKEL_GB="${PRUEFSTAND_DECKEL_GB:-100}"
DEADLINE_MIN="${PRUEFSTAND_DEADLINE_MIN:-100}"
mkdir -p "$STATE/queue" "$STATE/done" "$STATE/ergebnisse"
@@ -342,7 +342,9 @@ PY
# ---- Steckbrief: Kandidat vs. Amtsinhaber
local BERICHT="$STATE/ergebnisse/kandidat-${KEY}.md"
python3 - "$OUT" "$STATE/baseline.json" "$ROLLE" "$HF_ID" "$WARUM" "$BERICHT" <<'PY'
local SIEGER_FLAG="$STATE/tmp-sieger-${KEY}"
rm -f "$SIEGER_FLAG"
python3 - "$OUT" "$STATE/baseline.json" "$ROLLE" "$HF_ID" "$WARUM" "$BERICHT" "$SIEGER_FLAG" <<'PY'
import json, sys, time
kand = json.load(open(sys.argv[1]))
try:
@@ -378,14 +380,15 @@ if faelle:
tempo = (f"Tempo {round(100*tg_k/tg_b-100):+d} % vs. Amtsinhaber" if tg_k and tg_b
else "Tempo-Vergleich unvollständig")
if qk >= qb and tg_k >= 0.8 * (tg_b or tg_k):
urteil = "KANDIDAT SIEHT STARK AUS — Karte prüfen lohnt sich."
urteil = "KANDIDAT SIEHT STARK AUS — Auto-Adoption greift (Etappe E5)."
open(sys.argv[7], "w").write("1")
elif qk >= qb:
urteil = "Qualität hält mit, aber deutlich langsamer — vermutlich kein Aufsteiger."
else:
urteil = "Qualität unter Amtsinhaber — kein Aufsteiger."
zeilen.append(f"**Einordnung:** Bestehensquote {qk:.0%} vs. {qb:.0%} · {tempo}. {urteil}")
zeilen.append("")
zeilen.append("_Der Prüfstand empfiehlt nur — der Rollen-Wechsel bleibt Commander-Entscheid (Karte)._")
zeilen.append("_Auto-Adoption (Etappe E5): Der Prüfstand tauscht Sieger-Modelle nun selbstständig aus._")
text = "\n".join(z for z in zeilen if z is not None)
open(ziel, "w", encoding="utf-8").write(text + "\n")
print(text)
@@ -402,6 +405,35 @@ PY
briefkasten "Prüfstand" "Kandidat ${HF_ID} (Rolle ${ROLLE}) geprüft — Steckbrief im Auftrags-Kanban und Vault (pruefstand/${KEY}.md)."
mv "$SPEC" "$STATE/done/${KEY}.spec.json" 2>/dev/null || true
cp "$OUT" "$STATE/done/${KEY}.json" 2>/dev/null || echo "{\"status\":\"geprueft\"}" > "$STATE/done/${KEY}.json"
# ---- Auto-Adoption (Etappe E5)
if [ -f "$SIEGER_FLAG" ]; then
echo "Kandidat hat gewonnen — starte Auto-Adoption für Rolle $ROLLE..."
local FINAL_DIR="/srv/models/${HF_ID//\//-}-GGUF"
sudo -n mkdir -p "$FINAL_DIR"
sudo -n mv "$ZIEL" "$FINAL_DIR/"
local FINAL_GGUF="$FINAL_DIR/$(basename "$PFAD")"
local FINAL_MMPROJ=""
if [ -n "$MM_ZIEL" ]; then
sudo -n mv "$MM_ZIEL" "$FINAL_DIR/"
FINAL_MMPROJ="$FINAL_DIR/$(basename "$MMPROJ")"
fi
sudo -n chown -R hitonabi:hitonabi "$FINAL_DIR" 2>/dev/null || true
# Config updaten
local CONFIG="$MC/deploy/llama-swap.config.yaml"
python3 "$PSD/auto_adopt.py" "$CONFIG" "$ROLLE" "$HF_ID" "$FINAL_GGUF" "$FINAL_MMPROJ"
# Reload llama-swap (via worker oder systemctl)
if systemctl --user is-active --quiet llama-swap; then
systemctl --user restart llama-swap
briefkasten "Prüfstand Auto-Adoption" "Rolle '$ROLLE' wurde erfolgreich durch '$HF_ID' ersetzt (Neustart erledigt)."
else
briefkasten "Prüfstand Auto-Adoption" "Rolle '$ROLLE' wurde durch '$HF_ID' ersetzt (llama-swap war inaktiv)."
fi
rm -f "$SIEGER_FLAG"
fi
rm -rf "${CACHE:?}/$KEY"
echo
echo "(Cache aufgeräumt; Roh-Ergebnisse: $STATE/ergebnisse/kandidat-${KEY}.*)"
+287
View File
@@ -0,0 +1,287 @@
# Spezifikation: Auto-Archivierung nach 14 Tagen Inaktivität
**Datum:** 2026-07-24
**Task:** t_a401f387
**Status:** Analyse abgeschlossen → Spezifikation erstellt
**Herkunft:** Aus der Ideen-Queue (Idle-Radar, Traum-Notiz vom 18.07.2026)
---
## 1. Kontext
### 1.1 Ziel
Reduziere den Clutter im Skill-Index und unterstütze den Curator bei der Pflege.
### 1.2 Aktueller Zustand
| Komponente | Status |
|------------|--------|
| Aktive Skills | 11 |
| Archivierte Skills | 15 Kategorien |
| Usage-Tracking | Vorhanden (`.usage.json`), aber leer |
| `.curator_state` | `paused: true` |
| 14-Tage-Regel | **Nicht implementiert** |
### 1.3 Messlatte
- **Stale-Skills:** Skills mit `state: "stale"` in `.usage.json` (bisher kein Eintrag)
- **Inaktivität:** Letzte Nutzung (`last_used_at`) liegt >14 Tage zurück
- **Archivierung:** Verschieben in `.archive/<Kategorie>/` und Setzen von `archived_at`
---
## 2. Architektur
### 2.1 Komponenten
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Curator-Auto-Archivierung │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Idle-Radar │ → │ Archivierungs- │ → │ Curator-Indexer│ │
│ │ (Trigger) │ │ Watchdog │ │ (Datenpflege) │ │
│ └─────────────────┘ └─────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.2 Datenfluss
1. **Idle-Radar** erkennt Inaktivität (nächtlicher Scan)
2. **Archivierungs-Watchdog** prüft 14-Tage-Regel
3. **Curator-Indexer** aktualisiert `.usage.json` mit `archived_at`
4. Skill-Verzeichnis wird nach `.archive/<Kategorie>/` verschoben
---
## 3. Dateistruktur
### 3.1 Aktiv-Verzeichnis (`~/.hermes/skills/`)
- Normale Skill-Ordner (`betrieb-playbook/`, `wartung/`, ...)
- `.usage.json` (Nutzungsdaten)
- `.curator_state` (Curator-Status)
### 3.2 Archiv-Verzeichnis (`~/.hermes/skills/.archive/`)
```
.archive/
├── apple/ # Kategorie: Apple-Tools
├── creative/ # Kategorie: Kreatives
├── data-science/ # Kategorie: Datenanalyse
├── github/ # Kategorie: GitHub
├── media/ # Kategorie: Medien
├── mlops/ # Kategorie: MLOps
├── research/ # Kategorie: Recherche
├── software-development/ # Kategorie: Dev-Tools
└── ... # Weitere Kategorien
```
### 3.3 Skill-Datei nach Archivierung
```
~/.hermes/skills/.archive/software-development/gitea-workflow/
├── SKILL.md
└── ... (weitere Dateien)
```
---
## 4. Konfiguration
### 4.1 `.curator_state`
```json
{
"last_report_path": "/home/hitonabi/.hermes/logs/curator/20260720-182035",
"last_run_at": "2026-07-20T18:20:35.806573+00:00",
"last_run_duration_seconds": 882.931897,
"last_run_summary": "auto: no changes; llm: Context length exceeded: max compression attempts (3) reached.",
"last_run_summary_shown_at": "2026-07-20T18:20:35.806573+00:00",
"paused": false, // ← aktiviert für Auto-Archivierung
"run_count": 11,
"archivierung": {
"enabled": true, // ← neuer Schlüssel
"threshold_days": 14, // ← neuer Schlüssel
"last_archive_run_at": "2026-07-24T03:00:00.000000+00:00"
}
}
```
### 4.2 `.usage.json` Archivierter Skill
```json
{
"gitea-workflow": {
"archived_at": "2026-07-24T03:00:00.000000+00:00", // ← neuer Schlüssel
"created_at": "2026-06-30T20:00:58.871579+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-06-23T19:00:00.000000+00:00",
"last_viewed_at": null,
"patch_count": 0,
"pinned": false,
"state": "archived", // ← geändert von "active"
"use_count": 0,
"view_count": 0
}
}
```
---
## 5. Trigger-Mechanismus
### 5.1 Zeitlicher Trigger
- **Cron-Job:** `0 3 * * *` (täglich um 3:00 Uhr)
- **Skript:** `~/.hermes/scripts/curator-archive-watchdog.sh`
### 5.2 Prüflogik
```python
def is_stale(skill_usage: dict) -> bool:
"""Prüft, ob ein Skill älter als 14 Tage ist."""
last_used = skill_usage.get("last_used_at")
if not last_used:
return False
last_used_dt = datetime.fromisoformat(last_used.replace("Z", "+00:00"))
now = datetime.now(last_used_dt.tzinfo)
days_inactive = (now - last_used_dt).days
return days_inactive >= 14
```
### 5.3 Ausschlusskriterien
- **Pinned:** `pinned: true` → nie archivieren
- **Verwendung im Live-Repo:** Skills aus `~/mission-control-v2/deploy/skills/` nicht archivieren
- **System-Skills:** `betrieb-playbook`, `wartung`, `orchestrator` nicht archivieren (Konfigurierbar in `.curator_state`)
---
## 6. Archivierungs-Workload
### 6.1 Schritte
1. **Scan:** Alle Skills in `~/.hermes/skills/` durchlaufen
2. **Prüfung:** `is_stale()` für jeden Skill aufrufen
3. **Ausschluss:** Pinned/System-Skills überspringen
4. **Verschieben:** Skill-Verzeichnis nach `.archive/<Kategorie>/`
5. **Index aktualisieren:** `.usage.json` mit `archived_at` und `state: "archived"` schreiben
6. **Protokoll:** Log-Eintrag in `~/.hermes/logs/curator/`
### 6.2 Beispiel-Archivierung
```bash
# Vorher
~/.hermes/skills/skill-automatisch-verknuepfen/SKILL.md
# Nachher
~/.hermes/skills/.archive/software-development/skill-automatisch-verknuepfen/SKILL.md
```
---
## 7. Datenformat
### 7.1 `.usage.json` Aktiv
```json
{
"name": {
"archived_at": null,
"created_at": "2026-07-03T07:50:53.782473+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-07-06T08:55:31.369025+00:00",
"last_viewed_at": "2026-07-06T08:55:31.367242+00:00",
"patch_count": 0,
"pinned": false,
"state": "active",
"use_count": 2,
"view_count": 2
}
}
```
### 7.2 `.usage.json` Archiviert
```json
{
"name": {
"archived_at": "2026-07-24T03:00:00.000000+00:00",
"created_at": "2026-07-03T07:50:53.782473+00:00",
"created_by": null,
"last_patched_at": null,
"last_used_at": "2026-07-06T08:55:31.369025+00:00",
"last_viewed_at": "2026-07-06T08:55:31.367242+00:00",
"patch_count": 0,
"pinned": false,
"state": "archived",
"use_count": 2,
"view_count": 2
}
}
```
---
## 8. Config-Interface
### 8.1 `.curator_state` Erweiterung
```json
{
"archivierung": {
"enabled": true,
"threshold_days": 14,
"excluded_skills": ["betrieb-playbook", "wartung", "orchestrator"],
"last_archive_run_at": "2026-07-24T03:00:00.000000+00:00"
}
}
```
### 8.2 `.usage.json` Erweiterung
```json
{
"name": {
"archived_at": "2026-07-24T03:00:00.000000+00:00"
}
}
```
---
## 9. Akzeptanzkriterien
| Kriterium | Status |
|-----------|--------|
| Klare Architektur | ✅ (siehe Abschnitt 2) |
| Definierte Dateistruktur | ✅ (`.archive/<Kategorie>/`) |
| Config-Interface | ✅ (`.curator_state` + `.usage.json`) |
| Keine Code-Änderungen | ✅ (nur Spezifikation) |
---
## 10. Nächste Schritte
### 10.1 Implementierung
1. **Skript erstellen:** `~/.hermes/scripts/curator-archive-watchdog.sh`
2. **Cron-Job anlegen:** `0 3 * * * curator-archive-watchdog.sh`
3. **`.curator_state` erweitern:** `archivierung`-Block hinzufügen
4. **`.usage.json` erweitern:** `archived_at`-Feld einfügen
### 10.2 Test
1. Manuell einen Skill archivieren (Handbetrieb)
2. Prüfen, ob `archived_at` gesetzt wird
3. Prüfen, ob `.archive/<Kategorie>/` angelegt wird
4. Prüfen, ob `state: "archived"` in `.usage.json` steht
---
## 11. Anhang
### 11.1 Begriffe
| Begriff | Bedeutung |
|---------|-----------|
| **Stale** | Skill mit `last_used_at > 14 Tage` |
| **Archivierung** | Verschieben in `.archive/<Kategorie>/` |
| **Curator** | Der automatische Wartungsdienst |
| **Idle-Radar** | Das System, das Inaktivität erkennt |
### 11.2 Quellen
- `~/.hermes/skills/.curator_state`
- `~/.hermes/skills/.usage.json`
- `~/.hermes/skills/.archive/`
- `~/wissens-vault/eigenbau-landkarte.md`
---
**Ende der Spezifikation**
+76
View File
@@ -0,0 +1,76 @@
# Curator Idle Watchdog Spezifikation
## Ziel
Automatische Reaktivierung des Hermes Curator nach **14 Tagen Idle**, damit der Skill-Graph nicht dauerhaft isoliert bleibt.
## Problem
- Curator läuft normalerweise alle 7 Tage (`interval: every 7d`)
- Wenn der Curator ausfällt (z. B. aufgrund von `paused: true`), bleiben Skills isoliert
- Derzeitige Beobachtung: Curator blockiert seit 16 Tagen (Stand 24.07.2026)
- Keine automatische Erkennung → keine automatische Wiederherstellung
## Lösung: Watchdog-Skript
### `curator-idle-watchdog.sh`
**Zweck:** Cron-basierte Prüfung, ob der Curator zu lange offline war.
**Ablauf:**
1. `hermes curator status` ausführen
2. "last run"-Zeit extrahieren (z. B. `17d ago`)
3. Tage berechnen und mit `THRESHOLD_DAYS=14` vergleichen
4. Bei Überschreitung → `hermes curator run` triggern
5. Alle Aktionen nach `~/.hermes/logs/curator/idle-watchdog-YYYYMMDD.log` protokollieren
### Cron-Eintrag
**Zeitplan:** Sonntag 00:00 (wöchentlich)
```bash
0 0 * * 0 ~/.hermes/profiles/werkstatt/scripts/curator-idle-watchdog.sh
```
**Logik:**
- Prüfung nur, wenn Curator `ENABLED` ist
- Wenn `last run` < 14 Tage → nichts tun
- Wenn `last run` ≥ 14 Tage → `hermes curator run` aufrufen
- Fehler protokollieren, aber nicht blockieren
## Implementierung
### Dateien
- Skript: `~/.hermes/profiles/werkstatt/scripts/curator-idle-watchdog.sh`
- Cron: `/etc/cron.d/curator-idle-watchdog` (oder user-level via `crontab -e`)
- Logs: `~/.hermes/logs/curator/idle-watchdog-*.log`
### Akzeptanzkriterien
- [ ] Skript existiert und ist ausführbar (`chmod +x`)
- [ ] Cron-Eintrag ist aktiv (wöchentliche Prüfung Sonntag 00:00)
- [ ] Reaktivierung triggert `hermes curator run`
- [ ] Protokollierung in `~/.hermes/logs/curator/` funktioniert
- [ ] Keine Änderungen an `main` oder `~/mission-control-v2`
## Test
### Manueller Test
```bash
# Simuliere 15-Tage-Idle
sed -i 's/last run:.*$/last run: 15d ago/' <(hermes curator status)
# Watchdog ausführen
~/.hermes/profiles/werkstatt/scripts/curator-idle-watchdog.sh
```
### Erwartetes Verhalten
1. Log-Eintrag: `WARNUNG: Curator ist für 15 Tage idle (>= 14) → triggere 'hermes curator run'`
2. `hermes curator run` wird ausgeführt
3. Log-Eintrag: `ERFOLG: Curator-Reaktivierung gestartet`
## Rollback-Plan
- Cron-Eintrag löschen: `crontab -e` → Zeile entfernen
- Skript löschen: `rm ~/.hermes/profiles/werkstatt/scripts/curator-idle-watchdog.sh`
- Logs archivieren: `tar -czf idle-watchdog-logs-$(date +%Y%m%d).tar.gz ~/.hermes/logs/curator/idle-watchdog-*.log`
## Anmerkungen
- 14 Tage Threshold = Kompromiss zwischen zu häufigem Trigger (z. B. bei kurzen Wartungen) und zu spätem Erkennen
- Watchdog prüft nur bei `ENABLED` Curator (kein Fehlalarm bei `paused`)
- Keine Abhängigkeit zu externen Diensten (reines Hermes-System)
+12
View File
@@ -15,6 +15,11 @@ set -u
URL="${MC_LLAMA_SWAP_URL:-http://127.0.0.1:8080}"
BRAINS="${MC_WARMUP_MODELS:-fast}"
EMBEDS="${MC_WARMUP_EMBED:-embed}"
# Reranker laufen im --reranking-Modus und antworten NUR auf /v1/rerank — weder Chat noch
# Embeddings wecken sie. Solange das hier fehlte, galt der Reranker dem Waechter dauerhaft
# als "fehlend": er rief warmup.sh alle 90 s auf, ohne dass sich je etwas aendern konnte
# (600 vergebliche Laeufe in 24 h, aelteste Meldung 15.07.2026).
RERANKS="${MC_WARMUP_RERANK:-reranker}"
if [ "${1:-}" != "--inner" ]; then
setsid "$0" --inner >/dev/null 2>&1 &
@@ -41,6 +46,13 @@ for m in $EMBEDS; do # Embedding-Modelle über /v1/emb
>/dev/null 2>&1 || true
done
for m in $RERANKS; do # Reranker über /v1/rerank (eigener Endpunkt!)
curl -s -m 120 -X POST "$URL/v1/rerank" \
-H 'Content-Type: application/json' \
-d "{\"model\":\"$m\",\"query\":\"ping\",\"documents\":[\"ping\"]}" \
>/dev/null 2>&1 || true
done
# Agenten-Prompt vorkauen: Hermes' Systemprompt (~70 KB, 33 Tool-Schemas) kostet nach
# jedem Modell-(Neu-)Laden sonst 30-40 s Prefill BEIM ERSTEN USER-CALL (gemessen 03.07.:
# kalt 37 s, mit gefülltem Prompt-Cache ~6 s). Ein Wegwerf-Turn füllt den -cram-Cache.
+27
View File
@@ -129,6 +129,33 @@ erweitert nur der Commander, nie das Skript.
legt er zusätzlich EINE rohe Idee in die Ideen-Queue („Probe bauen?", idempotent je
Release-Tag) — der Blick nach draußen füttert damit denselben Kreislauf.
## Curator Auto-Archivierung (Cron `0 3 * * *` — täglich um 03:00)
**Zweck:** Automatische Archivierung inaktiver Skills nach 14 Tagen ohne Nutzung.
**Skripte:**
- `~/.hermes/scripts/curator-archive-watchdog.sh` — Cron-Einstieg, ruft das Python-Skript auf.
- `~/.hermes/scripts/curator_archive_logic.py` — Hauptlogik. Liest `.curator_state` und `.usage.json` aus `~/.hermes/skills/`, prüft auf Inaktivität (>14 Tage seit letztem Gebrauch), überspringt gepinnte und exkludierte Skills, verschiebt Skills nach `.archive/<Kategorie>/`.
- `~/.hermes/scripts/curator-idle-watchdog.sh` — Zusätzlicher Watchdog (Sonntag 00:00, Cron `0 0 * * 0`): prüft, ob der Curator selbst ≥14 Tage idle ist und triggert ggf. eine Reaktivierung.
**Konfiguration** (`~/.hermes/skills/.curator_state`):
- `archivierung.enabled: false` — Archivierung muss explizit aktiviert werden.
- `archivierung.threshold_days: 14` — Tage ohne Nutzung bis zur Archivierung.
- `archivierung.excluded_skills: [...]` — Liste der nie zu archivierenden Skills.
- `archivierung.last_archive_run_at` — Zeitstempel des letzten Durchlaufs.
**Ablauf im Durchlauf:**
1. Prüft, ob Archivierung aktiviert ist.
2. Läuft alle Skills durch `.usage.json`.
3. Überspringt: gepinnte Skills, exkludierte Skills, aktive Skills (<14 Tage).
4. Archiviert: State auf "archived" setzen, Verzeichnis nach `.archive/<Kategorie>/` verschieben, Usage-Datei aktualisieren.
5. Protokolliert in `~/.hermes/logs/curator/archive-<Datum>.log`.
**Sicherheit:**
- Archivierung standardmäßig deaktiviert — passiert nie ohne explizite Freigabe.
- Gepinnte und exkludierte Skills werden nie berührt.
- Archivierung ist reversibel: Skills können manuell aus `.archive/` zurückverschoben werden.
## Leitplanken
- Propose-only bleibt: **keine Code-Zeile geht ohne Klick live.** Das Gate ist dieses
+49 -2
View File
@@ -51,10 +51,14 @@ einen User-Entscheid — kein „Best Practice sagt aber…" (siehe ARBEITSWEISE
⚠️ `llama-quantize` verwirft DFlash-Sondertensoren bei Nicht-Standard-Archs.
- **coder-Lane: Qwen3-Coder-Next, FINAL** („bleiben bei dem was wir haben, nachweislich
besser"). Qwen3.6-27B dense gebencht + verworfen (tg 12,7 t/s = 47× langsamer).
- **hipEngine + strix-halo-toolboxes/TheRock: beobachten, nicht adoptieren.** hipEngine
|- **hipEngine + strix-halo-toolboxes/TheRock: beobachten, nicht adoptieren.** hipEngine
(natives HIP, nur Qwen3.6, >2× Prefill) ist jung; kyuz0-Toolboxes lösen ein Problem, das
die Box nicht hat. Radar/Reminder wachen; Adoption nur nach eigenem brain-bench.
- **dist/ im Git ist ABSICHT.** Box hat kein Node; Deploy = `git reset --hard` + committetes
|- **Vision-Rolle: GLM-4.6V-Flash bleibt Amtsinhaber.** Qwen-AgentWorld-35B geprüft (Prüfstand
t_9daf088f, Speed: tg 26,8 t/s / pp 339,1, Vision: 0/1 korrekt). Qualität deutlich unter
GLM-4.6V-Flash, kein Aufsteiger — **abgelehnt**. Wiedervorlage nur bei neuer Version mit
nachweislich > 50 % Vision-Rate.
|- **dist/ im Git ist ABSICHT.** Box hat kein Node; Deploy = `git reset --hard` + committetes
`frontend/dist`. NICHT gitignoren.
## Lucy (Voice-App)
@@ -99,3 +103,46 @@ einen User-Entscheid — kein „Best Practice sagt aber…" (siehe ARBEITSWEISE
(deprecated Python, Server blockiert, Hybrid unsupported). Zweck/Umfang offen.
- **Aussortiert (tot):** Kyutai-Streaming-STT (nur EN/FR) · Unmute-Voll-Stack · LXC 105 (alt) ·
WebUI-Update-Checks · openWakeWord (Wake-Gate ist STT-basiert).
## Prüfstand 24.07.2026 — Coder-Backend + Modell (ROCm-Recheck erledigt)
Gemessen mit llama-bench (Vulkan/RADV, Q4_K_M, -fa on) auf der Box (Ryzen AI Max+ 395):
| Test | Coder-Next 80B-A3B | Devstral Small 2 24B dense |
| -------------- | ------------------ | -------------------------- |
| pp2048 @ 0 | 702 t/s | 468 t/s |
| tg64 @ 0 | 59 t/s | 15 t/s |
| pp2048 @ 8192 | 638 t/s | 315 t/s |
| tg64 @ 8192 | 55 t/s | 14 t/s |
| pp2048 @ 32768 | 488 t/s | 63 t/s (7,7x langsamer) |
| tg64 @ 32768 | 47 t/s | 11 t/s (4,2x langsamer) |
- **Backend bleibt Vulkan (ROCm-Recheck 24.07. erledigt).** Vulkan-Prefill haelt bei 32K
noch 488 t/s — der einzige ROCm-Vorteil (Langkontext-Prefill) ist auf dieser Box kein
Engpass. Der Autor des fuehrenden ROCm/Strix-Halo-Rezepts ist im Juli 2026 selbst zu
Vulkan zurueckgekehrt (+28% Decode). Kein ROCm-Bau — Produktiv-Risiko fuer null Gewinn.
Wiedervorlage nur bei neuem Engine-Langkontext-Support oder einem reinen HIP-Modell.
- **Coder bleibt Qwen3-Coder-Next — jetzt gemessen bestaetigt.** Devstral Small 2 (einziger
neuer agentisch trainierter Dense-Kandidat) ist 4x langsamer bei Generierung und bricht
beim 32K-Prefill auf 63 t/s ein (~9 min um einen 32K-Kontext zu lesen). Fuer agentisches
Coden auf dieser Box disqualifiziert — Reliabilitaet war nicht einmal noetig als Argument.
- **Der Coder ist nicht langsam (59 t/s).** Das Fassaden-/Halluzinations-Problem ist kein
Speed- und kein Backend-Problem und wird weder durch Modelltausch noch ROCm geloest. Es
ist Reliabilitaet = Modellklasse + Harness. Naechster Hebel: Post-Edit-Verify-Hook,
kleine Etappen/frische Threads, Eskalation der harten 20% an Frontier.
- **Devstral Small 2 ist der KRITIKER, nicht der Coder — mit hartem 16k-Deckel (25.07.).**
Das Verdikt „als Coder disqualifiziert" (24.07.) bleibt unveraendert richtig. Neu ist nur die
ENGE Rolle: Gegenlesen einer Etappe. Gemessen am 25.07. auf der Box: Generierung 15,0 t/s
(Coder 51,5), Prefill 265-318 t/s (Coder 754). Der Prefill bricht mit der Kontextlaenge ein
(32k -> 63 t/s = ~9 min nur zum Lesen), deshalb ist der Kritiker in llama-swap UND in beiden
opencode.json auf **16384** gedeckelt: schlimmster Fall je Review bleibt unter einer Minute.
Der Nutzen liegt nicht im Tempo, sondern in der FREMDEN Modellfamilie — ein Mistral-Modell hat
andere blinde Flecken als der Qwen-Coder. Alternative GLM-4.7-Flash (MoE, schnellerer Prefill)
bleibt eine Zeile Config entfernt, kostet aber ~9 Punkte SWE-bench (59,2 vs. 68,0).
- **llama-swap haelt nur EINE Gruppe resident, und `persistent: true` ist gefaehrlich (25.07.).**
Zwei ko-residente Gruppen verdraengen sich gegenseitig — alles, was gleichzeitig warm sein muss,
gehoert in DIESELBE Gruppe (`brains`). `persistent: true` hindert llama-swap daran, vor einem
grossen On-Demand-Modell abzuraeumen: mit 107 GB Warm-Set + Vision (20 GB) folgte ein
**Kernel-OOM** (llama-server abgeschossen). Regel: `Warm-Set + groesstes On-Demand-Modell
<= ~115 GB` UND `persistent: false`. Danach lud vision in 10 s statt 117 s + Absturz.
Kontext-Halbierung spart bei Qwen3-Next uebrigens **0 GB** (Hybrid-Attention, winziger KV).
@@ -1,4 +1,4 @@
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-DZ34eLRy.js";/**
import{c as A,Y as P,l as B,b as M,u as D,r as z,j as e,Z as T,e as a,$ as g,a0 as b,a1 as W,x as f,a2 as j,W as G,X as I,C as N,g as y,q as i}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as m,ag as S,r as c,j as e,ah as z,L as g,U as M,ai as C,ad as D,aj as A,ak as B,al as Z,e as E,am as L,u as R,b as q,g as k,q as T}from"./index-DZ34eLRy.js";import{R as j}from"./rotate-ccw-DRxIaGHA.js";/**
import{c as m,ai as S,r as c,j as e,aj as z,L as g,U as M,ak as C,ae as D,al as A,am as B,an as Z,e as E,ao as L,u as R,b as q,g as k,q as T}from"./index-BChEIiQl.js";import{R as j}from"./rotate-ccw-CPYnjQ6M.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as v,r as c,I,J as A,j as e,K as h,x as B,B as H,D as E,M as z,e as M,L as T,N as F,C as K}from"./index-DZ34eLRy.js";import{F as O}from"./folder-open-Bb3HvkQd.js";import{C as R}from"./circle-x-Cw3-1mKp.js";import{C as W}from"./copy-aWlMWseD.js";/**
import{c as v,r as c,I,J as A,j as e,K as h,x as B,B as H,D as E,M as z,e as M,L as T,N as F,C as K}from"./index-BChEIiQl.js";import{F as O}from"./folder-open-CtWva2VF.js";import{C as R}from"./circle-x-D8NCr-a2.js";import{C as W}from"./copy-CRj5M_pY.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as h,an as b,r as i,j as e,L as g,ao as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-DZ34eLRy.js";import{B as S}from"./book-open-C27V5HRN.js";import{C as z}from"./circle-x-Cw3-1mKp.js";/**
import{c as h,ap as b,r as i,j as e,L as g,aq as f,a2 as j,U as k,e as o,D as N,M as v,N as w,g as y}from"./index-BChEIiQl.js";import{B as S}from"./book-open-2v7qByfb.js";import{C as z}from"./circle-x-D8NCr-a2.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Y as b,u as f,$ as h,r as d,j as e,e as c,Z as g,T as p,a3 as j,a4 as N,g as v,a5 as w,q as x}from"./index-DZ34eLRy.js";function y(){const{data:s}=b(),m=f(),r=s!=null&&s.box_console_url?h(s.box_console_url):void 0,a=s==null?void 0:s.box_console_reachable,[l,o]=d.useState(!1),[i,n]=d.useState("");async function u(){o(!0),n("");try{const t=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})});n(t.ok?"Neu gestartet — einen Moment, dann lädt das Terminal.":`Fehlgeschlagen: ${t.err||"Unbekannter Fehler"}`),w(m,x.agentStatus,x.services)}catch(t){n(`Fehlgeschlagen: ${(t==null?void 0:t.message)||t}`)}finally{o(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:c("h-2 w-2 rounded-full",a?"bg-emerald-500 animate-pulse":"bg-amber-500")}),a?"online":"offline"]}),r&&e.jsxs("a",{href:r,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(g,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),r?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[a===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(p,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:u,disabled:l,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(j,{className:c("h-3.5 w-3.5",l&&"animate-spin")})," Dienst neu starten"]}),i&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:i})]}),e.jsx("iframe",{src:r,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{y as KonsoleView};
import{Y as b,u as f,$ as h,r as d,j as e,e as c,Z as g,T as p,a3 as j,a4 as N,g as v,a5 as w,q as x}from"./index-BChEIiQl.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,5 +1,5 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-CfWXNn-Z.js","assets/index-DZ34eLRy.js","assets/index-CYYdYGeg.css"])))=>i.map(i=>d[i]);
var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var J=(d,i,c)=>ke(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as ve,b as Ne,O as W,Q as Se,C as X,R as Ce,S as Ee,P as Me,U as O,e as x,V as ze,W as De,X as Z,A as Y,_ as Oe,g as j}from"./index-DZ34eLRy.js";import{C as ee}from"./copy-aWlMWseD.js";import{S as _e}from"./send-DrbIxJOY.js";import{B as Ge}from"./book-open-C27V5HRN.js";/**
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/GraphView-B3HC-zB_.js","assets/index-BChEIiQl.js","assets/index-Bm-JldFM.css"])))=>i.map(i=>d[i]);
var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,configurable:!0,writable:!0,value:c}):d[i]=c;var J=(d,i,c)=>ke(d,typeof i!="symbol"?i+"":i,c);import{c as h,r as s,j as e,u as ve,b as Ne,O as W,Q as Se,C as X,R as Ce,S as Ee,P as Me,U as O,e as x,V as ze,W as De,X as Z,A as Y,_ as Oe,g as j}from"./index-BChEIiQl.js";import{C as ee}from"./copy-CRj5M_pY.js";import{S as _e}from"./send-CS1kRsDf.js";import{B as Ge}from"./book-open-2v7qByfb.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -34,7 +34,7 @@ var je=Object.defineProperty;var ke=(d,i,c)=>i in d?je(d,i,{enumerable:!0,config
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Pe=h("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);class te extends s.Component{constructor(){super(...arguments);J(this,"state",{error:null})}static getDerivedStateFromError(c){return{error:c}}render(){return this.state.error?e.jsxs("div",{className:"h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto",children:[e.jsx("div",{className:"font-semibold mb-2",children:"Graph konnte nicht gerendert werden"}),e.jsx("pre",{className:"whitespace-pre-wrap break-words text-[11px] leading-relaxed",children:this.state.error.message})]}):this.props.children}}const se=s.lazy(()=>Oe(()=>import("./GraphView-CfWXNn-Z.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],v=new Set(["auto","agent","hermes"]),N={identity:{label:"Identität",icon:Pe,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:De,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Le,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:ze,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:Ge,text:"text-muted-foreground"},Re={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},_={identity:"#00f5ff",knowledge:"#3b82f6",rules:"#d946ef",events:"#fbbf24"},ae=`Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
*/const Pe=h("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);class te extends s.Component{constructor(){super(...arguments);J(this,"state",{error:null})}static getDerivedStateFromError(c){return{error:c}}render(){return this.state.error?e.jsxs("div",{className:"h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto",children:[e.jsx("div",{className:"font-semibold mb-2",children:"Graph konnte nicht gerendert werden"}),e.jsx("pre",{className:"whitespace-pre-wrap break-words text-[11px] leading-relaxed",children:this.state.error.message})]}):this.props.children}}const se=s.lazy(()=>Oe(()=>import("./GraphView-B3HC-zB_.js"),__vite__mapDeps([0,1,2])).then(d=>({default:d.GraphView}))),k=["identity","knowledge","rules","events"],v=new Set(["auto","agent","hermes"]),N={identity:{label:"Identität",icon:Pe,bg:"bg-cyan-500/10",text:"text-cyan-400"},knowledge:{label:"Wissen",icon:De,bg:"bg-indigo-500/10",text:"text-indigo-400"},rules:{label:"Regeln",icon:Le,bg:"bg-violet-500/10",text:"text-violet-400"},events:{label:"Ereignisse",icon:ze,bg:"bg-amber-500/10",text:"text-amber-400"}},ne={label:"Gedächtnis",icon:Ge,text:"text-muted-foreground"},Re={identity:"border-l-cyan-500/80",knowledge:"border-l-indigo-500/80",rules:"border-l-violet-500/80",events:"border-l-amber-500/80"},_={identity:"#00f5ff",knowledge:"#3b82f6",rules:"#d946ef",events:"#fbbf24"},ae=`Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
1) wer ich bin und woran ich gerade arbeite,
2) wie ich angesprochen werden möchte,
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
import{c as s,j as e}from"./index-BChEIiQl.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,4 +1,4 @@
import{ap as y,r as x,g as L,j as e,L as v,S,ae as W,U as C,e as w,aq as E}from"./index-DZ34eLRy.js";import{F as M}from"./folder-open-Bb3HvkQd.js";const D={"":"Allgemein",traeume:"Träume",muster:"Muster","skill-kandidaten":"Skill-Kandidaten","skill-kandidaten/beauftragt":"Skill-Kandidaten · beauftragt","skill-kandidaten/verworfen":"Skill-Kandidaten · verworfen"};function T(){const{data:o,isLoading:h}=y(),[l,f]=x.useState(null),[d,r]=x.useState(null),[u,i]=x.useState(!1),[a,p]=x.useState(""),t=x.useMemo(()=>(o==null?void 0:o.files)??[],[o]),m=x.useMemo(()=>{const s=new Map;for(const n of t)s.set(n.name.toLowerCase(),n.path);return s},[t]),g=x.useMemo(()=>{if(!a.trim())return t;const s=a.toLowerCase();return t.filter(n=>n.title.toLowerCase().includes(s)||n.path.toLowerCase().includes(s))},[t,a]),b=x.useMemo(()=>{const s=[];for(const n of g){const c=s.find(k=>k.dir===n.dir);c?c.files.push(n):s.push({dir:n.dir,files:[n]})}return s.sort((n,c)=>n.dir===""?-1:c.dir===""?1:n.dir.localeCompare(c.dir))},[g]);x.useEffect(()=>{if(!l&&t.length){const s=t.find(n=>n.path.toLowerCase()==="index.md");f((s==null?void 0:s.path)??t[0].path)}},[t,l]),x.useEffect(()=>{l&&(i(!0),L(`/api/wissen/datei?pfad=${encodeURIComponent(l)}`).then(r).catch(()=>r({path:l,content:"*(Notiz nicht ladbar)*",mtime:0})).finally(()=>i(!1)))},[l]);const j=s=>{const n=m.get(s.toLowerCase());n&&f(n)};return e.jsxs("div",{className:"space-y-5",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:"Wissen"}),e.jsx("p",{className:"text-sm text-muted-foreground",children:"Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki."})]}),o&&!o.available&&e.jsx("div",{className:"rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300",children:"Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar."}),h&&e.jsxs("div",{className:"flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Vault wird geladen …"]}),(o==null?void 0:o.available)&&t.length===0&&e.jsx("div",{className:"rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground",children:"Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste."}),t.length>0&&e.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row",children:[e.jsxs("aside",{className:"w-full shrink-0 space-y-3 lg:w-72",children:[e.jsxs("div",{className:"relative",children:[e.jsx("input",{value:a,onChange:s=>p(s.target.value),type:"search",placeholder:"Notizen durchsuchen…","aria-label":"Notizen durchsuchen",className:"h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50"}),e.jsx(S,{className:"absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground"})]}),e.jsx("div",{className:"max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin",children:b.map(({dir:s,files:n})=>e.jsxs("div",{children:[e.jsxs("p",{className:"mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(M,{className:"h-3 w-3"})," ",D[s]??s]}),e.jsx("div",{className:"space-y-0.5",children:n.map(c=>e.jsxs("button",{onClick:()=>f(c.path),className:w("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",l===c.path?"bg-primary/15 text-primary":"text-muted-foreground hover:bg-accent hover:text-foreground"),children:[e.jsx(W,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",title:c.title,children:c.name}),c.neu&&e.jsxs("span",{className:"ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300",children:[e.jsx(C,{className:"h-2.5 w-2.5"}),"neu"]})]},c.path))})]},s||"__root"))})]}),e.jsx("div",{className:"min-w-0 flex-1 mc-card p-5",children:u?e.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Notiz wird geladen …"]}):d?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between border-b border-border/40 pb-2",children:[e.jsxs("span",{className:"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground",children:[e.jsx(E,{className:"h-3.5 w-3.5"})," ",d.path]}),d.mtime>0&&e.jsxs("span",{className:"text-[10px] text-muted-foreground/60",children:["Stand ",new Date(d.mtime*1e3).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"})]})]}),e.jsx(z,{text:d.content,onWikiLink:j,known:m})]}):null})]})]})}function z({text:o,onWikiLink:h,known:l}){const f=o.split(`
import{ar as y,r as x,g as L,j as e,L as v,S,ah as W,U as C,e as w,as as E}from"./index-BChEIiQl.js";import{F as M}from"./folder-open-CtWva2VF.js";const D={"":"Allgemein",traeume:"Träume",muster:"Muster","skill-kandidaten":"Skill-Kandidaten","skill-kandidaten/beauftragt":"Skill-Kandidaten · beauftragt","skill-kandidaten/verworfen":"Skill-Kandidaten · verworfen"};function T(){const{data:o,isLoading:h}=y(),[l,f]=x.useState(null),[d,r]=x.useState(null),[u,i]=x.useState(!1),[a,p]=x.useState(""),t=x.useMemo(()=>(o==null?void 0:o.files)??[],[o]),m=x.useMemo(()=>{const s=new Map;for(const n of t)s.set(n.name.toLowerCase(),n.path);return s},[t]),g=x.useMemo(()=>{if(!a.trim())return t;const s=a.toLowerCase();return t.filter(n=>n.title.toLowerCase().includes(s)||n.path.toLowerCase().includes(s))},[t,a]),b=x.useMemo(()=>{const s=[];for(const n of g){const c=s.find(k=>k.dir===n.dir);c?c.files.push(n):s.push({dir:n.dir,files:[n]})}return s.sort((n,c)=>n.dir===""?-1:c.dir===""?1:n.dir.localeCompare(c.dir))},[g]);x.useEffect(()=>{if(!l&&t.length){const s=t.find(n=>n.path.toLowerCase()==="index.md");f((s==null?void 0:s.path)??t[0].path)}},[t,l]),x.useEffect(()=>{l&&(i(!0),L(`/api/wissen/datei?pfad=${encodeURIComponent(l)}`).then(r).catch(()=>r({path:l,content:"*(Notiz nicht ladbar)*",mtime:0})).finally(()=>i(!1)))},[l]);const j=s=>{const n=m.get(s.toLowerCase());n&&f(n)};return e.jsxs("div",{className:"space-y-5",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:"Wissen"}),e.jsx("p",{className:"text-sm text-muted-foreground",children:"Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki."})]}),o&&!o.available&&e.jsx("div",{className:"rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300",children:"Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar."}),h&&e.jsxs("div",{className:"flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Vault wird geladen …"]}),(o==null?void 0:o.available)&&t.length===0&&e.jsx("div",{className:"rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground",children:"Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste."}),t.length>0&&e.jsxs("div",{className:"flex flex-col gap-4 lg:flex-row",children:[e.jsxs("aside",{className:"w-full shrink-0 space-y-3 lg:w-72",children:[e.jsxs("div",{className:"relative",children:[e.jsx("input",{value:a,onChange:s=>p(s.target.value),type:"search",placeholder:"Notizen durchsuchen…","aria-label":"Notizen durchsuchen",className:"h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50"}),e.jsx(S,{className:"absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground"})]}),e.jsx("div",{className:"max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin",children:b.map(({dir:s,files:n})=>e.jsxs("div",{children:[e.jsxs("p",{className:"mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(M,{className:"h-3 w-3"})," ",D[s]??s]}),e.jsx("div",{className:"space-y-0.5",children:n.map(c=>e.jsxs("button",{onClick:()=>f(c.path),className:w("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",l===c.path?"bg-primary/15 text-primary":"text-muted-foreground hover:bg-accent hover:text-foreground"),children:[e.jsx(W,{className:"h-3.5 w-3.5 shrink-0"}),e.jsx("span",{className:"truncate",title:c.title,children:c.name}),c.neu&&e.jsxs("span",{className:"ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300",children:[e.jsx(C,{className:"h-2.5 w-2.5"}),"neu"]})]},c.path))})]},s||"__root"))})]}),e.jsx("div",{className:"min-w-0 flex-1 mc-card p-5",children:u?e.jsxs("div",{className:"flex items-center gap-2 text-xs text-muted-foreground",children:[e.jsx(v,{className:"h-4 w-4 animate-spin"})," Notiz wird geladen …"]}):d?e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-3 flex items-center justify-between border-b border-border/40 pb-2",children:[e.jsxs("span",{className:"flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground",children:[e.jsx(E,{className:"h-3.5 w-3.5"})," ",d.path]}),d.mtime>0&&e.jsxs("span",{className:"text-[10px] text-muted-foreground/60",children:["Stand ",new Date(d.mtime*1e3).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"})]})]}),e.jsx(z,{text:d.content,onWikiLink:j,known:m})]}):null})]})]})}function z({text:o,onWikiLink:h,known:l}){const f=o.split(`
`),d=[];let r=[],u=null;const i=a=>{r.length&&(d.push(e.jsx("ul",{className:"mb-3 ml-4 list-disc space-y-1",children:r},a)),r=[])};return f.forEach((a,p)=>{const t=`l${p}`;if(u!==null){a.trimEnd()==="```"?(d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85",children:u.join(`
`)},t)),u=null):u.push(a);return}if(a.trimStart().startsWith("```")){i(t),u=[];return}const m=a.trimEnd();if(!m.trim()){i(t);return}const g=m.match(/^(#{1,4})\s+(.*)$/);if(g){i(t);const j=g[1].length,s=j===1?"text-lg font-bold mt-1 mb-3":j===2?"text-base font-bold mt-4 mb-2":"text-sm font-bold mt-3 mb-1.5";d.push(e.jsx("p",{className:w(s,"font-space text-foreground"),children:N(g[2],h,l,t)},t));return}const b=m.match(/^\s*[-*•]\s+(.*)$/);if(b){r.push(e.jsx("li",{className:"text-xs leading-relaxed text-foreground/85",children:N(b[1],h,l,t)},t));return}i(t),d.push(e.jsx("p",{className:"mb-2 text-xs leading-relaxed text-foreground/85",children:N(m,h,l,t)},t))}),i("end"),u!==null&&d.push(e.jsx("pre",{className:"mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]",children:u.join(`
`)},"code-end")),e.jsx("div",{className:"max-w-3xl",children:d})}function N(o,h,l,f){return o.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g).map((r,u)=>{const i=`${f}-${u}`,a=r.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/);if(a){const p=a[1].trim(),t=(a[2]??a[1]).trim();return l.has(p.toLowerCase())?e.jsx("button",{onClick:()=>h(p),className:"rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer",children:t},i):e.jsx("span",{className:"rounded bg-background/40 px-1 text-muted-foreground",title:"Notiz existiert (noch) nicht",children:t},i)}return r.startsWith("**")&&r.endsWith("**")?e.jsx("b",{className:"font-semibold text-foreground",children:r.slice(2,-2)},i):r.startsWith("*")&&r.endsWith("*")&&r.length>2?e.jsx("i",{children:r.slice(1,-1)},i):r.startsWith("`")&&r.endsWith("`")?e.jsx("code",{className:"rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300",children:r.slice(1,-1)},i):e.jsx("span",{children:r},i)})}export{T as WissenView};
@@ -1,4 +1,4 @@
import{c as a}from"./index-DZ34eLRy.js";/**
import{c as a}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c}from"./index-DZ34eLRy.js";/**
import{c}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as e}from"./index-DZ34eLRy.js";/**
import{c as e}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c}from"./index-DZ34eLRy.js";/**
import{c}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-DZ34eLRy.js";/**
import{c as a}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as a}from"./index-DZ34eLRy.js";/**
import{c as a}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
-6
View File
@@ -1,6 +0,0 @@
import{c as t}from"./index-DZ34eLRy.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=t("Lightbulb",[["path",{d:"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5",key:"1gvzjb"}],["path",{d:"M9 18h6",key:"x1upvd"}],["path",{d:"M10 22h4",key:"ceow96"}]]);export{e as L};
@@ -1,4 +1,4 @@
import{c as t}from"./index-DZ34eLRy.js";/**
import{c as t}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-DZ34eLRy.js";/**
import{c as a}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-DZ34eLRy.js";/**
import{c as a}from"./index-BChEIiQl.js";/**
* @license lucide-react v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DZ34eLRy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CYYdYGeg.css">
<script type="module" crossorigin src="/assets/index-BChEIiQl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bm-JldFM.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -20,6 +20,7 @@ const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default:
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const EigenlebenView = lazy(() => import("@/views/EigenlebenView").then((m) => ({ default: m.EigenlebenView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
@@ -201,6 +202,7 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
<Suspense fallback={<ViewLoading />}>
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "ideen" && <IdeenView />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
@@ -0,0 +1,12 @@
import type { LucideIcon } from "lucide-react"
// Kleine Abschnitts-Überschrift innerhalb einer Seite. Lag vorher in AuftragsbuchView;
// seit der Ideen-Queue eine eigene Seite hat (25.07.2026) brauchen beide sie — also
// einmal hier statt zweimal dupliziert.
export function SectionLabel({ icon: Icon, children }: { icon: LucideIcon; children: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" /> {children}
</p>
)
}
+34
View File
@@ -582,6 +582,40 @@ export interface ChronikResp {
items: ChronikItem[]
}
// ── Governor (Token-Wächter :8100, GET /api/governor) ────────────────────────
// Sitzt zwischen Coding-Agenten und Gateway. `est` ist seine Schätzung VOR dem Lauf,
// `exact` die echten prompt_tokens AUS der Antwort — die Differenz ist sein Fehler,
// und aus ihr lernt er (chars_per_token je Modell). action: passthrough | soft | hard | defer.
export interface GovernorRun {
t: number
model: string
est: number | null
exact: number | null
action: string
}
export interface GovernorStatus {
ok: boolean
reachable: boolean
error?: string
upstream?: string
soft?: number
hard?: number | null
uptime_s?: number
counters?: {
chat: number
soft: number
hard: number
passthrough: number
tokens_prompt: number
tokens_completion: number
}
calibration?: Record<string, { chars_per_token: number; samples: number }>
calibration_default?: number
exempt_models?: string[]
recent?: GovernorRun[]
}
// ── Eigenleben („Von allein": Skills + Vorschlags-Bilanz der Box) ────────────
export interface EigenlebenSkill {
id: string
+6
View File
@@ -30,3 +30,9 @@ export function fmtEta(s?: number): string {
export function fmtCtx(c: number | null): string {
return c ? `${Math.round(c / 1024)}k` : "—"
}
/** Unix-Sekunden → "25.07., 21:04"; "—" bei 0/null/undefined. */
export function fmtWhen(ts: number | null | undefined): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
}
+12
View File
@@ -14,6 +14,7 @@ import {
type DiscoverResp,
type DraftsResp,
type EigenlebenResp,
type GovernorStatus,
type GroupsResp,
type HermesBrainResp,
type Health,
@@ -60,6 +61,7 @@ export const qk = {
auftragsbuch: ["auftragsbuch"] as const,
ideen: ["ideen"] as const,
chronik: ["chronik"] as const,
governor: ["governor"] as const,
eigenleben: ["eigenleben"] as const,
wissen: ["wissen"] as const,
zeitmaschine: ["zeitmaschine"] as const,
@@ -157,6 +159,16 @@ export const useVoiceTrace = (limit = 12, refetchInterval: number = TAKT.schnell
select: (d) => d.turns ?? [],
})
// Token-Wächter (:8100). Ist er aus, liefert das Backend `reachable:false` statt zu werfen —
// deshalb kein Retry-Sturm und kein Fehlerzustand in der Kachel.
export const useGovernor = (refetchInterval: number = TAKT.normal) =>
useQuery({
queryKey: qk.governor,
queryFn: () => api<GovernorStatus>("/api/governor"),
refetchInterval,
retry: false,
})
export const useMemoryGraph = (enabled = true) =>
useQuery({
queryKey: qk.memoryGraph,
+5 -4
View File
@@ -84,11 +84,12 @@ export const ROLE_META: RoleMeta[] = [
},
{
role: "coder",
label: "Programmierer (gründlich)",
short: "Code gründlich",
label: "Programmierer",
short: "Code",
icon: Code,
desc: "Schreibt und prüft Code besonders sorgfältig — für schwere Coding-Aufgaben.",
desc: "Baut und plant. Der Kopf der Coding-Mannschaft — er hält den Faden und verteilt Zuarbeit.",
tone: "bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/25",
protected: true,
},
{
role: "scout",
@@ -103,7 +104,7 @@ export const ROLE_META: RoleMeta[] = [
label: "Kritiker",
short: "Kritiker",
icon: Scale,
desc: "Die unbestechliche Zweitmeinung — prüft Behauptungen und Patches (anderer Hersteller als das Hirn).",
desc: "Liest dem Programmierer nach jeder Etappe gegen — bewusst aus einer FREMDEN Modellfamilie (Mistral statt Qwen), damit er andere blinde Flecken hat.",
tone: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25",
},
]
+7 -1
View File
@@ -10,10 +10,11 @@ import {
Sparkles,
SquareTerminal,
HelpCircle,
Lightbulb,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "auftraege" | "eigenleben" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "eigenleben" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
@@ -28,6 +29,11 @@ export interface NavItem {
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
// Der Haupteingang: Idee → Konzept → Repo → Zed. Steht bewusst direkt unter dem
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
{ id: "ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "eigenleben", label: "Skills", hint: "Fähigkeiten der Box — aktiv/inaktiv, Herkunft & Vorschlags-Bilanz", icon: Sparkles, group: "Operativ" },
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
+24 -765
View File
@@ -1,14 +1,14 @@
import { useEffect, useState } from "react"
import { useState } from "react"
import {
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
ChevronDown, ChevronRight, Clock, ClipboardCheck, Hammer, RefreshCw, Layers, Lightbulb, Send, HelpCircle,
RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork,
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, ArrowRight,
} from "lucide-react"
import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type IdeenKonzept, type IdeenProjekt, type SkillKandidat } from "@/lib/api"
import { Markdown } from "@/lib/markdown"
import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
import { api, type AuftragItem, type SkillKandidat } from "@/lib/api"
import { useAuftragsbuch, useIdeen, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
import { fmtWhen } from "@/lib/format"
import { SectionLabel } from "@/components/ui/SectionLabel"
// Auftragsbuch = die Vorschlags-Inbox der Box. Werkstatt/Orchestrator liefern propose-only-
// Branches, der Traum liefert Skill-Kandidaten — hier fällt die Entscheidung per Klick.
@@ -31,10 +31,6 @@ const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> =
abgelehnt: { label: "abgelehnt", cls: "text-muted-foreground border-border/60 bg-background/30" },
}
function fmtWhen(ts: number | null | undefined): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
}
export function AuftragsbuchView() {
const { data, isLoading, refetch, isFetching } = useAuftragsbuch()
@@ -62,8 +58,11 @@ export function AuftragsbuchView() {
const items = data?.items ?? []
const kandidaten = data?.skill_kandidaten ?? []
// Hängende Ideen leben seit 25.07. auf der eigenen Ideen-Seite. Das grüne „Nichts zu
// entscheiden" darf trotzdem nicht behaupten, alles sei erledigt — es zeigt jetzt einen
// Querverweis statt die Karte selbst (vorher stand sie direkt darunter).
const blockedIdeen = ideenData?.available ? ideenData.items.filter((i) => i.status === "blocked").length : 0
const empty = items.length === 0 && kandidaten.length === 0 && blockedIdeen === 0
const empty = items.length === 0 && kandidaten.length === 0
return (
<div className="space-y-6">
@@ -104,7 +103,20 @@ export function AuftragsbuchView() {
</div>
)}
<IdeenSection />
{blockedIdeen > 0 && (
<button
onClick={() => window.dispatchEvent(new CustomEvent("mc-navigate", { detail: { view: "ideen" } }))}
className="flex w-full items-center gap-3 rounded-2xl border border-red-500/30 bg-red-500/5 p-4 text-left transition-all hover:border-red-500/50 cursor-pointer">
<Lightbulb className="h-5 w-5 shrink-0 text-red-300" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
{blockedIdeen === 1 ? "Eine Idee hängt und braucht dich" : `${blockedIdeen} Ideen hängen und brauchen dich`}
</p>
<p className="text-xs text-muted-foreground">Hier ist nichts zu tun aber drüben in den Ideen wartet eine Frage auf deine Antwort.</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-red-300/70" />
</button>
)}
{items.length > 0 && (
<section className="space-y-3">
@@ -129,759 +141,6 @@ export function AuftragsbuchView() {
)
}
function SectionLabel({ icon: Icon, children }: { icon: any; children: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
<Icon className="h-3.5 w-3.5" /> {children}
</p>
)
}
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
// Eine Queue, drei Türen (Zentrale/Telegram/Lucy). Idee rein → die Box arbeitet sie
// selbst aus (Specifier) und baut (Werkstatt-Profil) → Code kommt als Karte zurück.
const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }> = {
triage: { label: "wird ausgearbeitet …", cls: "text-violet-300 border-violet-500/40 bg-violet-500/10", spin: true },
todo: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
scheduled: { label: "geplant", cls: "text-muted-foreground border-border/60 bg-background/30" },
ready: { label: "gleich dran", cls: "text-sky-300/80 border-sky-500/30 bg-sky-500/5" },
running: { label: "in Arbeit …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
review: { label: "im Review", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
blocked: { label: "hängt — braucht dich", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
done: { label: "fertig ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
}
// Welche Welt hat die Karte durchlaufen? Vorher stand auf Idee UND IDE-Projekt dasselbe
// „IDE" — man sah der fertigen Karte nicht an, ob sie nur durchdacht oder als Repo
// vorbereitet wurde (User 21.07.). Jetzt drei ehrliche Marken.
const ART_UI: Record<string, { label: string; cls: string; icon: typeof Hammer; title: string }> = {
box: {
label: "BOX", icon: Hammer,
cls: "border-border/60 bg-background/40 text-muted-foreground",
title: "Box-Welt: die Werkstatt baut das selbst. Das Ergebnis kommt als Patch-Vorschlag zurück — nichts geht ohne deinen Klick live.",
},
idee: {
label: "IDEE", icon: Lightbulb,
cls: "border-sky-500/40 bg-sky-500/10 text-sky-300",
title: "Nur durchdacht: die Box hat die Produkt-Idee mit voller Denk-Kaskade zu einem Konzept ausgearbeitet. Kein Gerüst, kein Bau — du liest es und entscheidest, ob ein Projekt draus wird.",
},
projekt: {
label: "IDE", icon: Code2,
cls: "border-violet-500/40 bg-violet-500/10 text-violet-300",
title: "IDE-Welt: die Box bereitet nur vor (Konzept + Repo + Doku, kein Code). Gebaut wird von dir in Zed.",
},
}
// Wie die Frage einer hängenden Karte eingeleitet wird — je nach Block-Grund des Workers.
const FRAGE_HINT: Record<string, string> = {
needs_input: "Die Box hat eine Frage:",
capability: "Die Box kam hier nicht weiter:",
transient: "Die Aufgabe hakt:",
}
function IdeenSection() {
const { data, isLoading } = useIdeen()
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const [text, setText] = useState("")
// Intake-Modus: Box baut · Idee (nur durchdenken) · IDE simpel/gründlich (Projekt vorbereiten).
const [modus, setModus] = useState<"box" | "idee" | "simpel" | "gruendlich">("box")
const isIdee = modus === "idee" // Produkt-Idee prüfen
const isProjekt = modus === "simpel" || modus === "gruendlich" // Projekt für Zed
const isIde = isIdee || isProjekt // beides → projektstart
// Ziel-Plattform des Projekts (nur IDE-Welt): läuft es als LXC-Dienst oder in Docker?
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [openLog, setOpenLog] = useState<string | null>(null)
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
const [antworten, setAntworten] = useState<Record<string, string>>({})
const items = data?.items ?? []
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
// Polling-Log nur aktivieren, wenn Idee triage/running und geöffnet
const logEnabled = openLog !== null && (data?.items.find((i) => i.id === openLog)?.status === "triage" || data?.items.find((i) => i.id === openLog)?.status === "running")
const logData = useIdeenLog(openLog ?? "", logEnabled)
async function submit() {
const t = text.trim()
if (t.length < 3 || sending) return
setSending(true)
try {
// Lange Eingaben: Kurzfassung als Titel, alles als Notiz — der Specifier der Box
// macht daraus ohnehin einen sauberen Auftrag.
const titel = t.length > 160 ? t.slice(0, 157) + "…" : t
await api("/api/ideen", { method: "POST", body: JSON.stringify({
titel, notiz: t.length > 160 ? t : "",
welt: isIde ? "ide" : "box",
tiefe: isProjekt ? modus : "",
ziel: isProjekt ? ziel : "", // eine Idee braucht noch keine Plattform
art: isIdee ? "idee" : "projekt",
}) })
setText("")
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setSending(false)
}
}
async function archive(id: string) {
setBusy((b) => ({ ...b, [id]: true }))
try {
await api("/api/ideen/archivieren", { method: "POST", body: JSON.stringify({ id }) })
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [id]: false }))
}
}
// Karten-Steuerung (Stopp / Neuer Versuch / Prio) — ein Helfer für alle drei Endpunkte.
async function steuer(prefix: string, id: string, pfad: string, body: object) {
setBusy((b) => ({ ...b, [prefix + id]: true }))
try {
await api(pfad, { method: "POST", body: JSON.stringify(body) })
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, [prefix + id]: false }))
}
}
// Hängende Aufgabe beantworten: die Antwort geht als Kommentar an die Karte, der Worker
// macht damit weiter (Ausweg aus der Sackgasse „fragt, aber niemand kann antworten").
async function answer(id: string) {
const t = (antworten[id] || "").trim()
if (t.length < 1 || busy["ans:" + id]) return
setBusy((b) => ({ ...b, ["ans:" + id]: true }))
try {
await api("/api/ideen/antwort", { method: "POST", body: JSON.stringify({ id, antwort: t }) })
setAntworten((a) => ({ ...a, [id]: "" }))
reload()
} catch (e: any) {
showAlert("Das hat nicht geklappt", String(e?.message || e))
} finally {
setBusy((b) => ({ ...b, ["ans:" + id]: false }))
}
}
// EINE Zeilen-Darstellung für alle Sichten (Projekt-Gruppen + Einzelkarten) — damit
// Antwortfeld, Live-Log und Ergebnis-Panel überall identisch funktionieren.
const zeile = (it: IdeenItem) => {
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
const isOpen = openLog === it.id
// Aufklappbar: laufende Karten zeigen das Live-Log, FERTIGE das Ergebnis
// (User-Wunsch 15.07.: „Dinge werden getestet, ich sehe das Ergebnis nicht").
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
const blocked = it.status === "blocked"
const answering = !!busy["ans:" + it.id]
// Hänger-Verdacht: läuft, aber seit >10 min kein Lebenszeichen (Heartbeat) — der
// Prozess kann trotzdem „leben" (wartet z. B. auf einen selbst gestarteten Server).
const haengt = it.status === "running" && (it.puls_alter ?? 0) > 600
const wartend = it.status === "todo" || it.status === "ready" || it.status === "scheduled"
return (
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : haengt ? "border-amber-500/40" : "border-border/40")}>
<div className="flex items-center gap-2">
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
{haengt && (
<span className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
title="Der Worker lebt, gibt aber kein Lebenszeichen mehr — vermutlich wartet er auf etwas, das nie kommt. „Neuer Versuch“ startet die Karte frisch.">
<AlertTriangle className="h-3 w-3" /> {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung
</span>
)}
{(it.prio ?? 0) !== 0 && (
<span className="shrink-0 rounded bg-background/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
title="Priorität — höher kommt zuerst dran (bei gleicher Prio: Erstellzeit)">P{it.prio}</span>
)}
{(() => {
const a = ART_UI[it.art || (it.welt === "ide" ? "projekt" : "box")]
if (!a) return null
const Icon = a.icon
return (
<span className={cn("flex shrink-0 items-center gap-1 rounded-lg border px-2 py-0.5 text-[10px] font-bold", a.cls)} title={a.title}>
<Icon className="h-3 w-3" /> {a.label}
</span>
)
})()}
{/* Tooltip = der volle TITEL (der ist abgeschnitten), nicht der Auftragstext
der beginnt mit Vorspann-Textbausteinen und half beim Lesen nie. */}
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.titel}>{it.titel}</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
{wartend && (
<span className="flex shrink-0 items-center">
<button
disabled={!!busy["pr:" + it.id]}
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "hoch" })}
title="Vorziehen (Prio +1)"
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
disabled={!!busy["pr:" + it.id]}
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "runter" })}
title="Zurückstellen (Prio 1)"
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
<ArrowDown className="h-3.5 w-3.5" />
</button>
</span>
)}
{(it.status === "running" || blocked) && (
<button
disabled={!!busy["rt:" + it.id]}
onClick={() =>
showConfirm("Neuer Versuch?",
it.status === "running"
? `Der laufende Worker von '${it.titel.slice(0, 60)}' wird beendet (samt gestarteter Test-Prozesse) und die Karte frisch gestartet. Bisheriger Zwischenstand im Workspace bleibt liegen.`
: `Die Karte '${it.titel.slice(0, 60)}' wird entsperrt und läuft weiter, sobald ein Platz frei ist.`,
() => steuer("rt:", it.id, "/api/ideen/retry", { id: it.id }))}
title="Neuer Versuch — Karte frisch starten"
className={cn("shrink-0 rounded-md p-1 transition-colors cursor-pointer disabled:opacity-50",
haengt ? "text-amber-300 hover:text-amber-200" : "text-muted-foreground/60 hover:text-foreground")}>
{busy["rt:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
</button>
)}
{(it.status === "running" || wartend || it.status === "triage") && (
<button
disabled={!!busy["st:" + it.id]}
onClick={() =>
showConfirm("Karte stoppen?",
`'${it.titel.slice(0, 60)}' wird angehalten${it.status === "running" ? " und der laufende Worker beendet" : ""}. Sie bleibt als gestoppt im Board und kann jederzeit per „Neuer Versuch“ weiterlaufen.`,
() => steuer("st:", it.id, "/api/ideen/stopp", { id: it.id }))}
title="Stoppen — Karte anhalten"
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-red-300 cursor-pointer disabled:opacity-50">
{busy["st:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
</button>
)}
{(it.status === "done" || blocked) && (
<button
disabled={!!busy[it.id]}
onClick={() => archive(it.id)}
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</button>
)}
{it.welt === "ide" && (it.status === "running" || it.status === "done") && (
<button
onClick={() => setOpenKonzept(openKonzept === it.id ? null : it.id)}
title="Konzept ansehen (das ausgearbeitete KONZEPT.md)"
className={cn("flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-bold transition-colors cursor-pointer",
openKonzept === it.id ? "text-violet-300" : "text-violet-300/70 hover:text-violet-200")}>
<FileText className="h-3.5 w-3.5" /> Konzept
</button>
)}
{canOpen && (
<button
onClick={() => setOpenLog(isOpen ? null : it.id)}
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
</div>
{openKonzept === it.id && (
<KonzeptPanel
id={it.id}
istIdee={it.art === "idee" && it.status === "done"}
onWeiter={reload}
/>
)}
{it.status === "running" && it.notiz && (
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
{it.notiz}
</p>
)}
{(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (
<p className="-mt-1 ml-1 text-[10px] text-muted-foreground/70">
wartet auf: {it.wartet_auf!.join(" · ")}
</p>
)}
{blocked && (
<div className="space-y-2 border-t border-red-500/20 pt-2">
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
<span>
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
{it.frage
? <span className="text-foreground/90">{it.frage}</span>
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
</span>
</p>
<div className="flex gap-2">
<input
value={antworten[it.id] || ""}
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
placeholder="Deine Antwort — die Box macht damit weiter"
maxLength={2000}
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
/>
<button
onClick={() => answer(it.id)}
disabled={answering || (antworten[it.id] || "").trim().length < 1}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten &amp; weiter
</button>
</div>
</div>
)}
{isOpen && canOpen && it.status !== "done" && (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
{logEnabled && logData.isLoading ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen </p>
) : logData.error ? (
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
) : logData.data?.available && logData.data.lines.length > 0 ? (
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
{logData.data.lines.join("\n")}
</pre>
) : (
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
)}
</div>
</div>
)}
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
</li>
)
}
// Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite).
if (data && !data.available) return null
return (
<section className="space-y-3">
<SectionLabel icon={Lightbulb}>
Ideen-Queue{data ? `${data.offen} offen` : ""}
</SectionLabel>
<div className="mc-card p-4 space-y-3">
<div className="flex gap-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit() }}
placeholder="Neue Idee — einfach hinschreiben, die Box macht den Rest"
className="h-9 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/50 px-3 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
/>
<button
onClick={submit}
disabled={sending || text.trim().length < 3}
className={cn("flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[11px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-50",
modus === "gruendlich" ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
: modus === "simpel" ? "border-violet-500/40 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
: isIdee ? "border-sky-500/40 bg-sky-500/10 text-sky-300 hover:bg-sky-500/20"
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20")}>
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : isIdee ? <Lightbulb className="h-3.5 w-3.5" /> : isProjekt ? <Code2 className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{isIdee ? "Durchdenken" : isProjekt ? "Vorbereiten" : "In die Queue"}
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground/70">Modus</span>
<div className="flex rounded-lg border border-border/60 p-0.5">
<button
onClick={() => setModus("box")}
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
modus === "box" ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
<Hammer className="h-3 w-3" /> Box
</button>
<button
onClick={() => setModus("idee")}
title="Produkt-Idee erst einmal durchdenken lassen: Was ist es, für wen, was gibt es schon, was spricht dagegen? Ergebnis ist ein Konzept — kein Projekt, kein Bau-Druck."
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
modus === "idee" ? "bg-sky-500/15 text-sky-300" : "text-muted-foreground/60 hover:text-foreground")}>
<Lightbulb className="h-3 w-3" /> Idee
</button>
<button
onClick={() => setModus("simpel")}
title="IDE-Projekt, schnell vorbereitet: knappes Konzept, wenige Denk-Runden. Für kleine, klar umrissene Sachen."
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
modus === "simpel" ? "bg-violet-500/15 text-violet-300" : "text-muted-foreground/60 hover:text-foreground")}>
<Code2 className="h-3 w-3" /> IDE: simpel
</button>
<button
onClick={() => setModus("gruendlich")}
title="IDE-Projekt, gründlich durchdacht: volle Kaskade mit mehreren Rollen und Härtetests. Für große/ambitionierte Projekte."
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
modus === "gruendlich" ? "bg-amber-500/15 text-amber-300" : "text-muted-foreground/60 hover:text-foreground")}>
<Code2 className="h-3 w-3" /> IDE: gründlich
</button>
</div>
</div>
{/* Immer sichtbare Orientierung der Text darunter erklärt nur den GEWÄHLTEN
Modus, die Wahl selbst muss man aber vorher treffen können (User 21.07.). */}
<p className="text-[10px] leading-relaxed text-muted-foreground/60">
<b className="text-primary/80">Box</b>, wenn die Box es selbst bauen soll (dein Heimlabor,
MC2, Lucy Ergebnis kommt als Patch-Vorschlag zurück). ·{" "}
<b className="text-sky-300/80">Idee</b>, wenn du noch nicht weißt, ob es das überhaupt wert
ist erst durchdenken, entscheiden kannst du danach. ·{" "}
<b className="text-violet-300/80">IDE</b>, wenn du es selbst in Zed bauen willst und dafür
ein startklares Repo brauchst.
</p>
{isProjekt && (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
<div className="flex rounded-lg border border-border/40 p-0.5">
<button
onClick={() => setZiel("lxc")}
title="Läuft als Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server automatisch mitgesichert."
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
ziel === "lxc" ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
LXC
</button>
<button
onClick={() => setZiel("docker")}
title="Natives Docker nach Standard-Praxis: Dockerfile + compose, portabel, über Gitea-/GitLab-Pipelines baubar. Für Projekte, die bewusst außerhalb der LXC-Welt leben."
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
ziel === "docker" ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
Docker
</button>
</div>
<span className="text-[10px] text-muted-foreground/50">
{ziel === "lxc" ? "Dienst im Proxmox-Container (deine Heimat)" : "natives Docker, portabel & pipeline-tauglich"}
</span>
</div>
)}
<p className="text-[11px] text-muted-foreground">
{isIdee ? (
<>
<b className="text-sky-300/90">Idee durchdenken:</b> Die Box arbeitet deine Produkt-Idee
mit Produkt-Blick aus was ist es genau, für wen, was gibt es dafür schon, was wäre der
kleinste sinnvolle Wurf und was spricht dagegen. Volle Denk-Kaskade mit Kritiker, Ergebnis
ist <b>nur ein Konzept</b> (kein Gerüst, keine Roadmap, kein Bau-Druck) du liest es hier
und entscheidest danach, ob ein Projekt draus wird.
</>
) : isProjekt ? (
<>
<b className={modus === "gruendlich" ? "text-amber-300/90" : "text-violet-300/90"}>IDE-Welt{modus === "gruendlich" ? " · gründlich" : " · simpel"}:</b>{" "}
Die Box denkt das Konzept durch{modus === "gruendlich"
? " — volle Kaskade: mehrere Fach-Rollen, bis zu 3 Denk-Runden, mehrfacher Kritiker-Härtetest (für große/ambitionierte Projekte, dauert entsprechend)"
: " — schlank: 2 Rollen, 1 Denk-Runde (für kleine, klar umrissene Projekte, ein paar Minuten)"}, legt
ein leeres Gitea-Repo an und füllt es nur mit den wichtigen Dokumenten (Konzept, Roadmap, AGENTS.md,
.aiexclude) <b>kein Code</b>. Danach öffnest du das Repo in Zed und baust selbst.
</>
) : (
<>
Eine Queue, alle Türen: hier, per Telegram oder im Gespräch mit Lucy. Die Box arbeitet Ideen selbst
aus und baut Code-Ergebnisse erscheinen als Karte unter Patch-Vorschläge, nichts davon geht ohne
deinen Klick live. Nur reine Doku-Bagatellen zieht die Box nachts selbst ein (mit Fangnetz und Meldung).
</>
)}
</p>
{isLoading && (
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Queue wird geladen </p>
)}
{data?.fehler && (
<p className="text-xs text-amber-300">Queue gerade nicht lesbar neuer Versuch läuft.</p>
)}
{items.length > 0 && (
<div className="space-y-3">
{(data?.projekte ?? []).map((p) => (
<ProjektGruppe key={p.key} projekt={p} karten={items.filter((i) => i.projekt === p.key)} zeile={zeile} />
))}
{(() => {
// Einzelkarten (keine Kette) — die klassische flache Liste wie bisher.
const keys = new Set((data?.projekte ?? []).map((p) => p.key))
const einzeln = items.filter((i) => !i.projekt || !keys.has(i.projekt))
return einzeln.length > 0 ? <ul className="space-y-1.5">{einzeln.map((it) => zeile(it))}</ul> : null
})()}
</div>
)}
{data?.available && !isLoading && items.length === 0 && (
<p className="text-xs text-muted-foreground/70">Queue ist leer wirf der Box eine Idee zu.</p>
)}
</div>
{dialogElement}
</section>
)
}
// Karten-Familie (verkettete Karten = ein Projekt) als Block: Fortschritt oben, dann die
// AKTIVEN Karten in Ausführungs-Reihenfolge, Wartende und Fertige zusammengeklappt.
// Ersetzt für Ketten die flache Erstellzeit-Liste (User: „Reihenfolge komplett durcheinander").
const AKTIV_RANG: Record<string, number> = { blocked: 0, running: 1, review: 2, ready: 3, triage: 4 }
function ProjektGruppe({ projekt, karten, zeile }: {
projekt: IdeenProjekt
karten: IdeenItem[]
zeile: (it: IdeenItem) => React.ReactNode
}) {
const [zeigWartende, setZeigWartende] = useState(false)
const [zeigFertige, setZeigFertige] = useState(false)
const aktiv = karten
.filter((i) => AKTIV_RANG[i.status] !== undefined)
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
const wartend = karten
.filter((i) => i.status === "todo" || i.status === "scheduled")
.sort((a, b) => ((a.wartet_auf?.length ? 1 : 0) - (b.wartet_auf?.length ? 1 : 0)) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
const fertig = karten
.filter((i) => i.status === "done")
.sort((a, b) => (b.fertig ?? 0) - (a.fertig ?? 0))
const anteil = projekt.gesamt > 0 ? Math.round((projekt.fertig / projekt.gesamt) * 100) : 0
return (
<div className="space-y-2 rounded-xl border border-border/50 bg-background/20 p-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 shrink-0 text-primary/70" />
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground" title={projekt.titel}>{projekt.titel}</p>
<span className="shrink-0 text-[10px] text-muted-foreground">{projekt.fertig} von {projekt.gesamt} fertig</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-background/60" title={`${anteil}%`}>
<div className="h-1 rounded-full bg-primary/70 transition-all" style={{ width: `${anteil}%` }} />
</div>
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
{wartend.length > 0 && (
<div>
<button
onClick={() => setZeigWartende((v) => !v)}
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
{zeigWartende ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{wartend.length} Karte{wartend.length === 1 ? "" : "n"} in der Kette laufen automatisch der Reihe nach
</button>
{zeigWartende && <ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>}
</div>
)}
{fertig.length > 0 && (
<div>
<button
onClick={() => setZeigFertige((v) => !v)}
className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300/70 hover:text-emerald-300 transition-colors cursor-pointer">
{zeigFertige ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{fertig.length} fertig
</button>
{zeigFertige && <ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>}
</div>
)}
</div>
)
}
// Konzept einer IDE-Karte: holt es aus dem Gitea-Repo — oder, wenn der Worker keins
// angelegt hat, aus seiner Datei in ~/konzepte (Bug-Fund 21.07.: dann stand hier vorher
// „noch kein Repo hinterlegt", obwohl das Konzept längst fertig war).
// Bei einer fertig durchdachten IDEE hängt unten der Weg nach vorn: „Gefällt mir".
function KonzeptPanel({ id, istIdee, onWeiter }: {
id: string
istIdee?: boolean
onWeiter?: () => void
}) {
const [data, setData] = useState<IdeenKonzept | null>(null)
const [fehler, setFehler] = useState(false)
const [versuch, setVersuch] = useState(0)
useEffect(() => {
let weg = false
setData(null); setFehler(false)
api<IdeenKonzept>(`/api/ideen/${id}/konzept`)
.then((r) => { if (!weg) setData(r) })
.catch(() => { if (!weg) setFehler(true) })
return () => { weg = true }
}, [id, versuch])
return (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-violet-500/25 bg-violet-500/[0.04] p-3">
<div className="mb-2 flex items-center gap-2">
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-violet-300">
<FileText className="h-3 w-3" /> Konzept
</p>
{data?.repo && (
<a href={`https://git.tobisniceshomelab.ddnsfree.com/${data.repo}`}
className="flex items-center gap-1 text-[10px] text-violet-300/70 hover:text-violet-200">
<GitFork className="h-3 w-3" /> {data.repo}
</a>
)}
{!data?.repo && data?.quelle === "datei" && (
<span className="truncate font-mono text-[10px] text-muted-foreground/70"
title="Der Worker hat kein Repo angelegt — das Konzept liegt als Datei auf der Box.">
{data.pfad}
</span>
)}
<button onClick={() => setVersuch((v) => v + 1)} title="Neu laden"
className="ml-auto rounded p-0.5 text-muted-foreground/50 hover:text-foreground cursor-pointer">
<RefreshCw className="h-3 w-3" />
</button>
</div>
{fehler ? (
<p className="text-[11px] text-red-400">Konzept nicht ladbar später nochmal versuchen.</p>
) : data === null ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Konzept wird geladen </p>
) : data.konzept ? (
<div className="max-h-[28rem] overflow-y-auto pr-1">
<Markdown text={data.konzept} />
</div>
) : (
<p className="text-[11px] text-muted-foreground italic">
{data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}
</p>
)}
{istIdee && data?.konzept && <WeiterLeiste id={id} onFertig={onWeiter} />}
</div>
</div>
)
}
// Was nach dem Lesen passieren kann. Zwei Wege, bewusst nebeneinander statt hintereinander:
// nachschärfen (ein Punkt passt nicht — häufiger Fall, darum oben und offen) oder annehmen.
function WeiterLeiste({ id, onFertig }: { id: string; onFertig?: () => void }) {
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
const [tiefe, setTiefe] = useState<"simpel" | "gruendlich">("simpel")
const [busy, setBusy] = useState(false)
const [neu, setNeu] = useState<{ id: string; art: "projekt" | "runde" } | null>(null)
const [fehler, setFehler] = useState("")
const [hinweis, setHinweis] = useState("")
const ruf = async (pfad: string, rumpf: object, art: "projekt" | "runde") => {
setBusy(true); setFehler("")
try {
const r = await api<{ id: string }>(pfad, { method: "POST", body: JSON.stringify(rumpf) })
setNeu({ id: r.id, art })
onFertig?.()
} catch (e: any) {
setFehler(String(e?.message || e))
} finally {
setBusy(false)
}
}
if (neu) {
return (
<p className="mt-3 border-t border-violet-500/20 pt-2 text-[11px] text-emerald-300">
{neu.art === "projekt" ? (
<>Projekt-Karte <span className="font-mono">{neu.id}</span> ist angelegt die Box bereitet
das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in Zed.</>
) : (
<>Überarbeitung <span className="font-mono">{neu.id}</span> läuft die Box arbeitet deine
Rückmeldung ein und schreibt die neue Fassung an dieselbe Stelle. Danach hier wieder
Konzept" öffnen.</>
)}
</p>
)
}
return (
<div className="mt-3 space-y-3 border-t border-violet-500/20 pt-2.5">
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">
<b className="text-sky-300/90">Passt etwas nicht?</b> Schreib hin, was anders werden soll
die Box arbeitet es ein und lässt den Rest stehen. Beliebig oft wiederholbar.
</p>
<div className="flex gap-2">
<input
value={hinweis}
onChange={(e) => setHinweis(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && hinweis.trim().length >= 3 && !busy) ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde") }}
placeholder="z. B. „Punkt 3: kein WebSocket, das ist mir zu fragil — nimm Polling“"
maxLength={2000}
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-sky-400/50 focus:outline-none"
/>
<button
onClick={() => ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde")}
disabled={busy || hinweis.trim().length < 3}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-sky-300 transition-all hover:bg-sky-500/20 cursor-pointer disabled:opacity-50">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Nachschärfen
</button>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
<b className="text-violet-300/90">Passt es so?</b> Dann macht die Box daraus ein startklares
Repo Konzept wird übernommen (nicht neu gedacht), dazu Roadmap, AGENTS.md &amp; Co.
Gebaut wird danach von dir in Zed.
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
<div className="flex rounded-lg border border-border/40 p-0.5">
{(["lxc", "docker"] as const).map((z) => (
<button key={z} onClick={() => setZiel(z)}
title={z === "lxc" ? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker) — wird automatisch mitgesichert."
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
ziel === z ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
{z === "lxc" ? "LXC" : "Docker"}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground/70">Aufwand</span>
<div className="flex rounded-lg border border-border/40 p-0.5">
{(["simpel", "gruendlich"] as const).map((t) => (
<button key={t} onClick={() => setTiefe(t)}
title={t === "simpel" ? "Konzept ist schon gehärtet — nur kurz auf die Bau-Sicht nachschärfen (empfohlen)."
: "Nochmal volle Kaskade über die Bau-Sicht: mehrere Rollen, Härtetests. Dauert entsprechend."}
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
tiefe === t ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
{t === "simpel" ? "schlank" : "gründlich"}
</button>
))}
</div>
<button
onClick={() => ruf("/api/ideen/weiterfuehren", { id, ziel, tiefe }, "projekt")}
disabled={busy}
className="ml-auto flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Code2 className="h-3.5 w-3.5" />}
Gefällt mir Projekt daraus machen
</button>
</div>
{fehler && <p className="text-[11px] text-red-400">{fehler}</p>}
</div>
)
}
// Ergebnis einer FERTIGEN Queue-Aufgabe: die Abschluss-Zusammenfassung des Workers
// (kanban_complete). Lazy — wird erst beim Aufklappen geladen, ändert sich nie mehr.
function ErgebnisPanel({ id }: { id: string }) {
const [erg, setErg] = useState<IdeenErgebnis | null>(null)
const [fehler, setFehler] = useState(false)
// Fehler darf nicht kleben: fällt der erste Versuch in ein Deploy-Fenster
// (Zentrale startet gerade neu), muss ein Klick neu laden können (Vorfall 16.07.).
const [versuch, setVersuch] = useState(0)
useEffect(() => {
let weg = false
setFehler(false)
setErg(null)
api<IdeenErgebnis>(`/api/ideen/${id}/ergebnis`)
.then((r) => { if (!weg) setErg(r) })
.catch(() => { if (!weg) setFehler(true) })
return () => { weg = true }
}, [id, versuch])
const text = erg ? (erg.summary || erg.result) : ""
return (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-2.5">
<p className="mb-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
<ClipboardCheck className="h-3 w-3" /> Ergebnis des Workers
</p>
{fehler ? (
<button
type="button"
onClick={() => setVersuch((v) => v + 1)}
className="text-[10px] text-red-400 underline decoration-dotted underline-offset-2 hover:text-red-300"
>
Ergebnis nicht ladbar nochmal versuchen
</button>
) : erg === null ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Wird geladen </p>
) : text ? (
<p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{text}</p>
) : (
<p className="text-[10px] text-muted-foreground italic">
Der Worker hat keine Abschluss-Zusammenfassung hinterlassen (ältere Karte oder Protokollverstoß).
</p>
)}
</div>
</div>
)
}
function StateBadge({ item }: { item: AuftragItem }) {
const st = item.status
File diff suppressed because it is too large Load Diff