mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 06:56:41 +00:00
feat: add skills_api patch + apply_patch infra
This commit is contained in:
parent
21bd3ae5cc
commit
1f2fc5f5a9
4 changed files with 140 additions and 2 deletions
13
Dockerfile
13
Dockerfile
|
|
@ -2,12 +2,21 @@ FROM nousresearch/hermes-agent:latest
|
||||||
|
|
||||||
USER root
|
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 '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
|
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
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
|
|
||||||
6
entrypoint.sh
Normal file → Executable file
6
entrypoint.sh
Normal file → Executable file
|
|
@ -1,6 +1,12 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SOUL.md override via env
|
||||||
if [ -n "$HERMES_SOUL_OVERRIDE" ]; then
|
if [ -n "$HERMES_SOUL_OVERRIDE" ]; then
|
||||||
echo "$HERMES_SOUL_OVERRIDE" > /opt/data/.hermes/SOUL.md
|
echo "$HERMES_SOUL_OVERRIDE" > /opt/data/.hermes/SOUL.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Garante diretório de skills
|
||||||
|
mkdir -p /opt/data/.hermes/skills
|
||||||
|
|
||||||
exec /opt/hermes/docker/entrypoint.sh "$@"
|
exec /opt/hermes/docker/entrypoint.sh "$@"
|
||||||
|
|
|
||||||
47
patches/apply_patch.py
Normal file
47
patches/apply_patch.py
Normal file
|
|
@ -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))
|
||||||
76
patches/skills_api.py
Normal file
76
patches/skills_api.py
Normal file
|
|
@ -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)}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue