import ctypes
import json
import logging
import os
import subprocess
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from typing import Any
_log = logging.getLogger("aphrodite")
_PLUGIN_DIR = Path(__file__).resolve().parent
_DYLIB_NAME = "libaphrodite_hermes.dylib" if sys.platform == "darwin" else \
"libaphrodite_hermes.so" if sys.platform == "linux" else "aphrodite_hermes.dll"
_DYLIB_PATH = os.environ.get("APHRODITE_HERMES_DYLIB_PATH",
str(_PLUGIN_DIR / "binaries" / _DYLIB_NAME))
_BINARY_NAME = "aphrodite.exe" if sys.platform == "win32" else "aphrodite"
_BINARY_PATH = os.environ.get("APHRODITE_BINARY_PATH",
str(_PLUGIN_DIR / "binaries" / _BINARY_NAME))
_dylib: ctypes.CDLL | None = None
_dylib_mtime: float = 0.0
_dylib_lock = threading.Lock()
def _load_dylib() -> ctypes.CDLL:
global _dylib, _dylib_mtime
with _dylib_lock:
path = _DYLIB_PATH
candidates = [
path,
str(_PLUGIN_DIR / "binaries" / _DYLIB_NAME),
str(_PLUGIN_DIR.parent / "binaries" / _DYLIB_NAME),
]
if sys.platform == "darwin":
candidates.append(
str(Path(__file__).resolve().parents[3] / "target" / "release" / _DYLIB_NAME)
)
for p in candidates:
if os.path.exists(p):
path = p
break
assert os.path.exists(path), f"Dylib not found. Tried: {candidates}"
current_mtime = os.path.getmtime(path)
if _dylib is not None and current_mtime == _dylib_mtime:
return _dylib
if _dylib is not None:
_log.warning(
"dylib mtime changed (%.2f -> %.2f) - hot-reloading %s; "
"this resets ALL session CCR state - existing markers in "
"the transcript will no longer resolve via aphrodite_retrieve",
_dylib_mtime, current_mtime, path)
dylib = ctypes.CDLL(path)
try:
dylib.aphrodite_hermes_get_schemas.restype = ctypes.c_void_p
dylib.aphrodite_hermes_get_hooks.restype = ctypes.c_void_p
dylib.aphrodite_hermes_list_skills.restype = ctypes.c_void_p
dylib.aphrodite_hermes_dispatch_tool.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
dylib.aphrodite_hermes_dispatch_tool.restype = ctypes.c_void_p
dylib.aphrodite_hermes_call_hook.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
dylib.aphrodite_hermes_call_hook.restype = ctypes.c_void_p
dylib.aphrodite_hermes_proxy_health.restype = ctypes.c_void_p
dylib.aphrodite_hermes_version.restype = ctypes.c_void_p
dylib.aphrodite_hermes_free_string.argtypes = [ctypes.c_void_p]
except AttributeError as e:
raise RuntimeError(
f"dylib at {path} is missing an expected symbol ({e}) - "
f"it may be built from a different aphrodite-hermes version "
f"than this plugin expects"
) from e
_dylib = dylib
_dylib_mtime = current_mtime
return dylib
def _read_str(ptr: int | None) -> str | None:
if ptr is None or ptr == 0:
return None
value = ctypes.cast(ptr, ctypes.c_char_p).value
return value.decode("utf-8") if value else None
def _call_json(dylib: ctypes.CDLL, fn_name: str, *args: bytes) -> Any:
fn = getattr(dylib, fn_name)
ptr = fn(*args)
result = _read_str(ptr)
if ptr:
dylib.aphrodite_hermes_free_string(ptr)
return json.loads(result) if result else None
def _make_handler(tool_name: str) -> Callable[..., str]:
def handler(args: dict[str, Any] | None = None, **kwargs: Any) -> str:
args_json = json.dumps(args or {})
dylib = _load_dylib()
return json.dumps(_call_json(
dylib,
"aphrodite_hermes_dispatch_tool",
tool_name.encode("utf-8"),
args_json.encode("utf-8"),
))
return handler
def _check_version(dylib: ctypes.CDLL) -> None:
try:
loaded = _call_json(dylib, "aphrodite_hermes_version")
loaded_version = (loaded or {}).get("version") if isinstance(loaded, dict) else None
expected_path = _PLUGIN_DIR / "BINARY_VERSION"
expected_version = expected_path.read_text().strip() if expected_path.exists() else None
if loaded_version and expected_version and loaded_version != expected_version:
_log.warning(
"aphrodite-hermes dylib version mismatch: loaded %s, "
"BINARY_VERSION expects %s - the JSON contract (hook/tool "
"schemas) may have changed between these versions",
loaded_version, expected_version,
)
except Exception as e:
_log.debug("version handshake skipped: %s", e)
def _env_bool(var: str) -> bool:
return os.environ.get(var, "").lower() in ("1", "true")
def _parse_port_env(var: str, default: int) -> int:
raw = os.environ.get(var)
if raw is None:
return default
try:
return int(raw)
except ValueError:
_log.warning("%s=%r is not a valid port; falling back to %d", var, raw, default)
return default
def _start_proxy():
import time
import urllib.request
if os.environ.get("APHRODITE_NO_AUTO_LAUNCH", "0") in ("1", "true"):
_log.info("APHRODITE_NO_AUTO_LAUNCH set - skipping proxy auto-launch")
return
binary = _BINARY_PATH
if not os.path.exists(binary):
_log.warning("aphrodite binary not found at %s", binary)
return
if not os.access(binary, os.X_OK):
os.chmod(binary, 0o755)
env = os.environ.copy()
env.setdefault("APHRODITE_NO_AUTO_LAUNCH", "0")
log_dir = Path.home() / ".hermes" / "aphrodite"
log_dir.mkdir(parents=True, exist_ok=True)
stderr_log = open(log_dir / "proxy-stderr.log", "a")
try:
subprocess.Popen(
[binary],
env=env,
stdout=subprocess.DEVNULL,
stderr=stderr_log,
cwd=os.getcwd(),
)
_log.info("aphrodite proxy started (%s)", binary)
except Exception as e:
_log.warning("failed to start aphrodite proxy: %s", e)
stderr_log.close()
return
_cache_port = _parse_port_env("APHRODITE_CACHE_PORT", 9797)
_token_port = _parse_port_env("APHRODITE_TOKEN_PORT", 9798)
proxies = [
("cache", _cache_port),
("token", _token_port),
]
deadline = time.monotonic() + 5.0
up: set[str] = set()
while time.monotonic() < deadline:
for name, port in proxies:
if name in up:
continue
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/health",
method="GET",
)
with urllib.request.urlopen(req, timeout=0.5) as resp:
if resp.status == 200:
up.add(name)
_log.info("aphrodite %s proxy healthy on :%d", name, port)
except Exception:
pass
if len(up) == len(proxies):
break
time.sleep(0.5)
for name, port in proxies:
if name not in up:
_log.warning(
"aphrodite %s proxy on :%d did not become healthy within 5s "
"- check ~/.hermes/aphrodite/proxy-stderr.log for errors",
name,
port,
)
def register(ctx: Any) -> None:
dylib = _load_dylib()
_log.info("aphrodite-hermes dylib loaded: %s", _DYLIB_PATH)
_check_version(dylib)
hooks = _call_json(dylib, "aphrodite_hermes_get_hooks")
if hooks:
def _hook_dispatch(hook_name: str, **kwargs: Any) -> Any:
args_json = json.dumps(kwargs, default=str)
return _call_json(
_load_dylib(),
"aphrodite_hermes_call_hook",
hook_name.encode("utf-8"),
args_json.encode("utf-8"),
)
for hook_name in hooks:
def _dispatch(*a: Any, name: str = hook_name, **kw: Any) -> Any:
return _hook_dispatch(name, **kw)
ctx.register_hook(hook_name, _dispatch)
_log.info("registered %d hooks", len(hooks))
schemas = _call_json(dylib, "aphrodite_hermes_get_schemas")
if schemas:
registered: list[str] = []
for schema in schemas:
name = schema["name"]
try:
ctx.register_tool(name, "aphrodite", schema, _make_handler(name))
registered.append(name)
except Exception as e:
_log.warning("failed to register tool %s: %s", name, e)
_log.info("registered %d tools: %s", len(registered), registered)
_skills_dir = Path(__file__).resolve().parent.parent.parent / "skills"
skills = _call_json(dylib, "aphrodite_hermes_list_skills")
if skills:
count = 0
for skill in skills:
name = skill["name"]
desc = skill.get("description", "")
skill_path = _skills_dir / name / "SKILL.md"
reg_name = "".join(c if (c.isalnum() or c in "_-") else "-" for c in name)
if skill_path.exists():
try:
ctx.register_skill(reg_name, skill_path, desc)
count += 1
except Exception as e:
_log.warning("failed to register skill %s: %s", name, e)
_log.info("registered %d skills from %s", count, _skills_dir)
if _env_bool("APHRODITE_CONTEXT_ENGINE"):
try:
_register_context_engine(ctx, dylib)
except Exception as e:
_log.warning(
"context engine opt-in requested but not registered (%s); "
"falling back to hooks + proxy", e,
)
_start_proxy()
def _register_context_engine(ctx: Any, dylib: ctypes.CDLL) -> None:
import importlib
context_engine_cls = importlib.import_module("agent.context_engine").ContextEngine
class AphroditeContextEngine(context_engine_cls):
@property
def name(self) -> str:
return "aphrodite"
def update_from_response(self, usage: dict[str, Any]) -> None:
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
self.last_completion_tokens = usage.get("completion_tokens", 0)
self.last_total_tokens = usage.get("total_tokens", 0)
def should_compress(self, prompt_tokens: int | None = None) -> bool:
return False
def compress(
self,
messages: list[Any],
current_tokens: int | None = None,
focus_topic: str | None = None,
) -> list[Any]:
return messages
ctx.register_context_engine(AphroditeContextEngine())