From 1f2fc5f5a9a9988ae1b804f7623921fb42490182 Mon Sep 17 00:00:00 2001 From: Felipe Domingues Date: Mon, 27 Apr 2026 14:55:51 -0300 Subject: [PATCH] feat: add skills_api patch + apply_patch infra --- Dockerfile | 13 ++++++-- entrypoint.sh | 6 ++++ patches/apply_patch.py | 47 ++++++++++++++++++++++++++ patches/skills_api.py | 76 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) mode change 100644 => 100755 entrypoint.sh create mode 100644 patches/apply_patch.py create mode 100644 patches/skills_api.py diff --git a/Dockerfile b/Dockerfile index 7463ac4..5568784 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,21 @@ FROM nousresearch/hermes-agent:latest USER root -RUN mkdir -p /opt/data/.hermes +RUN mkdir -p /opt/data/.hermes /opt/data/.hermes/skills /opt/hermes-custom +# Config + SOUL default RUN printf 'model:\n provider: ollama-cloud\n default: gemma4:31b-cloud\n' > /opt/data/.hermes/config.yaml - RUN printf 'Você é Mika, uma assistente pessoal de IA criada pela DomCo.' > /opt/data/.hermes/SOUL.md +# Patches +COPY patches/skills_api.py /opt/hermes-custom/skills_api.py +COPY patches/apply_patch.py /opt/hermes-custom/apply_patch.py + +# Disponibiliza skills_api no PYTHONPATH e injeta o include_router no api_server.py +ENV PYTHONPATH="/opt/hermes-custom:${PYTHONPATH}" +RUN python3 /opt/hermes-custom/apply_patch.py /opt/hermes/gateway/platforms/api_server.py + +# Entrypoint custom COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 17a33b1..1df0673 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,12 @@ #!/bin/bash +set -e + +# SOUL.md override via env if [ -n "$HERMES_SOUL_OVERRIDE" ]; then echo "$HERMES_SOUL_OVERRIDE" > /opt/data/.hermes/SOUL.md fi +# Garante diretório de skills +mkdir -p /opt/data/.hermes/skills + exec /opt/hermes/docker/entrypoint.sh "$@" diff --git a/patches/apply_patch.py b/patches/apply_patch.py new file mode 100644 index 0000000..2d7fd4a --- /dev/null +++ b/patches/apply_patch.py @@ -0,0 +1,47 @@ +""" +Injeta `from skills_api import router as skills_router` + `app.include_router(...)` +no api_server.py do Hermes durante o build da imagem. + +Idempotente: se o marcador já existir, não faz nada. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +MARKER = "# >>> hermes-custom: skills_api <<<" +INJECT = f""" +{MARKER} +try: + from skills_api import router as _skills_router + app.include_router(_skills_router) + print("[hermes-custom] skills_api router registered", flush=True) +except Exception as _e: + print(f"[hermes-custom] failed to register skills_api: {{_e}}", flush=True) +# <<< hermes-custom: skills_api >>> +""" + + +def main(target: str) -> int: + p = Path(target) + if not p.exists(): + print(f"[apply_patch] target not found: {target}", file=sys.stderr) + return 1 + + src = p.read_text(encoding="utf-8") + if MARKER in src: + print("[apply_patch] already applied, skipping") + return 0 + + # Procura a linha onde o `app = FastAPI(...)` é instanciado e injeta logo + # após o bloco de criação. Estratégia: anexa no final do arquivo — mais + # robusto contra mudanças upstream do que tentar achar a linha exata. + new_src = src.rstrip() + "\n\n" + INJECT + "\n" + p.write_text(new_src, encoding="utf-8") + print(f"[apply_patch] injected at offset {len(src)}") + return 0 + + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "/opt/hermes/gateway/platforms/api_server.py" + sys.exit(main(target)) diff --git a/patches/skills_api.py b/patches/skills_api.py new file mode 100644 index 0000000..647f525 --- /dev/null +++ b/patches/skills_api.py @@ -0,0 +1,76 @@ +""" +Skills API router — injetado no api_server.py do Hermes pelo apply_patch.py. +Expõe: + GET /api/skills → lista skills do tenant + POST /api/skills/sync → upsert em lote (chamado pelo Lovable) + GET /api/skills/health → ping +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Header, HTTPException + +router = APIRouter(prefix="/api/skills", tags=["skills"]) + +SKILLS_DIR = Path(os.environ.get("HERMES_SKILLS_DIR", "/opt/data/.hermes/skills")) +SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "") + + +def _ensure_dir() -> None: + SKILLS_DIR.mkdir(parents=True, exist_ok=True) + + +def _check_auth(token: str | None) -> None: + if not SYNC_TOKEN: + raise HTTPException(500, "HERMES_SKILLS_SYNC_TOKEN não configurado") + if token != SYNC_TOKEN: + raise HTTPException(401, "Token inválido") + + +@router.get("/health") +def health() -> dict[str, Any]: + _ensure_dir() + return {"ok": True, "dir": str(SKILLS_DIR), "count": len(list(SKILLS_DIR.glob("*.md")))} + + +@router.get("") +def list_skills() -> dict[str, Any]: + _ensure_dir() + items = [] + for f in sorted(SKILLS_DIR.glob("*.md")): + items.append({"name": f.stem, "size": f.stat().st_size}) + return {"skills": items} + + +@router.post("/sync") +def sync_skills( + payload: dict[str, Any], + x_sync_token: str | None = Header(default=None, alias="X-Sync-Token"), +) -> dict[str, Any]: + _check_auth(x_sync_token) + _ensure_dir() + skills = payload.get("skills") or [] + if not isinstance(skills, list): + raise HTTPException(400, "skills deve ser uma lista") + + written: list[str] = [] + for s in skills: + name = (s.get("name") or "").strip() + body = s.get("markdown") or "" + if not name or "/" in name or ".." in name: + continue + target = SKILLS_DIR / f"{name}.md" + target.write_text(body, encoding="utf-8") + written.append(name) + + # Remove skills que não vieram no payload (sync completo, opcional) + if payload.get("prune"): + keep = set(written) + for f in SKILLS_DIR.glob("*.md"): + if f.stem not in keep: + f.unlink(missing_ok=True) + + return {"written": written, "count": len(written)}