import contextlib
import ctypes
import itertools
import json
import logging
import os
import shutil
import subprocess
import sys
import threading
import types
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))
os.environ.setdefault("APHRODITE_DIRECTIVES_DIR", str(_PLUGIN_DIR / "directives"))
_STATE_MODULE_NAME = "aphrodite_hermes._process_state"
def _process_state() -> types.ModuleType:
holder = types.ModuleType(
_STATE_MODULE_NAME, "Process-global aphrodite dylib state shared by every loaded shim copy."
)
holder.dylib = None holder.dylib_mtime = 0.0 holder.dylib_copy_path = None holder.dylib_gen = itertools.count() holder.lock = threading.Lock() holder.atexit_registered = False try:
return sys.modules.setdefault(_STATE_MODULE_NAME, holder)
except Exception as e:
_log.warning("_process_state: sys.modules unavailable (%s); using a private holder", e)
return holder
_state = _process_state()
def _data_dir() -> Path:
try:
override = os.environ.get("APHRODITE_HOME")
if override:
return Path(override).expanduser()
return Path.home() / ".hermes" / "aphrodite"
except Exception as e:
_log.warning("_data_dir: %s; falling back to ~/.hermes/aphrodite", e)
return Path.home() / ".hermes" / "aphrodite"
def _hotreload_dir() -> str:
d = _data_dir() / "hotreload"
d.mkdir(parents=True, exist_ok=True)
return str(d)
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
if os.path.isdir(f"/proc/{pid}"):
return True
if sys.platform == "win32":
try:
import ctypes.wintypes as wt
SYNCHRONIZE = 0x00100000 PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 STILL_ACTIVE = 259 ERROR_ACCESS_DENIED = 5 k32 = ctypes.windll.kernel32
k32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
k32.OpenProcess.restype = wt.HANDLE
k32.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)]
k32.GetExitCodeProcess.restype = wt.BOOL
k32.CloseHandle.argtypes = [wt.HANDLE]
k32.CloseHandle.restype = wt.BOOL
h = k32.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return k32.GetLastError() == ERROR_ACCESS_DENIED
try:
code = wt.DWORD()
if k32.GetExitCodeProcess(h, ctypes.byref(code)):
return code.value == STILL_ACTIVE
return True
finally:
k32.CloseHandle(h)
except Exception:
_log.warning("_pid_alive: win32 probe failed for pid %s; treating as alive", pid)
return True
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
def _reap_stale_hotreloads() -> None:
try:
d = _hotreload_dir()
except Exception:
return
by_pid: dict[int, list[tuple[int, str]]] = {}
prefix = os.path.basename(_DYLIB_PATH)
try:
entries = os.listdir(d)
except OSError:
return
for name in entries:
if not name.startswith(prefix + "."):
continue
rest = name[len(prefix) + 1 :]
parts = rest.split(".")
if len(parts) != 2:
continue
try:
pid = int(parts[0])
gen = int(parts[1])
except ValueError:
continue
by_pid.setdefault(pid, []).append((gen, os.path.join(d, name)))
for pid, gens in by_pid.items():
if not _pid_alive(pid):
for _, path in gens:
with contextlib.suppress(OSError):
os.remove(path)
else:
gens.sort(reverse=True)
for _, path in gens[1:]:
with contextlib.suppress(OSError):
os.remove(path)
def _load_fresh_copy(src_path: str) -> str:
hotreload_dir = _hotreload_dir()
_reap_stale_hotreloads()
dst = os.path.join(
hotreload_dir, f"{os.path.basename(src_path)}.{os.getpid()}.{next(_state.dylib_gen)}"
)
shutil.copy2(src_path, dst)
return dst
def _dylib_candidates(plugin_dir: Path) -> list[str]:
plugin_dir = Path(plugin_dir).resolve()
candidates = [
_DYLIB_PATH, str(plugin_dir / "binaries" / _DYLIB_NAME),
str(plugin_dir.parent / "binaries" / _DYLIB_NAME),
]
parents = plugin_dir.parents
for depth in (2, 3):
if depth < len(parents):
candidates.append(str(parents[depth] / "target" / "release" / _DYLIB_NAME))
return candidates
def _load_dylib() -> ctypes.CDLL:
with _state.lock:
path = _DYLIB_PATH
candidates = _dylib_candidates(_PLUGIN_DIR)
for p in candidates:
if os.path.exists(p):
path = p
break
_ensure_binaries()
assert os.path.exists(path), f"Dylib not found. Tried: {candidates}"
current_mtime = os.path.getmtime(path)
if _state.dylib is not None and current_mtime == _state.dylib_mtime:
return _state.dylib
if _state.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",
_state.dylib_mtime,
current_mtime,
path,
)
load_path = _load_fresh_copy(path)
dylib = ctypes.CDLL(load_path)
if _state.dylib_copy_path is not None:
with contextlib.suppress(OSError):
os.remove(_state.dylib_copy_path)
_register_atexit_cleanup()
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
_state.dylib = dylib _state.dylib_mtime = current_mtime _state.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 _tail_log(path: Path, n: int = 15) -> str:
with contextlib.suppress(OSError):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
return "\n".join(lines[-n:])
return "(no stderr log yet)"
def _ensure_binaries() -> None:
if _env_bool("APHRODITE_NO_AUTO_DOWNLOAD"):
return
if os.path.exists(_BINARY_PATH) and os.path.exists(_DYLIB_PATH):
return
try:
result = subprocess.run(
["bash", str(_PLUGIN_DIR / "download.sh")],
timeout=180,
capture_output=True,
text=True,
errors="replace",
)
except Exception as e:
_log.warning(
"failed to run %s (%s) - run download.sh manually to fetch the aphrodite binaries",
_PLUGIN_DIR / "download.sh",
e,
)
return
if result.returncode != 0:
tail = "\n".join(((result.stdout or "") + (result.stderr or "")).splitlines()[-15:])
_log.warning(
"download.sh exited %d - run download.sh manually to fetch the "
"aphrodite binaries; output tail:\n%s",
result.returncode,
tail,
)
_health_opener: Any = None
def _proxy_healthy(port: int) -> bool:
import urllib.request
global _health_opener
if _health_opener is None:
try:
_health_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
except Exception as e:
_log.warning(
"_proxy_healthy: cannot build direct opener (%s); treating as unhealthy",
e,
)
return False
try:
req = urllib.request.Request(f"http://127.0.0.1:{port}/health", method="GET")
with _health_opener.open(req, timeout=0.5) as resp:
if resp.status != 200:
return False
body = resp.read(4096).decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except Exception:
return False
return isinstance(payload, dict) and payload.get("status") == "healthy"
except Exception:
return False
def _start_proxy():
import time
if os.environ.get("APHRODITE_NO_AUTO_LAUNCH", "0") in ("1", "true"):
_log.info("APHRODITE_NO_AUTO_LAUNCH set - skipping proxy auto-launch")
return
_ensure_binaries()
_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),
]
up: set[str] = {name for name, port in proxies if _proxy_healthy(port)}
if len(up) == len(proxies):
_log.info(
"aphrodite proxies already healthy on :%d/:%d - reusing the running "
"instance, skipping launch",
_cache_port,
_token_port,
)
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 = _data_dir()
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
_log.warning(
"aphrodite data dir %s unusable (%s); falling back to ~/.hermes/aphrodite",
log_dir,
e,
)
log_dir = Path.home() / ".hermes" / "aphrodite"
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError as e2:
_log.warning("cannot create %s either (%s) - skipping proxy launch", log_dir, e2)
return
try:
with open(log_dir / "proxy-stderr.log", "a") as stderr_log:
proc = subprocess.Popen(
[binary],
env=env,
stdout=subprocess.DEVNULL,
stderr=stderr_log,
cwd=os.getcwd(),
)
except Exception as e:
_log.warning("failed to start aphrodite proxy: %s", e)
return
rc = proc.poll()
if rc is None:
with contextlib.suppress(subprocess.TimeoutExpired):
rc = proc.wait(timeout=0.25)
if rc is not None:
_log.warning("aphrodite proxy exited immediately (rc=%s); last stderr:", rc)
tail = _tail_log(log_dir / "proxy-stderr.log")
_log.warning("%s", tail)
if "API key" in tail:
_log.warning(
"set APHRODITE_API_KEY env var, run `aphrodite setup`, or "
"add api_key to the TOML at ~/.hermes/aphrodite/aphrodite.toml"
)
return
_log.info("aphrodite proxy started (%s)", binary)
deadline = time.monotonic() + 5.0
up = set()
while time.monotonic() < deadline:
for name, port in proxies:
if name in up:
continue
if _proxy_healthy(port):
up.add(name)
_log.info("aphrodite %s proxy healthy on :%d", name, port)
if len(up) == len(proxies):
break
time.sleep(0.5)
for name, port in proxies:
if name not in up:
if proc.poll() is not None:
_log.warning(
"aphrodite proxy process exited early (rc=%s) while "
"waiting for %s on :%d; last stderr:",
proc.poll(),
name,
port,
)
tail = _tail_log(log_dir / "proxy-stderr.log")
_log.warning("%s", tail)
if "API key" in tail:
_log.warning(
"set APHRODITE_API_KEY env var, run `aphrodite setup`, "
"or add api_key to the TOML at "
"~/.hermes/aphrodite/aphrodite.toml"
)
else:
_log.warning(
"aphrodite %s proxy on :%d did not become healthy within 5s "
"- check %s for errors",
name,
port,
log_dir / "proxy-stderr.log",
)
def _register_atexit_cleanup() -> None:
if _state.atexit_registered:
return
_state.atexit_registered = True import atexit
def _cleanup() -> None:
if _state.dylib_copy_path is not None:
with contextlib.suppress(OSError):
os.remove(_state.dylib_copy_path)
_reap_stale_hotreloads()
atexit.register(_cleanup)
with contextlib.suppress(Exception):
_reap_stale_hotreloads()
def register(ctx: Any) -> None:
try:
dylib = _load_dylib()
except Exception as e:
_log.error(
"aphrodite-hermes dylib could not be loaded (%s) - plugin disabled; "
"run download.sh (or unset APHRODITE_NO_AUTO_DOWNLOAD) to fetch "
"the binaries, then restart Hermes",
e,
)
return
_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"] + [
p / "skills" for p in _PLUGIN_DIR.parents[1:3]
]
_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())