0f13a4f18d
Kategorie-Skills (z. B. autonomous-ai-agents/hermes-agent) haben einen
Schraegstrich in der ID. Die Route /api/eigenleben/skill/{skill_id} nahm nur
EIN Pfadsegment -> Request fiel in den SPA-Fallback (index.html, Status 200),
die UI zeigte "(Konnte den Skill-Text nicht laden.)". Live bewiesen:
skill/wartung = JSON, skill/autonomous-ai-agents%2Fhermes-agent = HTML.
Fix: :path-Konverter in der Route; dazu im Service "."/".."-Segmente
explizit abweisen (das Zeichen-Set der bestehenden Validierung liesse
".." als Segment durch, bisher irrelevant, mit :path sauber dicht).
Backend-only, kein dist-Rebuild noetig.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
220 lines
8.5 KiB
Python
220 lines
8.5 KiB
Python
"""Eigenleben — was die Box von allein gebaut und vorgeschlagen hat.
|
|
|
|
Beantwortet die User-Frage vom 15.07.2026: „Es gibt keinen Viewpoint, welche Skills
|
|
erstellt wurden, was die Box vorgeschlagen hat, welche Skills Lucy hat und wie die
|
|
funktionieren." Reine LESE-Schicht, keine Schreib-Operationen:
|
|
|
|
- Skills aus ~/.hermes/skills (= Lucys Skill-Satz; die Worker-Profile werkstatt/betrieb
|
|
tragen Kopien desselben Satzes). Herkunft dreistufig klassifiziert:
|
|
selbst → weder mitgeliefert noch aus unserem Repo = die Box/Lucy hat ihn erzeugt
|
|
repo → liegt in ~/mission-control-v2/deploy/skills (von uns gebaut + deployt)
|
|
bundled → liegt in ~/.hermes/hermes-agent/skills (kam mit Hermes mit)
|
|
- Nutzungszahlen aus ~/.hermes/skills/.usage.json (Hermes' eigener Zähler).
|
|
- Bilanz aus Auftragsbuch-Chronik (/srv/models/mc2-auftragsbuch.json, angenommene
|
|
Branches) + Ablehnungs-Lern-Journal (/srv/models/mc2-ablehnungen.jsonl).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
SKILLS_DIR = Path.home() / ".hermes" / "skills"
|
|
BUNDLED_DIR = Path.home() / ".hermes" / "hermes-agent" / "skills"
|
|
REPO_DIR = Path.home() / "mission-control-v2" / "deploy" / "skills"
|
|
HERMES_SRC = Path.home() / ".hermes" / "hermes-agent"
|
|
HERMES_PY = HERMES_SRC / "venv" / "bin" / "python"
|
|
USAGE_FILE = SKILLS_DIR / ".usage.json"
|
|
AUFTRAGSBUCH_FILE = Path("/srv/models/mc2-auftragsbuch.json")
|
|
ABLEHNUNGEN_FILE = Path("/srv/models/mc2-ablehnungen.jsonl")
|
|
|
|
_HERKUNFT_LABEL = {
|
|
"selbst": "Selbst erstellt",
|
|
"repo": "Von uns gebaut",
|
|
"bundled": "Mit Hermes mitgeliefert",
|
|
}
|
|
|
|
|
|
def _frontmatter(text: str) -> dict:
|
|
"""Sehr kleiner Frontmatter-Leser: nur die flachen key: value-Zeilen des ersten
|
|
---Blocks (name/description/version/author reichen hier; kein YAML-Import nötig)."""
|
|
out: dict = {}
|
|
if not text.startswith("---"):
|
|
return out
|
|
for line in text.split("\n", 1)[1].split("\n"):
|
|
if line.strip() == "---":
|
|
break
|
|
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
|
if not m:
|
|
continue
|
|
val = m.group(2).strip().strip('"').strip("'")
|
|
if val:
|
|
out[m.group(1)] = val
|
|
return out
|
|
|
|
|
|
def _norm(s: str) -> str:
|
|
return re.sub(r"[\s_-]+", "", (s or "").lower())
|
|
|
|
|
|
# aktiv/inaktiv: dieselbe Wahrheit wie Hermes selbst (und sein eingebautes WebUI) —
|
|
# hermes_cli.banner.get_available_skills() prüft Plattform/Voraussetzungen/Config und
|
|
# liefert, was der Agent WIRKLICH laden kann. Wir rufen sie im Hermes-venv auf (MC2
|
|
# läuft in eigenem Python) und cachen großzügig — der Skill-Satz ändert sich selten.
|
|
_aktiv_cache: dict = {"ts": 0.0, "namen": None}
|
|
_AKTIV_EVERY = 600.0 # s
|
|
|
|
|
|
def _aktive_namen() -> set[str] | None:
|
|
"""Normalisierte Namen aller ladbaren Skills; None = nicht ermittelbar (Dev-Modus)."""
|
|
if not HERMES_PY.is_file():
|
|
return None
|
|
now = time.time()
|
|
if _aktiv_cache["namen"] is not None and now - _aktiv_cache["ts"] < _AKTIV_EVERY:
|
|
return _aktiv_cache["namen"]
|
|
code = ("from hermes_cli.banner import get_available_skills;import json;"
|
|
"print(json.dumps(get_available_skills()))")
|
|
try:
|
|
r = subprocess.run([str(HERMES_PY), "-c", code], capture_output=True,
|
|
text=True, timeout=60, cwd=str(HERMES_SRC))
|
|
idx = r.stdout.find("{")
|
|
kategorien = json.loads(r.stdout[idx:]) if idx >= 0 else {}
|
|
namen = {_norm(n) for liste in kategorien.values() for n in liste}
|
|
except Exception:
|
|
return _aktiv_cache["namen"] # alter Stand ist besser als flackerndes None
|
|
_aktiv_cache.update(ts=now, namen=namen)
|
|
return namen
|
|
|
|
|
|
def list_skills() -> list[dict]:
|
|
"""Alle Skills mit Beschreibung, Herkunft und Nutzungszahlen (leer im Dev-Modus)."""
|
|
if not SKILLS_DIR.is_dir():
|
|
return []
|
|
try:
|
|
usage_raw = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
usage_raw = {}
|
|
usage = {_norm(k): v for k, v in usage_raw.items()}
|
|
aktive = _aktive_namen()
|
|
|
|
def eintrag(md: Path, rel: str, kategorie: str | None) -> dict:
|
|
try:
|
|
fm = _frontmatter(md.read_text(encoding="utf-8", errors="replace"))
|
|
except Exception:
|
|
fm = {}
|
|
if (BUNDLED_DIR / rel).is_dir():
|
|
herkunft = "bundled"
|
|
elif (REPO_DIR / rel).is_dir():
|
|
herkunft = "repo"
|
|
else:
|
|
herkunft = "selbst"
|
|
kurz = rel.split("/")[-1]
|
|
u = usage.get(_norm(kurz)) or usage.get(_norm(fm.get("name", ""))) or {}
|
|
aktiv = None
|
|
if aktive is not None:
|
|
aktiv = _norm(kurz) in aktive or _norm(fm.get("name", "")) in aktive
|
|
return {
|
|
"id": rel,
|
|
"name": fm.get("name") or kurz,
|
|
"kategorie": kategorie,
|
|
"aktiv": aktiv,
|
|
"beschreibung": fm.get("description") or "",
|
|
"version": fm.get("version") or "",
|
|
"autor": fm.get("author") or "",
|
|
"herkunft": herkunft,
|
|
"herkunft_label": _HERKUNFT_LABEL[herkunft],
|
|
"genutzt": int(u.get("use_count") or 0),
|
|
"zuletzt_genutzt": u.get("last_used_at") or None,
|
|
"erstellt": u.get("created_at") or None,
|
|
"status": u.get("state") or "active",
|
|
"geaendert": md.stat().st_mtime,
|
|
}
|
|
|
|
skills: list[dict] = []
|
|
for d in sorted(SKILLS_DIR.iterdir()):
|
|
if not d.is_dir() or d.name.startswith("."):
|
|
continue # .archive (Curator) & Co. sind keine aktiven Skills
|
|
md = d / "SKILL.md"
|
|
if md.is_file():
|
|
skills.append(eintrag(md, d.name, None))
|
|
continue
|
|
# Kategorie-Ordner (mitgelieferte Sammlungen wie research/, apple/): eine Ebene
|
|
# tiefer liegen die echten Skills (research/blogwatcher/SKILL.md) — je einzeln zeigen.
|
|
for sub in sorted(d.iterdir()):
|
|
smd = sub / "SKILL.md"
|
|
if sub.is_dir() and not sub.name.startswith(".") and smd.is_file():
|
|
skills.append(eintrag(smd, f"{d.name}/{sub.name}", d.name))
|
|
# Selbst erstellte zuerst — das ist der Star der Ansicht.
|
|
order = {"selbst": 0, "repo": 1, "bundled": 2}
|
|
skills.sort(key=lambda s: (order[s["herkunft"]], -s["genutzt"], s["name"]))
|
|
return skills
|
|
|
|
|
|
def skill_text(skill_id: str) -> str | None:
|
|
"""Voller SKILL.md-Text („wie funktioniert der Skill") — max. Kategorie/Name, keine Pfad-Tricks."""
|
|
if not re.fullmatch(r"[\w.-]+(/[\w.-]+)?", skill_id or ""):
|
|
return None
|
|
# Das Zeichen-Set oben erlaubt „." und „.." als Segment — seit die Route ein
|
|
# :path-Parameter ist, hier explizit raus (kein Klettern über SKILLS_DIR hinaus).
|
|
if any(seg in (".", "..") for seg in skill_id.split("/")):
|
|
return None
|
|
md = SKILLS_DIR / skill_id / "SKILL.md"
|
|
if not md.is_file():
|
|
return None
|
|
try:
|
|
return md.read_text(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def bilanz() -> dict:
|
|
"""Vorschlags-Bilanz: was die Box vorschlug und was daraus wurde (eine Timeline)."""
|
|
eintraege: list[dict] = []
|
|
try:
|
|
branches = json.loads(AUFTRAGSBUCH_FILE.read_text(encoding="utf-8")).get("branches") or {}
|
|
for branch, info in branches.items():
|
|
eintraege.append({
|
|
"art": "angenommen" if info.get("state") == "eingespielt" else info.get("state", "?"),
|
|
"titel": branch,
|
|
"detail": info.get("detail") or "",
|
|
"ts": float(info.get("ts") or 0),
|
|
})
|
|
except Exception:
|
|
pass
|
|
try:
|
|
for line in ABLEHNUNGEN_FILE.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
j = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
eintraege.append({
|
|
"art": "abgelehnt",
|
|
"titel": j.get("subject") or j.get("branch") or "?",
|
|
"detail": j.get("grund") or "",
|
|
"ts": float(j.get("ts") or 0),
|
|
})
|
|
except Exception:
|
|
pass
|
|
eintraege.sort(key=lambda e: -e["ts"])
|
|
return {
|
|
"eintraege": eintraege,
|
|
"angenommen": sum(1 for e in eintraege if e["art"] == "angenommen"),
|
|
"abgelehnt": sum(1 for e in eintraege if e["art"] == "abgelehnt"),
|
|
}
|
|
|
|
|
|
def overview() -> dict:
|
|
skills = list_skills()
|
|
b = bilanz()
|
|
return {
|
|
"available": SKILLS_DIR.is_dir(),
|
|
"skills": skills,
|
|
"selbst_erstellt": sum(1 for s in skills if s["herkunft"] == "selbst"),
|
|
"bilanz": b,
|
|
}
|