import contextlib
import ctypes
import itertools
import json
import logging
import os
import shutil
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_copy_path: str | None = None
_dylib_gen = itertools.count()
_dylib_lock = threading.Lock()
def _load_fresh_copy(src_path: str) -> str:
hotreload_dir = os.path.join(os.path.dirname(src_path), ".hotreload")
os.makedirs(hotreload_dir, exist_ok=True)
dst = os.path.join(
hotreload_dir, f"{os.path.basename(src_path)}.{os.getpid()}.{next(_dylib_gen)}"
)
shutil.copy2(src_path, dst)
return dst
def _load_dylib() -> ctypes.CDLL:
global _dylib, _dylib_mtime, _dylib_copy_path
with _dylib_lock:
path = _DYLIB_PATH
candidates = [
path,
str(_PLUGIN_DIR / "binaries" / _DYLIB_NAME),
str(_PLUGIN_DIR.parent / "binaries" / _DYLIB_NAME),
]
for depth in (2, 3):
candidates.append(
str(Path(__file__).resolve().parents[depth] / "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,
)
load_path = _load_fresh_copy(path)
dylib = ctypes.CDLL(load_path)
if _dylib_copy_path is not None:
with contextlib.suppress(OSError):
os.remove(_dylib_copy_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
_dylib_copy_path = load_path
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)
try:
with open(log_dir / "proxy-stderr.log", "a") as stderr_log:
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)
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_candidates = [
_PLUGIN_DIR / "skills",
_PLUGIN_DIR.parents[1] / "skills",
_PLUGIN_DIR.parents[2] / "skills",
]
_skills_dir = next((p for p in _skills_dir_candidates if p.is_dir()), None)
if _skills_dir is None:
_log.warning(
"no skills/ directory found (tried %s) - 0 skills will register",
_skills_dir_candidates,
)
_skills_dir = _skills_dir_candidates[0]
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())