From 96afd4f3ea8417293ff7895c49a80fd5f31b5eb9 Mon Sep 17 00:00:00 2001 From: "theresa.at" Date: Sat, 2 May 2026 00:04:18 +0800 Subject: [PATCH 1/2] docs: add Hermes Agent Cortex integration --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/README.md b/README.md index fab82a9..0787b4a 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Every memory, searchable. Every extraction, auditable. | **Claude Desktop** | Add MCP config → restart | | **Cursor / Windsurf** | Add MCP server in settings | | **Claude Code** | `claude mcp add cortex -- npx @cortexmem/mcp` | +| **Hermes Agent** | Native memory provider or MCP server | | **Any app** | REST API: `/api/v1/recall` + `/api/v1/ingest` | --- @@ -409,6 +410,50 @@ Settings → Developer → Edit Config: > Without auth: remove the `CORTEX_AUTH_TOKEN` line from `env`. + +### Hermes Agent + +Hermes Agent can connect to Cortex in two ways: + +1. **Native memory provider** for automatic pre-turn recall and post-turn ingestion. +2. **MCP server** for explicit `cortex_*` tools in sessions where you prefer MCP-only access. + +#### Native memory provider + +Set Hermes config: + +```bash +hermes config set memory.provider cortex +``` + +Add Cortex connection settings to the environment used by Hermes, typically `~/.hermes/.env`: + +```bash +CORTEX_URL=http://127.0.0.1:21100 +CORTEX_AUTH_TOKEN=your-token-here +CORTEX_AGENT_ID=hermes +# Optional, useful when multiple Hermes instances share the same Cortex server +CORTEX_PAIRING_CODE=your-instance-id +``` + +Restart Hermes after changing config or environment. Verify with: + +```bash +hermes memory status +``` + +Expected result: provider `cortex`, plugin installed, status available. + +#### MCP tools + +If you only want explicit Cortex tools, configure Cortex as an MCP server in Hermes: + +```bash +hermes mcp add cortex --command npx --args '@cortexmem/mcp --server-url http://127.0.0.1:21100' +``` + +For authenticated Cortex servers, include `CORTEX_AUTH_TOKEN` and `CORTEX_AGENT_ID` in the MCP server environment. After changing MCP config, restart Hermes or reload MCP if your current session supports it. + ### Other MCP Clients
diff --git a/README.zh-CN.md b/README.zh-CN.md index 0942b05..cee1352 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -152,6 +152,7 @@ Cortex 从你与记忆的互动中学习: | **Claude Desktop** | 添加 MCP 配置 → 重启 | | **Cursor / Windsurf** | 在设置中添加 MCP 服务器 | | **Claude Code** | `claude mcp add cortex -- npx @cortexmem/mcp` | +| **Hermes Agent** | 原生记忆提供商或 MCP 服务器 | | **任何应用** | REST API: `/api/v1/recall` + `/api/v1/ingest` | --- @@ -411,6 +412,50 @@ openclaw plugins install @cortexmem/openclaw > 不用认证:删除 `env` 中的 `CORTEX_AUTH_TOKEN` 行。 + +### Hermes Agent + +Hermes Agent 有两种方式接入 Cortex: + +1. **原生记忆提供商**:每轮对话前自动 recall,每轮对话后自动 ingest。 +2. **MCP 服务器**:只暴露显式 `cortex_*` 工具,适合只想按需调用的场景。 + +#### 原生记忆提供商 + +设置 Hermes 配置: + +```bash +hermes config set memory.provider cortex +``` + +把 Cortex 连接信息加入 Hermes 使用的环境文件,通常是 `~/.hermes/.env`: + +```bash +CORTEX_URL=http://127.0.0.1:21100 +CORTEX_AUTH_TOKEN=你的令牌 +CORTEX_AGENT_ID=hermes +# 可选:多个 Hermes 实例共用同一个 Cortex 服务时使用 +CORTEX_PAIRING_CODE=你的实例标识 +``` + +修改配置或环境变量后重启 Hermes。用下面命令验证: + +```bash +hermes memory status +``` + +预期结果:provider 为 `cortex`,plugin installed,status available。 + +#### MCP 工具 + +如果只需要显式 Cortex 工具,可以在 Hermes 里把 Cortex 配成 MCP 服务器: + +```bash +hermes mcp add cortex --command npx --args '@cortexmem/mcp --server-url http://127.0.0.1:21100' +``` + +如果 Cortex 服务启用了认证,需要在 MCP server 环境里加入 `CORTEX_AUTH_TOKEN` 和 `CORTEX_AGENT_ID`。修改 MCP 配置后,重启 Hermes,或在当前会话支持时 reload MCP。 + ### 其他 MCP 客户端
From 956ccbdec713f390cc68a699451dbb3a5327392e Mon Sep 17 00:00:00 2001 From: "theresa.at" Date: Sat, 2 May 2026 00:20:22 +0800 Subject: [PATCH 2/2] feat: add Hermes Agent Cortex provider reference --- integrations/hermes-agent/README.md | 51 ++ .../memory/cortex-provider/__init__.py | 555 ++++++++++++++++++ .../memory/cortex-provider/plugin.yaml | 3 + .../plugins/memory/test_cortex_provider.py | 333 +++++++++++ 4 files changed, 942 insertions(+) create mode 100644 integrations/hermes-agent/README.md create mode 100644 integrations/hermes-agent/plugins/memory/cortex-provider/__init__.py create mode 100644 integrations/hermes-agent/plugins/memory/cortex-provider/plugin.yaml create mode 100644 integrations/hermes-agent/tests/plugins/memory/test_cortex_provider.py diff --git a/integrations/hermes-agent/README.md b/integrations/hermes-agent/README.md new file mode 100644 index 0000000..9c79df0 --- /dev/null +++ b/integrations/hermes-agent/README.md @@ -0,0 +1,51 @@ +# Hermes Agent Cortex Provider + +Reference native memory provider for connecting Hermes Agent to Cortex through the Cortex REST API. + +## What it does + +- Enables `memory.provider: cortex` in Hermes Agent. +- Recalls Cortex memories before each turn through `POST /api/v1/recall`. +- Ingests completed turns after each response through `POST /api/v1/ingest`. +- Exposes explicit tools: `cortex_recall`, `cortex_remember`, `cortex_forget`, `cortex_search`, `cortex_relations`, and `cortex_stats`. + +## Install into Hermes Agent + +Copy the provider directory into your Hermes Agent checkout or Hermes plugin path: + +```bash +mkdir -p ~/.hermes/hermes-agent/plugins/memory +cp -R integrations/hermes-agent/plugins/memory/cortex-provider-provider ~/.hermes/hermes-agent/plugins/memory/cortex +``` + +Set Hermes config: + +```bash +hermes config set memory.provider cortex +``` + +Set environment variables for the Hermes process: + +```bash +CORTEX_URL=http://127.0.0.1:21100 +CORTEX_AUTH_TOKEN=*** +CORTEX_AGENT_ID=hermes +CORTEX_PAIRING_CODE=*** +``` + +Restart Hermes after changing config or environment. + +Verify: + +```bash +hermes memory status +``` + +Expected result: provider `cortex`, plugin installed, status available. + +## Notes + +- `CORTEX_URL` defaults to `http://127.0.0.1:21100`. +- Non-sensitive settings can also live in `$HERMES_HOME/cortex.json`. +- `CORTEX_AUTH_TOKEN` and `CORTEX_PAIRING_CODE` are read from environment variables. +- Subagents and non-primary contexts can recall memories, but cannot write or delete memories. diff --git a/integrations/hermes-agent/plugins/memory/cortex-provider/__init__.py b/integrations/hermes-agent/plugins/memory/cortex-provider/__init__.py new file mode 100644 index 0000000..3169967 --- /dev/null +++ b/integrations/hermes-agent/plugins/memory/cortex-provider/__init__.py @@ -0,0 +1,555 @@ +"""Cortex memory plugin — native Hermes MemoryProvider integration. + +Cortex is a local/remote universal AI agent memory service. This provider uses +Cortex REST APIs directly so Hermes can select it via ``memory.provider: cortex`` +and get automatic recall/write behavior without routing through MCP tools. + +Config via environment variables: + CORTEX_URL — Cortex base URL (default: http://127.0.0.1:21100) + CORTEX_AUTH_TOKEN — Optional bearer token + CORTEX_AGENT_ID — Cortex agent id (default: hermes) + CORTEX_PAIRING_CODE — Optional Cortex pairing code + +Non-secret settings may also live in ``$HERMES_HOME/cortex.json``. Keep secrets such as tokens in environment variables. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional + +from agent.memory_provider import MemoryProvider +from tools.registry import tool_error + +logger = logging.getLogger(__name__) + +_DEFAULT_URL = "http://127.0.0.1:21100" +_DEFAULT_AGENT_ID = "hermes" +_DEFAULT_TIMEOUT = 8.0 +_DEFAULT_RECALL_MAX_TOKENS = 4000 +_VALID_CATEGORIES = { + "identity", "preference", "decision", "fact", "entity", "correction", + "todo", "context", "summary", "skill", "relationship", "goal", + "insight", "project_state", "constraint", "policy", + "agent_self_improvement", "agent_user_habit", "agent_relationship", + "agent_persona", +} +_VALID_LAYERS = {"working", "core", "archive"} + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "y", "on"}: + return True + if lowered in {"0", "false", "no", "n", "off"}: + return False + return default + + +def _coerce_int(value: Any, default: int, minimum: int, maximum: int) -> int: + try: + return max(minimum, min(maximum, int(value))) + except Exception: + return default + + +def _coerce_float(value: Any, default: float, minimum: float, maximum: float) -> float: + try: + return max(minimum, min(maximum, float(value))) + except Exception: + return default + + +def _get_default_hermes_home() -> Path: + try: + from hermes_constants import get_hermes_home + return get_hermes_home() + except Exception: + return Path.home() / ".hermes" + + +def _load_config(hermes_home: Optional[str] = None) -> dict: + """Load Cortex provider config from env, overridden by cortex.json.""" + config = { + "url": os.environ.get("CORTEX_URL", _DEFAULT_URL), + "auth_token": os.environ.get("CORTEX_AUTH_TOKEN", ""), + "agent_id": os.environ.get("CORTEX_AGENT_ID", _DEFAULT_AGENT_ID), + "pairing_code": os.environ.get("CORTEX_PAIRING_CODE", ""), + "recall_max_tokens": os.environ.get("CORTEX_RECALL_MAX_TOKENS", _DEFAULT_RECALL_MAX_TOKENS), + "timeout": os.environ.get("CORTEX_TIMEOUT", _DEFAULT_TIMEOUT), + "debug": os.environ.get("CORTEX_DEBUG", "false"), + } + + home = Path(hermes_home) if hermes_home else _get_default_hermes_home() + config_path = home / "cortex.json" + if config_path.exists(): + try: + file_cfg = json.loads(config_path.read_text(encoding="utf-8")) + if isinstance(file_cfg, dict): + aliases = { + "cortex_url": "url", + "base_url": "url", + } + file_only_keys = {"url", "agent_id", "recall_max_tokens", "timeout", "debug"} + normalized = {} + for key, value in file_cfg.items(): + normalized_key = aliases.get(key, key) + if normalized_key in file_only_keys: + normalized[normalized_key] = value + config.update({k: v for k, v in normalized.items() if v is not None and v != ""}) + except Exception: + logger.debug("Failed to parse cortex.json", exc_info=True) + + url = str(config.get("url") or _DEFAULT_URL).strip().rstrip("/") or _DEFAULT_URL + config["url"] = url + config["auth_token"] = str(config.get("auth_token") or "").strip() + config["agent_id"] = str(config.get("agent_id") or _DEFAULT_AGENT_ID).strip() or _DEFAULT_AGENT_ID + config["pairing_code"] = str(config.get("pairing_code") or "").strip() + config["recall_max_tokens"] = _coerce_int( + config.get("recall_max_tokens"), _DEFAULT_RECALL_MAX_TOKENS, 1, 32000 + ) + config["timeout"] = _coerce_float(config.get("timeout"), _DEFAULT_TIMEOUT, 0.5, 60.0) + config["debug"] = _coerce_bool(config.get("debug"), False) + return config + + +class _CortexClient: + """Tiny urllib-based Cortex REST client.""" + + def __init__(self, base_url: str, auth_token: str = "", timeout: float = _DEFAULT_TIMEOUT): + self.base_url = (base_url or _DEFAULT_URL).rstrip("/") + self.auth_token = auth_token or "" + self.timeout = timeout + + def _request(self, method: str, path: str, payload: Optional[dict] = None, + query: Optional[dict] = None) -> dict: + url = f"{self.base_url}{path}" + if query: + clean_query = {k: v for k, v in query.items() if v not in (None, "", [])} + if clean_query: + url = f"{url}?{urllib.parse.urlencode(clean_query, doseq=True)}" + + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read().decode("utf-8") + if not raw: + return {} + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {"result": parsed} + + def post(self, path: str, payload: dict) -> dict: + return self._request("POST", path, payload=payload) + + def get(self, path: str, query: Optional[dict] = None) -> dict: + return self._request("GET", path, query=query) + + def delete(self, path: str) -> dict: + return self._request("DELETE", path) + + +def _error_message(exc: Exception) -> str: + if isinstance(exc, urllib.error.HTTPError): + return f"HTTP {exc.code}: {exc.reason}" + if isinstance(exc, urllib.error.URLError): + return str(exc.reason) + return str(exc) + + +def _clean_text(text: str) -> str: + return (text or "").strip() + + +def _is_trivial_exchange(user_content: str, assistant_content: str) -> bool: + combined = f"{user_content or ''} {assistant_content or ''}".strip().lower() + return combined in {"", "ok", "okay", "thanks", "thank you", "yes", "no", "k"} + + +RECALL_SCHEMA = { + "name": "cortex_recall", + "description": "Recall relevant long-term memories from Cortex.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query or current task context."}, + "max_tokens": {"type": "integer", "description": "Maximum context tokens to return."}, + "layers": { + "type": "array", + "items": {"type": "string", "enum": ["working", "core", "archive"]}, + "description": "Optional Cortex memory layers to search.", + }, + "skip_filters": {"type": "boolean", "description": "Skip Cortex relevance filters."}, + }, + "required": ["query"], + }, +} + +REMEMBER_SCHEMA = { + "name": "cortex_remember", + "description": "Store a durable explicit memory in Cortex.", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "Memory content to store."}, + "category": {"type": "string", "description": "Cortex memory category.", "enum": sorted(_VALID_CATEGORIES)}, + "layer": {"type": "string", "enum": ["working", "core", "archive"], "description": "Memory layer, default core."}, + "importance": {"type": "number", "description": "Importance from 0 to 1."}, + "confidence": {"type": "number", "description": "Confidence from 0 to 1."}, + }, + "required": ["content"], + }, +} + +FORGET_SCHEMA = { + "name": "cortex_forget", + "description": "Delete a Cortex memory by id.", + "parameters": { + "type": "object", + "properties": {"memory_id": {"type": "string", "description": "Cortex memory id to delete."}}, + "required": ["memory_id"], + }, +} + +SEARCH_SCHEMA = { + "name": "cortex_search", + "description": "Search Cortex memories and return raw ranked results.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "description": "Maximum result count."}, + "categories": {"type": "array", "items": {"type": "string"}}, + "layers": {"type": "array", "items": {"type": "string", "enum": ["working", "core", "archive"]}}, + "debug": {"type": "boolean"}, + }, + "required": ["query"], + }, +} + +RELATIONS_SCHEMA = { + "name": "cortex_relations", + "description": "List Cortex entity relationships.", + "parameters": { + "type": "object", + "properties": { + "subject": {"type": "string"}, + "object": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": [], + }, +} + +STATS_SCHEMA = { + "name": "cortex_stats", + "description": "Get Cortex memory service statistics.", + "parameters": {"type": "object", "properties": {}, "required": []}, +} + + +class CortexMemoryProvider(MemoryProvider): + """Native Cortex MemoryProvider using Cortex REST APIs.""" + + def __init__(self): + self._config: dict = {} + self._client: Optional[_CortexClient] = None + self._session_id = "" + self._agent_id = _DEFAULT_AGENT_ID + self._pairing_code = "" + self._agent_context = "primary" + self._recall_max_tokens = _DEFAULT_RECALL_MAX_TOKENS + self._prefetch_results: dict[str, str] = {} + self._prefetch_lock = threading.Lock() + self._prefetch_threads: dict[str, threading.Thread] = {} + self._sync_thread: Optional[threading.Thread] = None + self._write_thread: Optional[threading.Thread] = None + + @property + def name(self) -> str: + return "cortex" + + def is_available(self) -> bool: + cfg = _load_config() + return bool(cfg.get("url")) + + def save_config(self, values: Dict[str, Any], hermes_home: str) -> None: + config_path = Path(hermes_home) / "cortex.json" + existing = {} + if config_path.exists(): + try: + existing = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + existing = {} + secret_keys = {"auth_token", "pairing_code", "token", "api_key"} + for key in secret_keys: + existing.pop(key, None) + safe_values = {k: v for k, v in values.items() if k not in secret_keys} + existing.update(safe_values) + config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + os.chmod(config_path, 0o600) + except Exception: + logger.debug("Failed to chmod cortex.json", exc_info=True) + + def get_config_schema(self) -> List[Dict[str, Any]]: + return [ + {"key": "url", "description": "Cortex server URL", "default": _DEFAULT_URL}, + {"key": "auth_token", "description": "Cortex auth token", "secret": True, "required": False, "env_var": "CORTEX_AUTH_TOKEN"}, + {"key": "agent_id", "description": "Cortex agent id", "default": _DEFAULT_AGENT_ID}, + {"key": "pairing_code", "description": "Optional Cortex pairing code", "secret": True, "required": False, "env_var": "CORTEX_PAIRING_CODE"}, + {"key": "recall_max_tokens", "description": "Maximum tokens for automatic recall", "default": str(_DEFAULT_RECALL_MAX_TOKENS)}, + ] + + def initialize(self, session_id: str, **kwargs) -> None: + hermes_home = kwargs.get("hermes_home") + self._config = _load_config(hermes_home) + self._session_id = session_id or "" + self._agent_context = kwargs.get("agent_context") or "primary" + self._agent_id = self._resolve_agent_id(kwargs) + self._pairing_code = self._config.get("pairing_code", "") + self._recall_max_tokens = self._config.get("recall_max_tokens", _DEFAULT_RECALL_MAX_TOKENS) + self._client = _CortexClient( + self._config.get("url", _DEFAULT_URL), + self._config.get("auth_token", ""), + self._config.get("timeout", _DEFAULT_TIMEOUT), + ) + + def _resolve_agent_id(self, kwargs: Dict[str, Any]) -> str: + configured = self._config.get("agent_id") or "" + if configured and configured != _DEFAULT_AGENT_ID: + return configured + user_id = kwargs.get("user_id") + identity = kwargs.get("agent_identity") or self._config.get("agent_id") or _DEFAULT_AGENT_ID + if user_id: + return f"{identity}:{user_id}" + return configured or identity or _DEFAULT_AGENT_ID + + def system_prompt_block(self) -> str: + return ( + "# Cortex Memory\n" + f"Active. Agent ID: {self._agent_id}.\n" + "Use cortex_recall to search Cortex, cortex_remember to store durable facts, " + "and cortex_forget to remove memories by id." + ) + + def _client_or_raise(self) -> _CortexClient: + if self._client is None: + self._client = _CortexClient(_DEFAULT_URL) + return self._client + + def _recall_payload(self, query: str, **overrides) -> dict: + payload = { + "query": query, + "agent_id": self._agent_id, + "max_tokens": overrides.get("max_tokens", self._recall_max_tokens), + } + if self._pairing_code: + payload["pairing_code"] = self._pairing_code + layers = overrides.get("layers") + if layers: + payload["layers"] = [layer for layer in layers if layer in _VALID_LAYERS] + if overrides.get("skip_filters") is not None: + payload["skip_filters"] = bool(overrides.get("skip_filters")) + return payload + + def prefetch(self, query: str, *, session_id: str = "") -> str: + key = session_id or self._session_id or "default" + thread = self._prefetch_threads.get(key) + if thread and thread.is_alive(): + thread.join(timeout=3.0) + with self._prefetch_lock: + result = self._prefetch_results.pop(key, "") + if not result: + return "" + return f"## Cortex Memory\n{result}" + + def queue_prefetch(self, query: str, *, session_id: str = "") -> None: + query = _clean_text(query) + if not query: + return + key = session_id or self._session_id or "default" + + def _run(): + try: + response = self._client_or_raise().post("/api/v1/recall", self._recall_payload(query)) + context = _clean_text(str(response.get("context") or "")) + if context: + with self._prefetch_lock: + self._prefetch_results[key] = context + except Exception as exc: + logger.debug("Cortex prefetch failed: %s", _error_message(exc)) + + thread = threading.Thread(target=_run, daemon=True, name=f"cortex-prefetch-{key}") + self._prefetch_threads[key] = thread + thread.start() + + def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: + if self._agent_context != "primary": + return + user = _clean_text(user_content) + assistant = _clean_text(assistant_content) + if _is_trivial_exchange(user, assistant): + return + + payload = { + "user_message": user, + "assistant_message": assistant, + "messages": [ + {"role": "user", "content": user}, + {"role": "assistant", "content": assistant}, + ], + "agent_id": self._agent_id, + "session_id": session_id or self._session_id, + } + if self._pairing_code: + payload["pairing_code"] = self._pairing_code + + def _sync(): + try: + self._client_or_raise().post("/api/v1/ingest", payload) + except Exception as exc: + logger.warning("Cortex sync failed: %s", _error_message(exc)) + + if self._sync_thread and self._sync_thread.is_alive(): + self._sync_thread.join(timeout=5.0) + self._sync_thread = threading.Thread(target=_sync, daemon=True, name="cortex-sync") + self._sync_thread.start() + + def on_memory_write(self, action, target, content, metadata=None) -> None: + if self._agent_context != "primary": + return + if action not in {"add", "replace"}: + return + text = _clean_text(content) + if not text: + return + payload = self._memory_payload(text, metadata or {}) + payload["source"] = "hermes_builtin_memory" + + def _write(): + try: + self._client_or_raise().post("/api/v1/memories", payload) + except Exception as exc: + logger.debug("Cortex explicit memory mirror failed: %s", _error_message(exc)) + + if self._write_thread and self._write_thread.is_alive(): + self._write_thread.join(timeout=5.0) + self._write_thread = threading.Thread(target=_write, daemon=True, name="cortex-memory-write") + self._write_thread.start() + + def _memory_payload(self, content: str, args: Dict[str, Any]) -> dict: + category = str(args.get("category") or "fact") + if category not in _VALID_CATEGORIES: + category = "fact" + layer = str(args.get("layer") or "core") + if layer not in _VALID_LAYERS: + layer = "core" + payload = { + "layer": layer, + "category": category, + "content": content, + "agent_id": self._agent_id, + "importance": _coerce_float(args.get("importance"), 0.7, 0.0, 1.0), + "source": args.get("source") or "hermes_cortex_provider", + } + if args.get("confidence") is not None: + payload["confidence"] = _coerce_float(args.get("confidence"), 0.8, 0.0, 1.0) + return payload + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + return [RECALL_SCHEMA, REMEMBER_SCHEMA, FORGET_SCHEMA, SEARCH_SCHEMA, RELATIONS_SCHEMA, STATS_SCHEMA] + + def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: + args = args or {} + try: + if tool_name == "cortex_recall": + query = _clean_text(args.get("query", "")) + if not query: + return tool_error("Missing required parameter: query") + payload = self._recall_payload( + query, + max_tokens=args.get("max_tokens") or self._recall_max_tokens, + layers=args.get("layers"), + skip_filters=args.get("skip_filters"), + ) + return json.dumps(self._client_or_raise().post("/api/v1/recall", payload)) + + if tool_name == "cortex_remember": + if self._agent_context != "primary": + return tool_error("Cortex memory writes are disabled outside the primary agent context") + content = _clean_text(args.get("content", "")) + if not content: + return tool_error("Missing required parameter: content") + return json.dumps(self._client_or_raise().post("/api/v1/memories", self._memory_payload(content, args))) + + if tool_name == "cortex_forget": + if self._agent_context != "primary": + return tool_error("Cortex memory deletes are disabled outside the primary agent context") + memory_id = _clean_text(args.get("memory_id", "")) + if not memory_id: + return tool_error("Missing required parameter: memory_id") + safe_id = urllib.parse.quote(memory_id, safe="") + return json.dumps(self._client_or_raise().delete(f"/api/v1/memories/{safe_id}")) + + if tool_name == "cortex_search": + query = _clean_text(args.get("query", "")) + if not query: + return tool_error("Missing required parameter: query") + payload = { + "query": query, + "agent_id": self._agent_id, + "limit": _coerce_int(args.get("limit", 10), 10, 1, 100), + } + for key in ("layers", "categories", "debug"): + if args.get(key) not in (None, "", []): + payload[key] = args[key] + return json.dumps(self._client_or_raise().post("/api/v1/search", payload)) + + if tool_name == "cortex_relations": + query = {"agent_id": self._agent_id, "limit": _coerce_int(args.get("limit", 20), 20, 1, 200)} + if args.get("subject"): + query["subject"] = args["subject"] + if args.get("object"): + query["object"] = args["object"] + return json.dumps(self._client_or_raise().get("/api/v1/relations", query)) + + if tool_name == "cortex_stats": + return json.dumps(self._client_or_raise().get("/api/v1/stats")) + except Exception as exc: + return tool_error(_error_message(exc)) + + return tool_error(f"Unknown tool: {tool_name}") + + def shutdown(self) -> None: + for thread in list(self._prefetch_threads.values()): + if thread and thread.is_alive(): + thread.join(timeout=5.0) + self._prefetch_threads.clear() + self._prefetch_results.clear() + for attr in ("_sync_thread", "_write_thread"): + thread = getattr(self, attr) + if thread and thread.is_alive(): + thread.join(timeout=5.0) + setattr(self, attr, None) + + +def register(ctx) -> None: + """Register Cortex as a memory provider plugin.""" + ctx.register_memory_provider(CortexMemoryProvider()) diff --git a/integrations/hermes-agent/plugins/memory/cortex-provider/plugin.yaml b/integrations/hermes-agent/plugins/memory/cortex-provider/plugin.yaml new file mode 100644 index 0000000..b8b2538 --- /dev/null +++ b/integrations/hermes-agent/plugins/memory/cortex-provider/plugin.yaml @@ -0,0 +1,3 @@ +name: cortex +version: 1.0.0 +description: "Cortex — native Hermes MemoryProvider using Cortex REST APIs for automatic recall, turn ingestion, and explicit memory tools." diff --git a/integrations/hermes-agent/tests/plugins/memory/test_cortex_provider.py b/integrations/hermes-agent/tests/plugins/memory/test_cortex_provider.py new file mode 100644 index 0000000..ba62202 --- /dev/null +++ b/integrations/hermes-agent/tests/plugins/memory/test_cortex_provider.py @@ -0,0 +1,333 @@ +import json +import stat +import urllib.error + +import pytest + +from plugins.memory import discover_memory_providers, load_memory_provider +from plugins.memory.cortex import CortexMemoryProvider, _CortexClient, _load_config + + +class FakeResponse: + def __init__(self, payload, status=200): + self.payload = payload + self.status = status + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def test_load_config_defaults(monkeypatch, tmp_path): + monkeypatch.delenv("CORTEX_URL", raising=False) + monkeypatch.delenv("CORTEX_AUTH_TOKEN", raising=False) + monkeypatch.delenv("CORTEX_AGENT_ID", raising=False) + + cfg = _load_config(str(tmp_path)) + + assert cfg["url"] == "http://127.0.0.1:21100" + assert cfg["auth_token"] == "" + assert cfg["agent_id"] == "hermes" + assert cfg["recall_max_tokens"] == 4000 + + +def test_load_config_file_overrides_non_secret_env(monkeypatch, tmp_path): + monkeypatch.setenv("CORTEX_URL", "http://env.example") + monkeypatch.setenv("CORTEX_AUTH_TOKEN", "env-token") + monkeypatch.setenv("CORTEX_AGENT_ID", "env-agent") + monkeypatch.setenv("CORTEX_PAIRING_CODE", "env-pair") + (tmp_path / "cortex.json").write_text(json.dumps({ + "url": "http://file.example", + "auth_token": "file-token", + "token": "alias-token", + "api_key": "alias-api-key", + "agent_id": "file-agent", + "pairing_code": "file-pair", + "recall_max_tokens": 123, + })) + + cfg = _load_config(str(tmp_path)) + + assert cfg["url"] == "http://file.example" + assert cfg["auth_token"] == "env-token" + assert cfg["agent_id"] == "file-agent" + assert cfg["pairing_code"] == "env-pair" + assert cfg["recall_max_tokens"] == 123 + + + + +def test_schema_marks_secret_values_as_secret(): + provider = CortexMemoryProvider() + schema = {item["key"]: item for item in provider.get_config_schema()} + + assert schema["auth_token"]["secret"] is True + assert schema["auth_token"]["env_var"] == "CORTEX_AUTH_TOKEN" + assert schema["pairing_code"]["secret"] is True + assert schema["pairing_code"]["env_var"] == "CORTEX_PAIRING_CODE" + +def test_client_sends_json_and_auth_header(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["headers"] = dict(request.header_items()) + captured["body"] = json.loads(request.data.decode("utf-8")) + captured["timeout"] = timeout + return FakeResponse({"ok": True}) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + client = _CortexClient("http://cortex.local/", "secret-token", timeout=7) + result = client.post("/api/v1/recall", {"query": "hello"}) + + assert result == {"ok": True} + assert captured["url"] == "http://cortex.local/api/v1/recall" + assert captured["method"] == "POST" + assert captured["headers"]["Authorization"] == "Bearer secret-token" + assert captured["headers"]["Content-type"] == "application/json" + assert captured["body"] == {"query": "hello"} + assert captured["timeout"] == 7 + + +def test_plugin_discovery_and_load(monkeypatch): + monkeypatch.setenv("CORTEX_URL", "http://127.0.0.1:21100") + + providers = {name: available for name, _desc, available in discover_memory_providers()} + provider = load_memory_provider("cortex") + + assert providers["cortex"] is True + assert isinstance(provider, CortexMemoryProvider) + + +def test_provider_schema_names(): + provider = CortexMemoryProvider() + names = {schema["name"] for schema in provider.get_tool_schemas()} + + assert names == { + "cortex_recall", + "cortex_remember", + "cortex_forget", + "cortex_search", + "cortex_relations", + "cortex_stats", + } + + +def test_queue_prefetch_and_prefetch_formats_context(monkeypatch, tmp_path): + calls = [] + + class FakeClient: + def post(self, path, payload): + calls.append((path, payload)) + return {"context": "- Tsun uses Hermes", "count": 1, "memories": [{"id": "m1"}]} + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli") + provider._client = FakeClient() + + provider.queue_prefetch("Hermes memory", session_id="session-1") + provider._prefetch_threads["session-1"].join(timeout=1) + result = provider.prefetch("Hermes memory", session_id="session-1") + + assert calls == [("/api/v1/recall", { + "query": "Hermes memory", + "agent_id": "hermes", + "max_tokens": 4000, + })] + assert result == "## Cortex Memory\n- Tsun uses Hermes" + + +def test_prefetch_is_isolated_by_session(tmp_path): + class FakeClient: + def post(self, path, payload): + return {"context": f"memory for {payload['query']}"} + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="telegram") + provider._client = FakeClient() + + provider.queue_prefetch("session-a", session_id="a") + provider._prefetch_threads["a"].join(timeout=1) + provider.queue_prefetch("session-b", session_id="b") + provider._prefetch_threads["b"].join(timeout=1) + + assert provider.prefetch("ignored", session_id="a") == "## Cortex Memory\nmemory for session-a" + assert provider.prefetch("ignored", session_id="b") == "## Cortex Memory\nmemory for session-b" + + +def test_sync_turn_posts_to_ingest(monkeypatch, tmp_path): + calls = [] + + class FakeClient: + def post(self, path, payload): + calls.append((path, payload)) + return {"ok": True} + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli") + provider._client = FakeClient() + + provider.sync_turn("remember this", "stored", session_id="session-2") + provider._sync_thread.join(timeout=1) + + assert calls == [("/api/v1/ingest", { + "user_message": "remember this", + "assistant_message": "stored", + "messages": [ + {"role": "user", "content": "remember this"}, + {"role": "assistant", "content": "stored"}, + ], + "agent_id": "hermes", + "session_id": "session-2", + })] + + +def test_non_primary_agent_context_skips_writes(tmp_path): + class FakeClient: + def post(self, path, payload): # pragma: no cover - should not be called + raise AssertionError("non-primary context should not write") + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli", agent_context="subagent") + provider._client = FakeClient() + + provider.sync_turn("remember this", "stored", session_id="session-1") + provider.on_memory_write("add", "memory", "Tsun prefers direct answers") + + assert provider._sync_thread is None + assert provider._write_thread is None + + +def test_save_config_filters_secrets_and_sets_0600_permissions(tmp_path): + provider = CortexMemoryProvider() + config_path = tmp_path / "cortex.json" + config_path.write_text(json.dumps({"auth_token": "old-token", "pairing_code": "old-pair", "url": "old"})) + + provider.save_config({ + "url": "http://cortex.local", + "agent_id": "hermes-test", + "auth_token": "secret-token", + "pairing_code": "secret-pair", + }, str(tmp_path)) + + saved = json.loads(config_path.read_text()) + mode = stat.S_IMODE(config_path.stat().st_mode) + + assert saved == {"agent_id": "hermes-test", "url": "http://cortex.local"} + assert mode == 0o600 + + +def test_non_primary_context_blocks_write_and_delete_tools(tmp_path): + class FakeClient: + def post(self, path, payload): # pragma: no cover - should not be called + raise AssertionError("non-primary context should not write") + + def delete(self, path): # pragma: no cover - should not be called + raise AssertionError("non-primary context should not delete") + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli", agent_context="subagent") + provider._client = FakeClient() + + remember = json.loads(provider.handle_tool_call("cortex_remember", {"content": "do not write"})) + forget = json.loads(provider.handle_tool_call("cortex_forget", {"memory_id": "m1"})) + + assert "disabled outside the primary agent context" in remember["error"] + assert "disabled outside the primary agent context" in forget["error"] + + +def test_cortex_remember_tool_posts_memory(monkeypatch, tmp_path): + calls = [] + + class FakeClient: + def post(self, path, payload): + calls.append((path, payload)) + return {"id": "mem_1", "content": payload["content"]} + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli") + provider._client = FakeClient() + + result = json.loads(provider.handle_tool_call("cortex_remember", { + "content": "Tsun prefers direct answers", + "category": "preference", + "importance": 0.9, + })) + + assert result["id"] == "mem_1" + assert calls == [("/api/v1/memories", { + "layer": "core", + "category": "preference", + "content": "Tsun prefers direct answers", + "agent_id": "hermes", + "importance": 0.9, + "source": "hermes_cortex_provider", + })] + + +def test_forget_search_relations_and_stats_tools(tmp_path): + calls = [] + + class FakeClient: + def post(self, path, payload): + calls.append(("post", path, payload)) + return {"path": path, "payload": payload} + + def get(self, path, query=None): + calls.append(("get", path, query or {})) + return {"path": path, "query": query or {}} + + def delete(self, path): + calls.append(("delete", path, {})) + return {"ok": True} + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli") + provider._client = FakeClient() + + assert json.loads(provider.handle_tool_call("cortex_forget", {"memory_id": "m1"}))["ok"] is True + assert json.loads(provider.handle_tool_call("cortex_search", {"query": "Hermes", "limit": 3}))["path"] == "/api/v1/search" + assert json.loads(provider.handle_tool_call("cortex_relations", {"subject": "Hermes"}))["path"] == "/api/v1/relations" + assert json.loads(provider.handle_tool_call("cortex_stats", {}))["path"] == "/api/v1/stats" + + assert calls[0] == ("delete", "/api/v1/memories/m1", {}) + assert calls[1] == ("post", "/api/v1/search", {"query": "Hermes", "agent_id": "hermes", "limit": 3}) + assert calls[2] == ("get", "/api/v1/relations", {"agent_id": "hermes", "subject": "Hermes", "limit": 20}) + assert calls[3] == ("get", "/api/v1/stats", {}) + + + + +def test_http_error_message_does_not_return_response_body(): + err = urllib.error.HTTPError( + "http://cortex.local", + 401, + "Unauthorized", + {}, + None, + ) + + from plugins.memory.cortex import _error_message + + assert _error_message(err) == "HTTP 401: Unauthorized" + +def test_tool_errors_do_not_raise(tmp_path): + class FailingClient: + def post(self, path, payload): + raise urllib.error.URLError("down") + + provider = CortexMemoryProvider() + provider.initialize("session-1", hermes_home=str(tmp_path), platform="cli") + provider._client = FailingClient() + + result = json.loads(provider.handle_tool_call("cortex_recall", {"query": "Hermes"})) + + assert "error" in result + assert "down" in result["error"]