Bot & Automation

AI Assistants

/root/hermes-projects/AI Assistants

apps/whatsapp_control_api/app/services/hermes_admin.py text
from __future__ import annotations

import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path

from ..core.config import get_settings

SAFE_TOOL_NAME = re.compile(r"^[A-Za-z0-9_.:-]{1,100}$")
SAFE_SKILL_NAME = re.compile(r"^[A-Za-z0-9_.:/@+-]{1,300}$")


@dataclass(slots=True)
class CommandResult:
    ok: bool
    output: str


def list_installed_skills() -> list[dict]:
    skills_root = get_settings().hermes_workspace_root / ".hermes" / "skills"
    if not skills_root.exists():
        return []

    skills: list[dict] = []
    for skill_file in skills_root.rglob("SKILL.md"):
        if any(part.startswith(".") for part in skill_file.relative_to(skills_root).parts):
            continue
        relative = skill_file.relative_to(skills_root)
        category = relative.parts[0] if len(relative.parts) > 2 else "uncategorized"
        metadata = _read_skill_frontmatter(skill_file)
        skills.append(
            {
                "name": metadata.get("name") or skill_file.parent.name,
                "description": metadata.get("description") or "No description available.",
                "version": metadata.get("version") or "-",
                "category": category,
                "path": str(skill_file.parent),
            }
        )
    return sorted(skills, key=lambda item: (item["category"], item["name"].lower()))


def search_skills(query: str, limit: int = 10) -> list[dict]:
    cleaned = query.strip()
    if not cleaned or len(cleaned) > 120:
        return []
    result = run_hermes_command(["skills", "search", cleaned, "--limit", str(max(1, min(limit, 20))), "--json"], timeout=45)
    if not result.ok:
        return []
    try:
        data = json.loads(result.output)
    except json.JSONDecodeError:
        return []
    return [
        {
            "name": str(item.get("name") or ""),
            "identifier": str(item.get("identifier") or ""),
            "source": str(item.get("source") or ""),
            "trust_level": str(item.get("trust_level") or ""),
            "description": str(item.get("description") or ""),
        }
        for item in data
        if item.get("identifier")
    ]


def install_skill(identifier: str) -> CommandResult:
    cleaned = identifier.strip()
    if not SAFE_SKILL_NAME.fullmatch(cleaned):
        return CommandResult(False, "Skill identifier tidak valid.")
    return run_hermes_command(["skills", "install", cleaned, "--yes"], timeout=180)


def uninstall_skill(name: str) -> CommandResult:
    cleaned = name.strip()
    if not SAFE_SKILL_NAME.fullmatch(cleaned):
        return CommandResult(False, "Skill name tidak valid.")
    return run_hermes_command(["skills", "uninstall", cleaned], timeout=90)


def list_tools() -> list[dict]:
    result = run_hermes_command(["tools", "list", "--platform", "cli"], timeout=30)
    if not result.ok:
        return []
    tools: list[dict] = []
    pattern = re.compile(r"^\s*([✓✗])\s+(enabled|disabled)\s+([A-Za-z0-9_.:-]+)\s+(.*)$")
    for line in result.output.splitlines():
        match = pattern.match(line)
        if not match:
            continue
        tools.append(
            {
                "enabled": match.group(2) == "enabled",
                "name": match.group(3),
                "description": match.group(4).strip(),
            }
        )
    return tools


def set_tool_enabled(name: str, enabled: bool) -> CommandResult:
    cleaned = name.strip()
    if not SAFE_TOOL_NAME.fullmatch(cleaned):
        return CommandResult(False, "Tool name tidak valid.")
    action = "enable" if enabled else "disable"
    return run_hermes_command(["tools", action, "--platform", "cli", cleaned], timeout=60)


def hermes_overview() -> dict:
    status = run_hermes_command(["status", "--all"], timeout=45)
    memory = run_hermes_command(["memory", "status"], timeout=30)
    config = _redact_command_output(run_hermes_command(["config", "show"], timeout=30).output)
    return {
        "status": _redact_command_output(status.output) if status.output else "Status unavailable.",
        "memory": _redact_command_output(memory.output) if memory.output else "Memory status unavailable.",
        "config": config or "Config unavailable.",
        "storage": hermes_storage_overview(),
    }


def hermes_storage_overview() -> str:
    hermes_root = get_settings().hermes_workspace_root / ".hermes"
    sections = ("skills", "sessions", "memories", "logs", "hooks", "cron", "cache", "work-logs")
    lines = [f"Hermes home: {hermes_root}"]
    for name in sections:
        path = hermes_root / name
        file_count = 0
        total_size = 0
        if path.exists():
            for item in path.rglob("*"):
                if item.is_file() and not item.is_symlink():
                    try:
                        total_size += item.stat().st_size
                        file_count += 1
                    except OSError:
                        continue
        lines.append(f"{name}: {file_count} files, {_format_size(total_size)}")
    return "\n".join(lines)


def run_hermes_command(arguments: list[str], *, timeout: int) -> CommandResult:
    settings = get_settings()
    command = [settings.hermes_command, *arguments]
    try:
        completed = subprocess.run(
            command,
            cwd=settings.hermes_workspace_root,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
            env=None,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return CommandResult(False, f"Hermes command gagal: {exc}")
    output = "\n".join(part for part in (completed.stdout.strip(), completed.stderr.strip()) if part).strip()
    return CommandResult(completed.returncode == 0, _redact_command_output(output))


def _read_skill_frontmatter(skill_file: Path) -> dict[str, str]:
    text = skill_file.read_text(encoding="utf-8", errors="replace")
    if not text.startswith("---"):
        return {}
    parts = text.split("---", 2)
    if len(parts) < 3:
        return {}
    metadata: dict[str, str] = {}
    for line in parts[1].splitlines():
        if ":" not in line or line.startswith((" ", "\t")):
            continue
        key, value = line.split(":", 1)
        if key.strip() in {"name", "description", "version"}:
            metadata[key.strip()] = value.strip().strip("'\"")
    return metadata


def _redact_command_output(text: str) -> str:
    cleaned = re.sub(r"\b(sk-[A-Za-z0-9_-]{6})[A-Za-z0-9_-]+", r"\1...[redacted]", text)
    cleaned = re.sub(
        r"(?im)^(\s*(?:api[_ -]?key|token|secret|password)\s*[:=]\s*).+$",
        r"\1[redacted]",
        cleaned,
    )
    return cleaned[-30_000:]


def _format_size(size_bytes: int) -> str:
    size = float(max(0, size_bytes))
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024 or unit == "GB":
            return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} GB"