diff --git a/Dockerfile b/Dockerfile index 9c91213..36a2a77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,20 +36,35 @@ RUN pip install --no-cache-dir aiohttp # Copia customizações COPY patches/skills_api.py /opt/hermes-custom/skills_api.py COPY entrypoint.sh /opt/hermes-custom/entrypoint.sh +COPY plugins/mika_runtime /opt/hermes/plugins/mika_runtime RUN chmod +x /opt/hermes-custom/entrypoint.sh # Garante que o Python encontre o módulo customizado ENV PYTHONPATH=/opt/hermes-custom:/opt/hermes +ENV HERMES_MODEL_PROVIDER=ollama-cloud +ENV HERMES_MODEL_DEFAULT=gemma4:31b-cloud +ENV HERMES_STT_PROVIDER=local +ENV HERMES_STT_LOCAL_MODEL=base +ENV HERMES_TTS_PROVIDER=disabled # Diretório de dados/config do Hermes RUN mkdir -p /opt/data/.hermes /opt/hermes-custom -# Config padrão (caso HERMES_CONFIG_OVERRIDE não seja definido) +# Config padrão. O entrypoint reescreve este arquivo em runtime a partir das +# env vars por tenant, mas deixamos um fallback válido já embutido na imagem. RUN printf '%s\n' \ 'name: hermes-custom' \ 'host: 127.0.0.1' \ 'port: 8000' \ 'data_dir: /opt/data' \ + 'model:' \ + ' provider: "ollama-cloud"' \ + ' default: "gemma4:31b-cloud"' \ + 'stt:' \ + ' enabled: true' \ + ' provider: "local"' \ + ' local:' \ + ' model: "base"' \ > /opt/data/.hermes/config.yaml # SOUL.md padrão (sobrescrito em runtime via HERMES_SOUL_OVERRIDE) diff --git a/entrypoint.sh b/entrypoint.sh index 1249f84..9e8880b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,15 +7,78 @@ PUBLIC_HOST="${PUBLIC_HOST:-0.0.0.0}" PUBLIC_PORT="${PORT:-${PUBLIC_PORT:-8642}}" HERMES_HOME="${HERMES_HOME:-/opt/data/.hermes}" +CONFIG_PATH="${CONFIG_PATH:-$HERMES_HOME/config.yaml}" SOUL_PATH="${SOUL_PATH:-$HERMES_HOME/SOUL.md}" +MODEL_PROVIDER="${HERMES_MODEL_PROVIDER:-ollama-cloud}" +MODEL_DEFAULT="${HERMES_MODEL_DEFAULT:-gemma4:31b-cloud}" +STT_PROVIDER="${HERMES_STT_PROVIDER:-local}" +STT_LOCAL_MODEL="${HERMES_STT_LOCAL_MODEL:-base}" +STT_OPENAI_MODEL="${HERMES_STT_OPENAI_MODEL:-whisper-1}" +TTS_PROVIDER="${HERMES_TTS_PROVIDER:-disabled}" mkdir -p "$HERMES_HOME" +write_config() { + cat > "$CONFIG_PATH" <> "$CONFIG_PATH" <> "$CONFIG_PATH" <> "$CONFIG_PATH" < "$SOUL_PATH" fi +if [ "${HERMES_SUSPENDED:-}" = "true" ]; then + echo "[entrypoint] Agent suspended via HERMES_SUSPENDED=true" + exec sleep infinity +fi + echo "[entrypoint] Hermes interno: $INTERNAL_HOST:$INTERNAL_PORT" echo "[entrypoint] Proxy público: $PUBLIC_HOST:$PUBLIC_PORT" echo "[entrypoint] Iniciando Hermes original..." diff --git a/patches/skills_api.py b/patches/skills_api.py index ab247ff..845e7af 100644 --- a/patches/skills_api.py +++ b/patches/skills_api.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 """ -skills_api.py — Reverse proxy + Skills API para Hermes Agent. +skills_api.py — Reverse proxy + Runtime Sync API para Hermes Agent. Roda na porta pública ($PORT) e: - - Intercepta /api/skills/* → handlers locais - - Proxy todo o resto → Hermes interno (127.0.0.1:$HERMES_INTERNAL_PORT) + - Intercepta /api/skills/*, /api/cronjobs/*, /api/integrations/* + - Proxy todo o resto para o Hermes interno (127.0.0.1:$HERMES_INTERNAL_PORT) """ import asyncio +import json import os +import re +import shutil import sys from aiohttp import web, ClientSession, ClientTimeout @@ -19,6 +22,15 @@ HERMES_INTERNAL_PORT = int(os.environ.get("HERMES_INTERNAL_PORT", "8000")) HERMES_BASE = f"http://127.0.0.1:{HERMES_INTERNAL_PORT}" API_SERVER_KEY = os.environ.get("API_SERVER_KEY", "") SKILLS_SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "") +HERMES_HOME = os.environ.get("HERMES_HOME", "/opt/data/.hermes") +SKILLS_ROOT = os.path.join(HERMES_HOME, "skills") +MANAGED_SKILLS_ROOT = os.path.join(SKILLS_ROOT, "mika-managed") +SKILLS_MANIFEST_PATH = os.path.join(MANAGED_SKILLS_ROOT, "manifest.json") +RUNTIME_ROOT = os.path.join(HERMES_HOME, "mika") +MANAGED_CRON_ROOT = os.path.join(RUNTIME_ROOT, "cronjobs") +CRON_MANIFEST_PATH = os.path.join(MANAGED_CRON_ROOT, "manifest.json") +MANAGED_INTEGRATIONS_ROOT = os.path.join(RUNTIME_ROOT, "integrations") +INTEGRATIONS_MANIFEST_PATH = os.path.join(MANAGED_INTEGRATIONS_ROOT, "manifest.json") # Hop-by-hop headers que NÃO devem ser repassados no proxy HOP_BY_HOP = { @@ -26,44 +38,533 @@ HOP_BY_HOP = { "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length", } + +# ============================================================ +# Helpers +# ============================================================ +def _check_auth(request: web.Request, expected_token: str = "") -> bool: + """Valida Authorization: Bearer .""" + token = (expected_token or API_SERVER_KEY).strip() + if not token: + return True # sem chave configurada = aberto + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return False + return auth[7:].strip() == token + + +def _load_json(path: str, default: dict | list | None = None): + if not os.path.exists(path): + return default if default is not None else {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return default if default is not None else {} + + +def _secure_file(path: str, mode: int = 0o600) -> None: + try: + if os.path.exists(path): + os.chmod(path, mode) + except OSError: + pass + + +def _write_json(path: str, payload: dict, mode: int = 0o600) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp, path) + _secure_file(path, mode) + + +def _write_text(path: str, content: str, mode: int = 0o644) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, path) + _secure_file(path, mode) + + +def _managed_skill_dirname(skill: dict) -> str: + raw_name = str(skill.get("name") or "skill").strip().lower() + slug = re.sub(r"[^a-z0-9._-]+", "-", raw_name).strip("-") or "skill" + skill_id = str(skill.get("skill_id") or "manual").strip()[:8] or "manual" + return f"{slug}--{skill_id}" + + +def _managed_entry_name(slug_source: str, entry_id: str) -> str: + slug = re.sub(r"[^a-z0-9._-]+", "-", str(slug_source or "entry").strip().lower()).strip("-") or "entry" + suffix = re.sub(r"[^a-z0-9]+", "", str(entry_id or "manual").lower())[:8] or "manual" + return f"{slug}--{suffix}" + + +def _load_skills_manifest() -> dict: + data = _load_json(SKILLS_MANIFEST_PATH, {"skills": []}) + return data if isinstance(data, dict) else {"skills": []} + + +def _load_runtime_manifest(path: str, key: str) -> dict: + data = _load_json(path, {key: []}) + return data if isinstance(data, dict) else {key: []} + + +def _load_cron_helpers(): + try: + from cron.jobs import ensure_dirs, load_jobs, save_jobs, compute_next_run + except Exception as exc: + raise RuntimeError(f"cron helpers unavailable: {exc}") from exc + return ensure_dirs, load_jobs, save_jobs, compute_next_run + + +def _normalize_string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + items = [] + for item in value: + if isinstance(item, str): + text = item.strip() + if text: + items.append(text) + return items + + +def _sanitize_cron_runtime_job(raw_job: dict, existing_job: dict | None, synced_at: str, compute_next_run): + job_id = str(raw_job.get("job_id") or "").strip() + name = str(raw_job.get("name") or "").strip() + prompt = str(raw_job.get("action_prompt") or "").strip() + cron_expression = str(raw_job.get("cron_expression") or "").strip() + + if not job_id or not name or not prompt or not cron_expression: + return None + + status = str(raw_job.get("status") or "paused").strip().lower() + enabled = status == "active" + human_readable = str(raw_job.get("human_readable") or cron_expression).strip() or cron_expression + schedule = { + "kind": "cron", + "expr": cron_expression, + "display": human_readable, + } + + last_run_at = ( + existing_job.get("last_run_at") + if existing_job and existing_job.get("last_run_at") + else raw_job.get("last_run_at") + ) + + schedule_changed = ( + not existing_job or + existing_job.get("schedule", {}).get("expr") != cron_expression + ) + + if existing_job and not schedule_changed and existing_job.get("next_run_at"): + next_run_at = existing_job.get("next_run_at") + else: + next_run_at = raw_job.get("next_run_at") or compute_next_run(schedule, last_run_at) + + repeat = existing_job.get("repeat") if isinstance(existing_job, dict) else None + if not isinstance(repeat, dict): + repeat = {"times": None, "completed": 0} + repeat_completed = repeat.get("completed") + if not isinstance(repeat_completed, int): + repeat_completed = 0 + repeat_times = repeat.get("times") + if not isinstance(repeat_times, int): + repeat_times = None + + paused_reason = None if enabled else (raw_job.get("auto_paused_reason") or "Sincronizado como pausado pelo Mika") + paused_at = None if enabled else ( + existing_job.get("paused_at") + if existing_job and existing_job.get("paused_at") + else synced_at + ) + + return { + "id": job_id, + "name": name, + "prompt": prompt, + "skills": [], + "skill": None, + "model": None, + "provider": None, + "base_url": None, + "script": None, + "context_from": None, + "schedule": schedule, + "schedule_display": human_readable, + "repeat": { + "times": repeat_times, + "completed": repeat_completed, + }, + "enabled": enabled, + "state": "scheduled" if enabled else "paused", + "paused_at": paused_at, + "paused_reason": paused_reason, + "created_at": ( + existing_job.get("created_at") + if existing_job and existing_job.get("created_at") + else str(raw_job.get("created_at") or synced_at) + ), + "next_run_at": next_run_at, + "last_run_at": last_run_at, + "last_status": existing_job.get("last_status") if existing_job else None, + "last_error": existing_job.get("last_error") if existing_job else None, + "last_delivery_error": existing_job.get("last_delivery_error") if existing_job else None, + "deliver": "local", + "origin": None, + "enabled_toolsets": None, + "workdir": None, + "managed_by": "mika", + "mika": { + "job_id": job_id, + "description": raw_job.get("description"), + "natural_language_input": raw_job.get("natural_language_input"), + "required_mcp_slugs": _normalize_string_list(raw_job.get("required_mcp_slugs")), + "status": status, + "auto_paused_reason": raw_job.get("auto_paused_reason"), + "timezone": raw_job.get("timezone"), + "source_updated_at": raw_job.get("updated_at"), + "synced_at": synced_at, + }, + } + + # ============================================================ # Skills API handlers # ============================================================ async def skills_health(request: web.Request) -> web.Response: """Health check — não requer auth.""" + manifest = _load_skills_manifest() return web.json_response({ "status": "ok", "service": "skills_api", "hermes_backend": HERMES_BASE, + "managed_skills_root": MANAGED_SKILLS_ROOT, + "managed_skills_count": len(manifest.get("skills", [])), + "last_sync_at": manifest.get("synced_at"), }) -def _check_auth(request: web.Request) -> bool: - """Valida Authorization: Bearer .""" - if not API_SERVER_KEY: - return True # sem chave configurada = aberto - auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): - return False - return auth[7:].strip() == API_SERVER_KEY - - async def skills_list(request: web.Request) -> web.Response: if not _check_auth(request): return web.json_response({"error": "unauthorized"}, status=401) - # TODO: integrar com sistema real de skills do Hermes - return web.json_response({"skills": []}) + manifest = _load_skills_manifest() + return web.json_response({ + "skills": manifest.get("skills", []), + "managed_skills_root": MANAGED_SKILLS_ROOT, + "synced_at": manifest.get("synced_at"), + "agent_instance_id": manifest.get("agent_instance_id"), + }) async def skills_sync(request: web.Request) -> web.Response: - if not _check_auth(request): + if not _check_auth(request, SKILLS_SYNC_TOKEN or API_SERVER_KEY): return web.json_response({"error": "unauthorized"}, status=401) try: payload = await request.json() except Exception: return web.json_response({"error": "invalid json"}, status=400) - # TODO: persistir skills recebidas - return web.json_response({"ok": True, "received": len(payload.get("skills", []))}) + + if not isinstance(payload, dict): + return web.json_response({"error": "payload must be a json object"}, status=400) + + incoming_skills = payload.get("skills", []) + if not isinstance(incoming_skills, list): + return web.json_response({"error": "skills must be a list"}, status=400) + + os.makedirs(MANAGED_SKILLS_ROOT, exist_ok=True) + + expected_dirs = set() + written_skills = [] + skipped = 0 + + for raw_skill in incoming_skills: + if not isinstance(raw_skill, dict): + skipped += 1 + continue + + skill_id = str(raw_skill.get("skill_id") or "").strip() + name = str(raw_skill.get("name") or "").strip() + markdown_content = str(raw_skill.get("markdown_content") or "").strip() + + if not skill_id or not name or not markdown_content: + skipped += 1 + continue + + dirname = _managed_skill_dirname(raw_skill) + skill_dir = os.path.join(MANAGED_SKILLS_ROOT, dirname) + os.makedirs(skill_dir, exist_ok=True) + + skill_markdown = markdown_content if markdown_content.endswith("\n") else f"{markdown_content}\n" + _write_text(os.path.join(skill_dir, "SKILL.md"), skill_markdown, mode=0o644) + + metadata = dict(raw_skill) + metadata.pop("markdown_content", None) + metadata["managed_dir"] = dirname + metadata["synced_at"] = payload.get("synced_at") + _write_json(os.path.join(skill_dir, "mika.json"), metadata, mode=0o644) + + expected_dirs.add(dirname) + written_skills.append(metadata) + + removed = [] + if os.path.isdir(MANAGED_SKILLS_ROOT): + for entry in os.listdir(MANAGED_SKILLS_ROOT): + if entry == "manifest.json": + continue + full_path = os.path.join(MANAGED_SKILLS_ROOT, entry) + if os.path.isdir(full_path) and entry not in expected_dirs: + shutil.rmtree(full_path, ignore_errors=True) + removed.append(entry) + + manifest = { + "agent_instance_id": payload.get("agent_instance_id"), + "synced_at": payload.get("synced_at"), + "skills": written_skills, + } + _write_json(SKILLS_MANIFEST_PATH, manifest, mode=0o644) + + return web.json_response({ + "ok": True, + "received": len(incoming_skills), + "written": len(written_skills), + "skipped": skipped, + "removed": removed, + "managed_skills_root": MANAGED_SKILLS_ROOT, + }) + + +# ============================================================ +# Cronjobs API handlers +# ============================================================ +async def cronjobs_health(request: web.Request) -> web.Response: + manifest = _load_runtime_manifest(CRON_MANIFEST_PATH, "cronjobs") + return web.json_response({ + "status": "ok", + "service": "cronjobs_api", + "managed_cron_root": MANAGED_CRON_ROOT, + "managed_cronjobs_count": len(manifest.get("cronjobs", [])), + "last_sync_at": manifest.get("synced_at"), + }) + + +async def cronjobs_list(request: web.Request) -> web.Response: + if not _check_auth(request): + return web.json_response({"error": "unauthorized"}, status=401) + + ensure_dirs, load_jobs, _, _ = _load_cron_helpers() + ensure_dirs() + jobs = load_jobs() + managed_jobs = [job for job in jobs if job.get("managed_by") == "mika"] + manifest = _load_runtime_manifest(CRON_MANIFEST_PATH, "cronjobs") + + return web.json_response({ + "cronjobs": managed_jobs, + "manifest": manifest, + "managed_cron_root": MANAGED_CRON_ROOT, + "synced_at": manifest.get("synced_at"), + "agent_instance_id": manifest.get("agent_instance_id"), + }) + + +async def cronjobs_sync(request: web.Request) -> web.Response: + if not _check_auth(request, SKILLS_SYNC_TOKEN or API_SERVER_KEY): + return web.json_response({"error": "unauthorized"}, status=401) + + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid json"}, status=400) + + if not isinstance(payload, dict): + return web.json_response({"error": "payload must be a json object"}, status=400) + + incoming_jobs = payload.get("cronjobs", []) + if not isinstance(incoming_jobs, list): + return web.json_response({"error": "cronjobs must be a list"}, status=400) + + ensure_dirs, load_jobs, save_jobs, compute_next_run = _load_cron_helpers() + ensure_dirs() + os.makedirs(MANAGED_CRON_ROOT, exist_ok=True) + + existing_jobs = load_jobs() + existing_managed = { + str(job.get("id")): job + for job in existing_jobs + if job.get("managed_by") == "mika" + } + unmanaged_jobs = [job for job in existing_jobs if job.get("managed_by") != "mika"] + + synced_at = str(payload.get("synced_at") or "") + + runtime_jobs = [] + manifest_jobs = [] + skipped = 0 + + for raw_job in incoming_jobs: + if not isinstance(raw_job, dict): + skipped += 1 + continue + + job_id = str(raw_job.get("job_id") or "").strip() + existing_job = existing_managed.get(job_id) + runtime_job = _sanitize_cron_runtime_job(raw_job, existing_job, synced_at, compute_next_run) + if not runtime_job: + skipped += 1 + continue + + runtime_jobs.append(runtime_job) + manifest_jobs.append({ + "job_id": runtime_job["id"], + "name": runtime_job["name"], + "status": runtime_job["mika"]["status"], + "cron_expression": runtime_job["schedule"]["expr"], + "human_readable": runtime_job["schedule_display"], + "required_mcp_slugs": runtime_job["mika"]["required_mcp_slugs"], + "timezone": runtime_job["mika"]["timezone"], + "last_run_at": runtime_job.get("last_run_at"), + "next_run_at": runtime_job.get("next_run_at"), + "last_status": runtime_job.get("last_status"), + "last_error": runtime_job.get("last_error"), + "last_delivery_error": runtime_job.get("last_delivery_error"), + "source_updated_at": runtime_job["mika"].get("source_updated_at"), + "synced_at": runtime_job["mika"].get("synced_at"), + }) + + save_jobs(unmanaged_jobs + runtime_jobs) + + incoming_ids = {str(job.get("id")) for job in runtime_jobs} + removed = [ + job_id + for job_id in existing_managed.keys() + if job_id not in incoming_ids + ] + + manifest = { + "agent_instance_id": payload.get("agent_instance_id"), + "synced_at": payload.get("synced_at"), + "cronjobs": manifest_jobs, + } + _write_json(CRON_MANIFEST_PATH, manifest, mode=0o644) + + return web.json_response({ + "ok": True, + "received": len(incoming_jobs), + "written": len(runtime_jobs), + "skipped": skipped, + "removed": removed, + "managed_cron_root": MANAGED_CRON_ROOT, + }) + + +# ============================================================ +# Integrations API handlers +# ============================================================ +async def integrations_health(request: web.Request) -> web.Response: + manifest = _load_runtime_manifest(INTEGRATIONS_MANIFEST_PATH, "integrations") + return web.json_response({ + "status": "ok", + "service": "integrations_api", + "managed_integrations_root": MANAGED_INTEGRATIONS_ROOT, + "managed_integrations_count": len(manifest.get("integrations", [])), + "last_sync_at": manifest.get("synced_at"), + }) + + +async def integrations_list(request: web.Request) -> web.Response: + if not _check_auth(request): + return web.json_response({"error": "unauthorized"}, status=401) + + manifest = _load_runtime_manifest(INTEGRATIONS_MANIFEST_PATH, "integrations") + return web.json_response({ + "integrations": manifest.get("integrations", []), + "managed_integrations_root": MANAGED_INTEGRATIONS_ROOT, + "synced_at": manifest.get("synced_at"), + "agent_instance_id": manifest.get("agent_instance_id"), + "user_id": manifest.get("user_id"), + }) + + +async def integrations_sync(request: web.Request) -> web.Response: + if not _check_auth(request, SKILLS_SYNC_TOKEN or API_SERVER_KEY): + return web.json_response({"error": "unauthorized"}, status=401) + + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "invalid json"}, status=400) + + if not isinstance(payload, dict): + return web.json_response({"error": "payload must be a json object"}, status=400) + + incoming_integrations = payload.get("integrations", []) + if not isinstance(incoming_integrations, list): + return web.json_response({"error": "integrations must be a list"}, status=400) + + os.makedirs(MANAGED_INTEGRATIONS_ROOT, exist_ok=True) + + expected_files = set() + written_integrations = [] + skipped = 0 + + for raw_integration in incoming_integrations: + if not isinstance(raw_integration, dict): + skipped += 1 + continue + + integration_id = str(raw_integration.get("integration_id") or "").strip() + slug = str(raw_integration.get("slug") or "").strip() + name = str(raw_integration.get("name") or "").strip() + + if not integration_id or not slug or not name: + skipped += 1 + continue + + entry_name = _managed_entry_name(slug, integration_id) + full_path = os.path.join(MANAGED_INTEGRATIONS_ROOT, f"{entry_name}.json") + + full_record = dict(raw_integration) + full_record["managed_file"] = f"{entry_name}.json" + full_record["synced_at"] = payload.get("synced_at") + _write_json(full_path, full_record, mode=0o600) + + manifest_record = dict(full_record) + manifest_record.pop("access_token", None) + manifest_record.pop("refresh_token", None) + written_integrations.append(manifest_record) + expected_files.add(f"{entry_name}.json") + + removed = [] + if os.path.isdir(MANAGED_INTEGRATIONS_ROOT): + for entry in os.listdir(MANAGED_INTEGRATIONS_ROOT): + if entry == "manifest.json": + continue + full_path = os.path.join(MANAGED_INTEGRATIONS_ROOT, entry) + if os.path.isfile(full_path) and entry not in expected_files: + os.remove(full_path) + removed.append(entry) + + manifest = { + "agent_instance_id": payload.get("agent_instance_id"), + "user_id": payload.get("user_id"), + "synced_at": payload.get("synced_at"), + "integrations": written_integrations, + } + _write_json(INTEGRATIONS_MANIFEST_PATH, manifest, mode=0o600) + + return web.json_response({ + "ok": True, + "received": len(incoming_integrations), + "written": len(written_integrations), + "skipped": skipped, + "removed": removed, + "managed_integrations_root": MANAGED_INTEGRATIONS_ROOT, + }) # ============================================================ @@ -73,7 +574,6 @@ async def proxy(request: web.Request) -> web.StreamResponse: """Encaminha qualquer request para o Hermes interno.""" target_url = f"{HERMES_BASE}{request.rel_url}" - # Filtra headers hop-by-hop headers = { k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP @@ -90,7 +590,6 @@ async def proxy(request: web.Request) -> web.StreamResponse: data=body, allow_redirects=False, ) as upstream: - # Stream da resposta de volta resp_headers = { k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP @@ -115,16 +614,21 @@ async def proxy(request: web.Request) -> web.StreamResponse: # App setup # ============================================================ def make_app() -> web.Application: - app = web.Application(client_max_size=50 * 1024 * 1024) # 50MB + app = web.Application(client_max_size=50 * 1024 * 1024) - # Skills API (precedência sobre o proxy) app.router.add_get("/api/skills/health", skills_health) app.router.add_get("/api/skills", skills_list) app.router.add_post("/api/skills/sync", skills_sync) - # Proxy catch-all (qualquer outro path) - app.router.add_route("*", "/{tail:.*}", proxy) + app.router.add_get("/api/cronjobs/health", cronjobs_health) + app.router.add_get("/api/cronjobs", cronjobs_list) + app.router.add_post("/api/cronjobs/sync", cronjobs_sync) + app.router.add_get("/api/integrations/health", integrations_health) + app.router.add_get("/api/integrations", integrations_list) + app.router.add_post("/api/integrations/sync", integrations_sync) + + app.router.add_route("*", "/{tail:.*}", proxy) return app diff --git a/plugins/mika_runtime/__init__.py b/plugins/mika_runtime/__init__.py new file mode 100644 index 0000000..ca543d4 --- /dev/null +++ b/plugins/mika_runtime/__init__.py @@ -0,0 +1,44 @@ +"""Bundled Hermes plugin that exposes Mika-managed integrations as stable tools.""" + +from plugins.mika_runtime.tools import ( + CALCOM_API_SCHEMA, + INTEGRATIONS_STATUS_SCHEMA, + NOTION_API_SCHEMA, + TODOIST_API_SCHEMA, + handle_calcom_api, + handle_integrations_status, + handle_notion_api, + handle_todoist_api, +) + + +def register(ctx) -> None: + """Register Mika integration bridge tools.""" + ctx.register_tool( + name="mika_integrations_status", + toolset="mika_integrations", + schema=INTEGRATIONS_STATUS_SCHEMA, + handler=handle_integrations_status, + emoji="🔌", + ) + ctx.register_tool( + name="mika_notion_api", + toolset="mika_integrations", + schema=NOTION_API_SCHEMA, + handler=handle_notion_api, + emoji="🧠", + ) + ctx.register_tool( + name="mika_todoist_api", + toolset="mika_integrations", + schema=TODOIST_API_SCHEMA, + handler=handle_todoist_api, + emoji="✅", + ) + ctx.register_tool( + name="mika_calcom_api", + toolset="mika_integrations", + schema=CALCOM_API_SCHEMA, + handler=handle_calcom_api, + emoji="📅", + ) diff --git a/plugins/mika_runtime/plugin.yaml b/plugins/mika_runtime/plugin.yaml new file mode 100644 index 0000000..86282d7 --- /dev/null +++ b/plugins/mika_runtime/plugin.yaml @@ -0,0 +1,10 @@ +name: mika_runtime +version: 0.1.0 +description: "DOMCO Mika runtime integrations bridge for Notion, Todoist, and Cal.com using synced OAuth tokens." +author: DOMCO +kind: backend +provides_tools: + - mika_integrations_status + - mika_notion_api + - mika_todoist_api + - mika_calcom_api diff --git a/plugins/mika_runtime/tools.py b/plugins/mika_runtime/tools.py new file mode 100644 index 0000000..e11a84f --- /dev/null +++ b/plugins/mika_runtime/tools.py @@ -0,0 +1,506 @@ +"""Stable runtime bridge for integrations synced from Mika into Hermes.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Iterable, Tuple +from urllib import error, parse, request + +from hermes_constants import get_hermes_home + +SUPPORTED_SLUGS = ("notion", "todoist", "calcom") +MANAGED_INTEGRATIONS_ROOT = Path(get_hermes_home()) / "mika" / "integrations" +INTEGRATIONS_MANIFEST_PATH = MANAGED_INTEGRATIONS_ROOT / "manifest.json" +DEFAULT_NOTION_VERSION = "2022-06-28" +DEFAULT_CALCOM_VERSION = "2026-02-25" +MAX_RESPONSE_CHARS = 20000 +USER_AGENT = "domco-mika-runtime/0.1" + +INTEGRATIONS_STATUS_SCHEMA = { + "name": "mika_integrations_status", + "description": ( + "Lists the Mika integrations currently synced into this Hermes runtime. " + "Use it to confirm whether Notion, Todoist, or Cal.com are connected " + "before making provider-specific API calls." + ), + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "enum": list(SUPPORTED_SLUGS), + "description": "Optional provider slug to filter by.", + }, + }, + "additionalProperties": False, + }, +} + +NOTION_API_SCHEMA = { + "name": "mika_notion_api", + "description": ( + "Makes authenticated requests against the connected Notion workspace. " + "Useful for search, retrieving pages, creating pages, updating pages, " + "querying databases, and appending block children. Authorization and " + "Notion-Version headers are injected automatically." + ), + "parameters": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PATCH"], + "description": "HTTP method.", + }, + "path": { + "type": "string", + "description": ( + "Notion API path such as /v1/search, /v1/pages/, " + "/v1/pages, or /v1/blocks//children." + ), + }, + "query": { + "type": "object", + "description": "Optional query parameters appended to the URL.", + "additionalProperties": True, + }, + "body": { + "description": "Optional JSON request body for POST or PATCH requests.", + "anyOf": [{"type": "object"}, {"type": "array"}, {"type": "null"}], + }, + }, + "required": ["method", "path"], + "additionalProperties": False, + }, +} + +TODOIST_API_SCHEMA = { + "name": "mika_todoist_api", + "description": ( + "Makes authenticated requests against Todoist REST API v2 for the " + "connected account. Useful for tasks, projects, sections, labels, and " + "comments. Authorization is injected automatically." + ), + "parameters": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "DELETE"], + "description": "HTTP method.", + }, + "path": { + "type": "string", + "description": ( + "Todoist REST v2 path such as /tasks, /tasks/, " + "/tasks//close, /projects, /sections, or /comments." + ), + }, + "query": { + "type": "object", + "description": "Optional query parameters appended to the URL.", + "additionalProperties": True, + }, + "body": { + "description": "Optional JSON request body for POST requests.", + "anyOf": [{"type": "object"}, {"type": "array"}, {"type": "null"}], + }, + }, + "required": ["method", "path"], + "additionalProperties": False, + }, +} + +CALCOM_API_SCHEMA = { + "name": "mika_calcom_api", + "description": ( + "Makes authenticated requests against Cal.com API v2 for the connected " + "account. Useful for /v2/me, /v2/event-types, /v2/bookings, and related " + "resources. Authorization and cal-api-version headers are injected automatically." + ), + "parameters": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PATCH"], + "description": "HTTP method.", + }, + "path": { + "type": "string", + "description": ( + "Cal.com API path such as /v2/me, /v2/event-types, " + "/v2/event-types/, /v2/bookings, or /v2/bookings/." + ), + }, + "query": { + "type": "object", + "description": "Optional query parameters appended to the URL.", + "additionalProperties": True, + }, + "body": { + "description": "Optional JSON request body for POST or PATCH requests.", + "anyOf": [{"type": "object"}, {"type": "array"}, {"type": "null"}], + }, + }, + "required": ["method", "path"], + "additionalProperties": False, + }, +} + + +def _json_response(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + + +def _load_manifest() -> dict[str, Any]: + if not INTEGRATIONS_MANIFEST_PATH.exists(): + return {"integrations": []} + try: + return json.loads(INTEGRATIONS_MANIFEST_PATH.read_text(encoding="utf-8")) + except Exception as exc: + return { + "integrations": [], + "error": f"failed to read integrations manifest: {exc}", + } + + +def _normalize_slug(value: Any) -> str: + return str(value or "").strip().lower() + + +def _pick_active_integration(slug: str) -> Tuple[dict[str, Any] | None, str | None]: + manifest = _load_manifest() + manifest_integrations = manifest.get("integrations", []) + if not isinstance(manifest_integrations, list): + return None, "integrations manifest is malformed" + + matches = [ + item + for item in manifest_integrations + if isinstance(item, dict) + and _normalize_slug(item.get("slug")) == slug + ] + if not matches: + return None, f"integration '{slug}' is not synced into this runtime" + + active = [ + item for item in matches + if str(item.get("status") or "").strip().lower() == "active" + ] + if not active: + status = sorted({str(item.get("status") or "unknown") for item in matches}) + return None, ( + f"integration '{slug}' is synced but not active " + f"(current statuses: {', '.join(status)})" + ) + + chosen = sorted( + active, + key=lambda item: str(item.get("updated_at") or item.get("synced_at") or ""), + reverse=True, + )[0] + managed_file = str(chosen.get("managed_file") or "").strip() + if not managed_file: + return None, f"integration '{slug}' is missing managed_file metadata" + + record_path = MANAGED_INTEGRATIONS_ROOT / managed_file + if not record_path.exists(): + return None, ( + f"integration '{slug}' expected runtime file '{managed_file}', " + "but it does not exist" + ) + + try: + record = json.loads(record_path.read_text(encoding="utf-8")) + except Exception as exc: + return None, f"failed to read integration runtime file for '{slug}': {exc}" + + access_token = str(record.get("access_token") or "").strip() + if not access_token: + return None, f"integration '{slug}' has no access token in runtime storage" + + return record, None + + +def _redact_integration(item: dict[str, Any]) -> dict[str, Any]: + redacted = dict(item) + redacted.pop("access_token", None) + redacted.pop("refresh_token", None) + return redacted + + +def _coerce_query_pairs(value: Any) -> list[tuple[str, str]]: + if not isinstance(value, dict): + return [] + + pairs: list[tuple[str, str]] = [] + for key, raw_value in value.items(): + if raw_value is None: + continue + if isinstance(raw_value, (list, tuple)): + for entry in raw_value: + pairs.append((str(key), _stringify_query_value(entry))) + continue + pairs.append((str(key), _stringify_query_value(raw_value))) + return pairs + + +def _stringify_query_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "" + return str(value) + + +def _normalize_path(path: Any) -> str: + text = str(path or "").strip() + if not text: + raise ValueError("path is required") + if not text.startswith("/"): + text = f"/{text}" + return text + + +def _prepare_request( + *, + provider: str, + base_url: str, + allowed_methods: Iterable[str], + extra_headers: dict[str, str], + args: dict[str, Any], +) -> tuple[str, str, dict[str, str], bytes | None]: + method = str(args.get("method") or "").strip().upper() + if method not in set(allowed_methods): + raise ValueError( + f"method must be one of: {', '.join(sorted(set(allowed_methods)))}" + ) + + path = _normalize_path(args.get("path")) + query_pairs = _coerce_query_pairs(args.get("query")) + url = f"{base_url.rstrip('/')}{path}" + if query_pairs: + url = f"{url}?{parse.urlencode(query_pairs, doseq=True)}" + + body = args.get("body") + payload = None + headers = { + "Accept": "application/json", + "User-Agent": USER_AGENT, + **extra_headers, + } + + if body is not None: + if method == "GET": + raise ValueError(f"{provider} GET requests do not accept a JSON body") + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + + return method, url, headers, payload + + +def _truncate_text(text: str, limit: int = MAX_RESPONSE_CHARS) -> tuple[str, bool]: + if len(text) <= limit: + return text, False + return text[:limit], True + + +def _decode_response_body(content_type: str, raw_body: bytes) -> tuple[Any, bool]: + if not raw_body: + return None, False + + text = raw_body.decode("utf-8", errors="replace") + text, truncated = _truncate_text(text) + + if "application/json" in content_type.lower(): + try: + return json.loads(text), truncated + except Exception: + return text, truncated + return text, truncated + + +def _perform_request( + *, + provider: str, + integration: dict[str, Any], + base_url: str, + allowed_methods: Iterable[str], + extra_headers: dict[str, str], + args: dict[str, Any], +) -> str: + try: + method, url, headers, payload = _prepare_request( + provider=provider, + base_url=base_url, + allowed_methods=allowed_methods, + extra_headers=extra_headers, + args=args, + ) + except ValueError as exc: + return _json_response({ + "ok": False, + "provider": provider, + "error": str(exc), + }) + + req = request.Request( + url=url, + data=payload, + method=method, + headers=headers, + ) + + try: + with request.urlopen(req, timeout=45) as response: + raw_body = response.read() + content_type = response.headers.get("Content-Type", "") + decoded_body, truncated = _decode_response_body(content_type, raw_body) + return _json_response({ + "ok": True, + "provider": provider, + "integration": { + "slug": integration.get("slug"), + "name": integration.get("name"), + "status": integration.get("status"), + "connected_account_name": integration.get("connected_account_name"), + "connected_account_email": integration.get("connected_account_email"), + }, + "request": { + "method": method, + "url": url, + }, + "response": { + "status": response.status, + "content_type": content_type, + "truncated": truncated, + "body": decoded_body, + }, + }) + except error.HTTPError as exc: + raw_body = exc.read() + content_type = exc.headers.get("Content-Type", "") if exc.headers else "" + decoded_body, truncated = _decode_response_body(content_type, raw_body) + return _json_response({ + "ok": False, + "provider": provider, + "integration": { + "slug": integration.get("slug"), + "name": integration.get("name"), + "status": integration.get("status"), + "connected_account_name": integration.get("connected_account_name"), + "connected_account_email": integration.get("connected_account_email"), + }, + "request": { + "method": method, + "url": url, + }, + "response": { + "status": exc.code, + "content_type": content_type, + "truncated": truncated, + "body": decoded_body, + }, + }) + except Exception as exc: + return _json_response({ + "ok": False, + "provider": provider, + "request": { + "method": method, + "url": url, + }, + "error": str(exc), + }) + + +def handle_integrations_status(args: dict[str, Any], **_: Any) -> str: + slug = _normalize_slug(args.get("slug")) + manifest = _load_manifest() + integrations = manifest.get("integrations", []) + if not isinstance(integrations, list): + return _json_response({ + "ok": False, + "error": "integrations manifest is malformed", + }) + + filtered = [ + _redact_integration(item) + for item in integrations + if isinstance(item, dict) + and (not slug or _normalize_slug(item.get("slug")) == slug) + ] + + return _json_response({ + "ok": True, + "agent_instance_id": manifest.get("agent_instance_id"), + "user_id": manifest.get("user_id"), + "synced_at": manifest.get("synced_at"), + "integrations": filtered, + "available_tools": { + "notion": "mika_notion_api", + "todoist": "mika_todoist_api", + "calcom": "mika_calcom_api", + }, + }) + + +def handle_notion_api(args: dict[str, Any], **_: Any) -> str: + integration, err = _pick_active_integration("notion") + if err: + return _json_response({"ok": False, "provider": "notion", "error": err}) + + return _perform_request( + provider="notion", + integration=integration, + base_url="https://api.notion.com", + allowed_methods=("GET", "POST", "PATCH"), + extra_headers={ + "Authorization": f"Bearer {integration['access_token']}", + "Notion-Version": str( + integration.get("notion_version") + or DEFAULT_NOTION_VERSION + ), + }, + args=args, + ) + + +def handle_todoist_api(args: dict[str, Any], **_: Any) -> str: + integration, err = _pick_active_integration("todoist") + if err: + return _json_response({"ok": False, "provider": "todoist", "error": err}) + + return _perform_request( + provider="todoist", + integration=integration, + base_url="https://api.todoist.com/rest/v2", + allowed_methods=("GET", "POST", "DELETE"), + extra_headers={ + "Authorization": f"Bearer {integration['access_token']}", + }, + args=args, + ) + + +def handle_calcom_api(args: dict[str, Any], **_: Any) -> str: + integration, err = _pick_active_integration("calcom") + if err: + return _json_response({"ok": False, "provider": "calcom", "error": err}) + + return _perform_request( + provider="calcom", + integration=integration, + base_url="https://api.cal.com", + allowed_methods=("GET", "POST", "PATCH"), + extra_headers={ + "Authorization": f"Bearer {integration['access_token']}", + "cal-api-version": str( + integration.get("cal_api_version") + or DEFAULT_CALCOM_VERSION + ), + }, + args=args, + )