diff --git a/deploy/agent-hooks/box-steckbrief-inject.sh b/deploy/agent-hooks/box-steckbrief-inject.sh index 2067461..6823d7e 100644 --- a/deploy/agent-hooks/box-steckbrief-inject.sh +++ b/deploy/agent-hooks/box-steckbrief-inject.sh @@ -29,6 +29,7 @@ hint="" ctx="$(cat <= 3 fuer dieses Tool: EINMAL mechanisch blocken + (Diagnose-Anweisung statt naechster Blindversuch), Zaehler + zuruecksetzen. Der Folgeversuch ist frei; laeuft er wieder + 3x gegen dasselbe Muster, blockt es erneut. + +Gezaehlt wird nur, wo Wiederholung wirklich Stillstand heisst: terminal IMMER +(dort verstecken sich die "erfolgreichen" Fehler), andere Tools nur bei +status=error. Lese-Tools deckt die native idempotent-Bremse ab. + +Aufruf (hooks in config.yaml): `no-progress-bremse.py pre` bzw. `... post`. +State: ~/.hermes/state/no-progress/.json (48 h Selbst-Aufraeumen). +Fail-open: jeder eigene Fehler -> {} (die Bremse darf nie selbst zur Wand werden). +""" +import hashlib +import json +import os +import re +import sys +import time + +STATE_DIR = os.path.expanduser("~/.hermes/state/no-progress") +SIG_LOG = os.path.join(STATE_DIR, "neue-signaturen.jsonl") +SEEN_FILE = os.path.join(STATE_DIR, "seen-sigs.json") +THRESHOLD = 3 +STATE_TTL = 48 * 3600 + +BLOCK_REASON = ( + "NO-PROGRESS-BREMSE: '{tool}' hat {n}x in Folge dasselbe Ergebnismuster geliefert — " + "das ist Stillstand, egal wie sehr die Befehle variieren. STOPP. " + "1) Benenne das Hindernis in EINEM Satz (was genau meldet das Ergebnis?). " + "2) Diagnose statt Variation — bei SSH/Zugriff ZUERST: Schluessel-Passphrase pruefen " + "(`ssh-keygen -y -f str: + t = (text or "")[:2000].lower() + t = _HEX_RE.sub("#", _NUM_RE.sub("#", t)) + t = _WS_RE.sub(" ", t).strip()[:400] + return hashlib.sha1(f"{tool}|{t}".encode()).hexdigest()[:16] + + +def _session_key(p: dict) -> str: + sid = p.get("session_id") or p.get("parent_session_id") or "" + if sid: + return re.sub(r"[^A-Za-z0-9_.-]", "_", str(sid))[:80] + return "cwd-" + hashlib.sha1(os.getcwd().encode()).hexdigest()[:12] + + +def _prune() -> None: + now = time.time() + for f in os.listdir(STATE_DIR): + fp = os.path.join(STATE_DIR, f) + if f.endswith(".json") and f != os.path.basename(SEEN_FILE): + if now - os.path.getmtime(fp) > STATE_TTL: + os.remove(fp) + + +def _load(path: str) -> dict: + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except Exception: + return {} + + +def _save(path: str, data: dict) -> None: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh) + os.replace(tmp, path) + + +def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "post" + payload = json.load(sys.stdin) + tool = str(payload.get("tool_name") or "") + if not tool: + print("{}") + return + os.makedirs(STATE_DIR, exist_ok=True) + sfile = os.path.join(STATE_DIR, _session_key(payload) + ".json") + state = _load(sfile) + tools = state.setdefault("tools", {}) + + if mode == "post": + status = str(payload.get("status") or "ok") + # terminal immer beobachten (dort tarnen sich Fehler als Erfolg), + # sonst nur echte Fehler — Lese-Tools regelt die native Bremse. + if tool != "terminal" and status != "error": + print("{}") + return + sig = _norm_sig(tool, str(payload.get("result") or "")) + entry = tools.get(tool) or {} + entry["count"] = entry.get("count", 0) + 1 if entry.get("sig") == sig else 1 + entry["sig"] = sig + tools[tool] = entry + _save(sfile, state) + _prune() + seen = _load(SEEN_FILE) + sigs = seen.setdefault("sigs", []) + if sig not in sigs and status == "error" or (tool == "terminal" and sig not in sigs and entry["count"] >= 2): + sigs.append(sig) + del sigs[:-500] + _save(SEEN_FILE, seen) + sample = _WS_RE.sub(" ", str(payload.get("result") or ""))[:200] + with open(SIG_LOG, "a", encoding="utf-8") as fh: + fh.write(json.dumps({"ts": int(time.time()), "tool": tool, + "sig": sig, "sample": sample}, ensure_ascii=False) + "\n") + print("{}") + return + + # mode == "pre" + entry = tools.get(tool) or {} + if entry.get("count", 0) >= THRESHOLD: + entry["count"] = 0 + tools[tool] = entry + _save(sfile, state) + print(json.dumps({"decision": "block", + "reason": BLOCK_REASON.format(tool=tool, n=THRESHOLD)}, + ensure_ascii=False)) + return + print("{}") + + +if __name__ == "__main__": + try: + main() + except Exception: + print("{}") diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 62d343c..a3be460 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -160,6 +160,14 @@ cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-sta # Output = Konzept-Dokument (~/konzepte/-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop. mkdir -p "$HOME/.hermes/skills/konzept-fliessband" cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md" +# Betrieb-Playbook (Phase 2 Drei-Welten-Plan, 19.07.2026): Diagnose-Checklisten der +# Betrieb-Bahn (SSH/Deploy) — Gegenstueck zur No-Progress-Bremse. Auch in die Profile, +# damit betrieb-Worker es laden koennen (Profile teilen KEINE Skills, Lehre 18.07.). +for _sd in "$HOME/.hermes/skills" "$HOME"/.hermes/profiles/*/skills; do + [ -d "$(dirname "$_sd")" ] || continue + mkdir -p "$_sd/betrieb-playbook" + cp "$SRC/deploy/skills/betrieb-playbook/SKILL.md" "$_sd/betrieb-playbook/SKILL.md" +done # Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt # Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker # (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das @@ -169,7 +177,7 @@ mkdir -p "$HOME/.hermes/agent-hooks" # Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die # mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose # "script modified since approval"-Warnung von `hermes hooks doctor` aus. -for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py; do +for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py no-progress-bremse.py; do _src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h" cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; } done diff --git a/deploy/ensure-profile-hooks.py b/deploy/ensure-profile-hooks.py index ad9d0db..e6af2c5 100644 --- a/deploy/ensure-profile-hooks.py +++ b/deploy/ensure-profile-hooks.py @@ -1,14 +1,19 @@ #!/usr/bin/env python3 -"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026). +"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026; +erweitert 19.07.2026 um die No-Progress-Bremse und Mehrfach-Eintraege pro Event). Kanban-Worker laufen unter IHREM Profil (HERMES_HOME=~/.hermes/profiles/) und lesen dessen config.yaml — NICHT die Top-Level ~/.hermes/config.yaml. Die worker-relevanten Hooks -(box-steckbrief-inject = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen also in JEDER -Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.). +muessen also in JEDER Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie +(Bug-Fund 13.07.). -Idempotent + validiert + Backup. Setzt einen bestehenden `pre_verify`-Hook-Block als Anker -voraus (haben alle von default/werkstatt geklonten Profile); fehlt er, wird sauber -uebersprungen (exit 0), damit deploy.sh nicht bricht. Arg: Pfad zur Profil-config.yaml. +WICHTIG (Falle, 19.07.): Ein Event darf nur EINMAL als YAML-Key existieren. Existiert +`pre_tool_call:` bereits (tabu-pfade-guard), wird ein weiterer Eintrag IN den Block +eingefuegt — ein zweiter `pre_tool_call:`-Key wuerde beim YAML-Laden den ersten +verschlucken und den Tabu-Guard lautlos deaktivieren. + +Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende +`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml. """ import sys import os @@ -22,10 +27,13 @@ except Exception: sys.exit(0) AH = os.path.expanduser("~/.hermes/agent-hooks") -WANT = { - "pre_llm_call": f"{AH}/box-steckbrief-inject.sh", - "pre_tool_call": f"{AH}/tabu-pfade-guard.py", -} +# Reihenfolge zaehlt: tabu-pfade-guard MUSS vor der Bremse stehen (Schutz vor Komfort). +WANT = [ + ("pre_llm_call", f"{AH}/box-steckbrief-inject.sh", 10), + ("pre_tool_call", f"{AH}/tabu-pfade-guard.py", 10), + ("pre_tool_call", f"{AH}/no-progress-bremse.py pre", 10), + ("post_tool_call", f"{AH}/no-progress-bremse.py post", 10), +] if len(sys.argv) < 2: print(" usage: ensure-profile-hooks.py ") @@ -44,22 +52,41 @@ if txt.count(anchor) != 1: print(f" [{os.path.basename(os.path.dirname(p))}] kein eindeutiger pre_verify-Anker — SKIP") sys.exit(0) -add = "" -for event, cmd in WANT.items(): - if cmd not in txt: - add += f" {event}:\n - command: {cmd}\n timeout: 10\n" +new = txt +changed = False +for event, cmd, timeout in WANT: + if cmd in new: + continue + entry = f" - command: {cmd}\n timeout: {timeout}\n" + ev_line = f" {event}:\n" + if ev_line in new: + # Eintrag ANS ENDE des bestehenden Event-Blocks: direkt nach dem Key einfuegen + # waere auch ok, aber hinter dem letzten Eintrag haelt die gewollte Reihenfolge. + idx = new.index(ev_line) + len(ev_line) + end = idx + for line in new[idx:].splitlines(keepends=True): + if line.startswith((" - ", " ")): + end += len(line) + else: + break + new = new[:end] + entry + new[end:] + else: + new = new.replace(anchor, anchor + ev_line + entry, 1) + changed = True -if not add: +if not changed: print(f" [{os.path.basename(os.path.dirname(p))}] Hooks schon registriert.") sys.exit(0) -new = txt.replace(anchor, anchor + add, 1) try: d = yaml.safe_load(new) h = d["hooks"] - for event, cmd in WANT.items(): - assert any(cmd in x.get("command", "") for x in h.get(event, [])) + for event, cmd, _t in WANT: + assert any(cmd == x.get("command", "") for x in h.get(event, [])), f"{event}: {cmd} fehlt" assert any("pre-verify-gates.sh" in x.get("command", "") for x in h["pre_verify"]) + # Tabu-Guard muss VOR der Bremse stehen (beide in pre_tool_call). + cmds = [x.get("command", "") for x in h["pre_tool_call"]] + assert cmds.index(f"{AH}/tabu-pfade-guard.py") < cmds.index(f"{AH}/no-progress-bremse.py pre") except Exception as exc: print(f" [{p}] Validierung fehlgeschlagen ({exc}) — config UNVERAENDERT.") sys.exit(0) diff --git a/deploy/skills/betrieb-playbook/SKILL.md b/deploy/skills/betrieb-playbook/SKILL.md new file mode 100644 index 0000000..2d863f0 --- /dev/null +++ b/deploy/skills/betrieb-playbook/SKILL.md @@ -0,0 +1,44 @@ +--- +name: betrieb-playbook +description: Betrieb-Bahn-Playbook fuer SSH-, Deploy- und Dienst-Aufgaben auf Box/pve/LXCs. IMMER laden, bevor du per SSH auf eine Kiste gehst, einen Dienst anfasst oder etwas deployst — es enthaelt die Diagnose-Checklisten, die teure Schleifen verhindern. +--- + +# Betrieb-Playbook (Bahn: BETRIEB) + +## Triage zuerst +Jede Aufgabe gehoert in GENAU eine Bahn: **Denken** (Konzept/Plan → Konzept-Fliessband), +**Bauen** (Code/Feature → Werkstatt-Karte), **Betrieb** (SSH/Deploy/Dienst → DIESES Playbook). +Wenn deine Aufgabe Code SCHREIBT, ist sie Bauen — nicht Betrieb. Mischformen aufteilen. + +## Eiserne Regeln +1. **Erster Versuch sitzt:** Vor dem ersten Befehl 30 Sekunden Lage klaeren (welcher Host, + welcher User, welcher Key, welcher Dienst). Zeit = Schleifen × Kontext. +2. **Maximal 3 Anlaeufe pro Hindernis.** Liefert ein Befehl 3x dasselbe Ergebnismuster + (auch als "Erfolg" getarnt wie `Permission denied` in der Ausgabe): STOPP — Diagnose + nach Checkliste, sonst blocked/Karte mit EXAKTEM Fehlertext an den Commander. + Die No-Progress-Bremse erzwingt das mechanisch — arbeite MIT ihr, nicht gegen sie. +3. **Verify ist Pflicht:** Nach jeder Aenderung den Zielzustand BEWEISEN + (systemctl is-active, ss -tlnp, curl mit HTTP-Code) und in den Bericht schreiben. +4. **Backup vor Aenderung:** Config/Datei erst `.bak`-kopieren, dann anfassen. + Rollback-Weg im Kopf haben, BEVOR du aenderst. + +## SSH-Diagnose-Checkliste (die teuer gelernte Reihenfolge) +1. **Passphrase-Falle ZUERST:** `ssh-keygen -y -f user@host` — + eine ~/.ssh/config-Wildcard kann den falschen Key aufzwingen. +3. `Host key verification failed` = known_hosts-Problem des AUFRUFERS, nicht der Gegenseite + (`ssh-keyscan host >> ~/.ssh/known_hosts` nach Pruefung des Fingerprints). +4. Gegenseite LESEN statt raten: pve/LXC-Auth-Log via `journalctl _COMM=sshd-session`; + Dienststatus `systemctl status `; Erreichbarkeit `ss -tlnp` am Ziel. +5. LXCs ohne SSH-Weg: `ssh pve` + `pct exec -- ` / `pct push` (Dateien erst + per scp auf pve zwischenlagern). authorized_keys auf pve IMMER in /root/.ssh UND + /etc/pve/priv pflegen. + +## Deploy-Checkliste (Box/LXC) +1. Quelle = Git-Branch, nie lose Dateien. Clone nach /tmp, nach Erfolg aufraeumen. +2. Zielzustand vorher erfassen (laeuft ein Alt-Prozess lose? Unit vorhanden/enabled?). +3. Dateien uebertragen → Alt-Prozess sauber stoppen → Unit `daemon-reload` + + `enable --now` → Verify (Schritt 3 der Eisernen Regeln). +4. Secrets (.env) NIE durchs Repo, NIE in Karten — liegen am Ziel oder kommen vom Commander. diff --git a/hermes/plugins/mc2-memory/__init__.py b/hermes/plugins/mc2-memory/__init__.py index 14cffb1..83529f0 100644 --- a/hermes/plugins/mc2-memory/__init__.py +++ b/hermes/plugins/mc2-memory/__init__.py @@ -49,6 +49,12 @@ MIN_USER_LEN = int(os.environ.get("MC2_MEMORY_MIN_USER_LEN", "25")) # zu Kurzes # wandern und Tage später ungefragt wiederverwendet werden. SKIP_UNTRUSTED = os.environ.get("MC2_MEMORY_SKIP_UNTRUSTED", "1") not in ("0", "false", "False", "no") UNTRUSTED_MARKERS = ("[Bildschirm-Sicht",) +# Lern-Buendelung (Phase 2, 19.07.2026): Die LLM-Extraktion lief bisher nach JEDEM Turn und +# konkurrierte dabei mit Lucys Hirn um die Slots (gleiches Modell, --parallel 2). Jetzt werden +# Turns gesammelt und als EIN Gespraechs-Block extrahiert: nach BATCH_TURNS Turns oder wenn +# BATCH_IDLE_S Sekunden Ruhe war. Ein Batch = ein Extraktionslauf statt N. +BATCH_TURNS = max(1, int(os.environ.get("MC2_MEMORY_BATCH_TURNS", "4"))) +BATCH_IDLE_S = float(os.environ.get("MC2_MEMORY_BATCH_IDLE", "180")) # Triviale Turns, die keinen dauerhaften Fakt tragen (reine Begrüßung/Quittung, Aufwärm-/Test- # Kommandos) → gar nicht erst zum Auto-Lernen schicken. Spart Extraktions-Läufe und verhindert @@ -77,6 +83,10 @@ class MC2MemoryProvider(MemoryProvider): self._worker: Optional[threading.Thread] = None self._stop = threading.Event() self._recall_cache: Dict[str, str] = {} + # Turn-Puffer der Lern-Buendelung (siehe BATCH_TURNS/BATCH_IDLE_S). + self._buf: List[List[Dict[str, str]]] = [] + self._buf_lock = threading.Lock() + self._buf_last_add = 0.0 # -- Identität / Verfügbarkeit ------------------------------------------- def name(self) -> str: @@ -164,13 +174,29 @@ class MC2MemoryProvider(MemoryProvider): a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip() if a: msgs.append({"role": "assistant", "content": a[:4000]}) - try: - self._q.put_nowait(msgs) - except queue.Full: - log.debug("mc2-memory learn queue full — Turn übersprungen") + with self._buf_lock: + self._buf.append(msgs) + self._buf_last_add = time.time() + full = len(self._buf) >= BATCH_TURNS + if full: + self._drain_buffer() # Neue Fakten können gelandet sein → Recall-Cache invalidieren. self._recall_cache.clear() + def _drain_buffer(self) -> None: + """Gepufferte Turns als EINEN Gespraechs-Block in die Lern-Queue geben.""" + with self._buf_lock: + if not self._buf: + return + merged: List[Dict[str, str]] = [] + for turn in self._buf: + merged.extend(turn) + self._buf.clear() + try: + self._q.put_nowait(merged) + except queue.Full: + log.debug("mc2-memory learn queue full — Batch übersprungen") + def _post_learn(self, msgs: list) -> None: try: httpx.post(f"{MC_URL}/api/memory/learn", @@ -182,8 +208,14 @@ class MC2MemoryProvider(MemoryProvider): def _run(self) -> None: while not self._stop.is_set(): try: - msgs = self._q.get(timeout=1.0) + msgs = self._q.get(timeout=5.0) except queue.Empty: + # Idle-Flush: Ruhe im Gespraech → angesammelte Turns jetzt lernen. + with self._buf_lock: + idle = (self._buf and + time.time() - self._buf_last_add >= BATCH_IDLE_S) + if idle: + self._drain_buffer() continue try: self._post_learn(msgs) @@ -193,6 +225,7 @@ class MC2MemoryProvider(MemoryProvider): def _flush(self) -> None: """Offene Turns garantiert rausschreiben — bei Session-Ende/CLI-Exit, wo der Prozess sofort beendet wird (daemon-Worker würde sonst mitten im POST sterben).""" + self._drain_buffer() self._stop.set() if self._worker and self._worker.is_alive(): self._worker.join(timeout=95.0) # laufenden Worker-POST zu Ende lassen