diff --git a/README.md b/README.md index e294748..29fe38f 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,21 @@ obsidian-vault/Reviews/Queue/ integrations/hermes/memory-gateway/ ``` +安装或更新到本机 Hermes skill 目录: + +```bash +mkdir -p /home/tom/.hermes/skills/memory-gateway +rsync -a --delete \ + /home/tom/memory-gateway/integrations/hermes/memory-gateway/ \ + /home/tom/.hermes/skills/memory-gateway/ +``` + +使用方式: + +- Hermes 对话中可以加载 `memory-gateway` skill,让 agent 按 skill 文档主动调用脚本。 +- skill 不等于自动记忆;只有 agent 根据 skill/policy 主动调用脚本时才会写入或检索记忆。 +- 适合人工可控的显式操作:创建用户、检索记忆、追加 episode、commit session、上传知识和检查 EverMemOS。 + 主要脚本: ```text @@ -444,12 +459,109 @@ python /home/tom/.hermes/skills/memory-gateway/scripts/memory_search.py \ --limit 5 ``` +## Hermes / OpenClaw Agent Plugin + +通用 Agent plugin 位于: + +```text +plugins/memory-gateway-agent/ +``` + +它是独立 adapter,不 import Gateway 内部 `services/repositories/server`,所有调用都通过现有 `/v1` HTTP API。它面向 Hermes/OpenClaw 这类 agent runtime 暴露统一工具: + +- `memory_search` +- `memory_append_episode` +- `memory_commit_session` +- `memory_upsert` +- `memory_feedback` + +Hermes 本机安装: + +```bash +mkdir -p /home/tom/.hermes/plugins +ln -s /home/tom/memory-gateway/plugins/memory-gateway-agent \ + /home/tom/.hermes/plugins/memory-gateway-agent +hermes plugins enable memory-gateway-agent +hermes plugins list +hermes tools list +``` + +如果软链接已存在,先确认它指向当前仓库: + +```bash +ls -l /home/tom/.hermes/plugins/memory-gateway-agent +``` + +运行配置: + +```bash +export MEMORY_GATEWAY_URL=http://127.0.0.1:1934 +export MEMORY_GATEWAY_API_KEY= +export MEMORY_GATEWAY_DEFAULT_USER_ID=test_user_memory_gateway_plugin +export MEMORY_GATEWAY_DEFAULT_AGENT_ID=test_hermes_memory_gateway_plugin +export MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID=test_workspace_memory_gateway_plugin +export MEMORY_GATEWAY_AUTO_SEARCH=true +export MEMORY_GATEWAY_AUTO_APPEND_EPISODE=true +export MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false +``` + +Hermes plugin 已验证: + +- `hermes plugins list` 可发现并启用 `memory-gateway-agent`。 +- `hermes tools list` 可看到 `memory_gateway` toolset。 +- `pre_llm_call` 会自动检索 Memory Gateway。 +- `post_llm_call` 会按 policy 写入摘要型 candidate episode。 +- `on_session_end` 默认不会 commit;只有 `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=true` 才会 commit。 + +真实 Hermes chat 验证: + +```bash +PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \ +python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py +``` + +插件 E2E 验证: + +```bash +PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \ +python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/gateway_e2e_check.py + +PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \ +python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/hermes_hook_probe.py +``` + +清理测试数据: + +```bash +PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \ +python /home/tom/memory-gateway/plugins/memory-gateway-agent/scripts/cleanup_test_memories.py +``` + +安全边界: + +- plugin 不保存完整原始对话,只写摘要型 episode。 +- 默认拒绝 password、token、API key、cookie、private key、完整 transcript 和大段日志。 +- `memory_upsert` 是高风险长期记忆写入,不会自动触发。 +- 用户要求 forget/delete 时,应走 `memory_feedback` 或 delete 能力。 +- hook trace 默认关闭;需要排查时设置 `MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS=true`,只会写入 hook 名称、短 session id、Gateway action 和状态到 `plugins/memory-gateway-agent/.tmp/hook_trace.log`。 + +OpenClaw manifest 目前是 best-effort 草案: + +```text +plugins/memory-gateway-agent/openclaw.plugin.yaml +``` + +需要等 OpenClaw runtime 可用后再做第五阶段实测。 + ## 测试 ```bash cd /home/tom/memory-gateway source /home/tom/OpenViking/.venv/bin/activate PYTHONPATH=/home/tom/memory-gateway pytest -q + +PYTHONPATH=/home/tom/memory-gateway/plugins/memory-gateway-agent \ + pytest -q plugins/memory-gateway-agent/tests ``` 当前测试覆盖: diff --git a/plugins/memory-gateway-agent/README.md b/plugins/memory-gateway-agent/README.md new file mode 100644 index 0000000..e981d00 --- /dev/null +++ b/plugins/memory-gateway-agent/README.md @@ -0,0 +1,153 @@ +# Memory Gateway Agent Plugin + +This plugin is an adapter for the existing Memory Gateway. It is not Memory Gateway core and it does not import core service, repository, or server modules. + +The plugin calls the existing HTTP API: + +- `POST /v1/memory/search` +- `POST /v1/episodes` +- `POST /v1/sessions/{session_id}/commit` +- `POST /v1/memory` +- `POST /v1/memory/{memory_id}/feedback` + +## Configuration + +Environment variables: + +- `MEMORY_GATEWAY_URL`, default `http://127.0.0.1:1934` +- `MEMORY_GATEWAY_API_KEY`, optional +- `MEMORY_GATEWAY_DEFAULT_USER_ID`, optional +- `MEMORY_GATEWAY_DEFAULT_AGENT_ID`, optional +- `MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID`, optional +- `MEMORY_GATEWAY_AUTO_SEARCH`, default `true` +- `MEMORY_GATEWAY_AUTO_APPEND_EPISODE`, default `true` +- `MEMORY_GATEWAY_AUTO_COMMIT_SESSION`, default `false` +- `MEMORY_GATEWAY_REVIEW_MODE`, default `true` +- `MEMORY_GATEWAY_PLUGIN_DEBUG_RAW`, default `false` +- `MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS`, default `false` + +If an API key is configured, the plugin sends `X-API-Key`. It never logs the API key. + +## Validation Status + +| Check | Status | +| --- | --- | +| Mock unit tests | passed | +| Hermes plugin discovery | passed | +| Hermes tool registration | passed | +| Hermes hook registration | passed | +| Gateway E2E | passed | +| PluginManager.invoke_hook probe | passed | +| Real Hermes interactive session | passed | +| OpenClaw runtime validation | pending | + +## Hermes + +Use `hermes.plugin.yaml` as the Hermes-facing manifest. The entrypoint is: + +```text +__init__:register +``` + +The plugin attempts to register tools and best-effort hooks. If the Hermes runtime does not expose hook registration, it still works in tools-only mode. + +Install locally: + +```bash +mkdir -p ~/.hermes/plugins +ln -s /home/tom/memory-gateway/plugins/memory-gateway-agent ~/.hermes/plugins/memory-gateway-agent +hermes plugins enable memory-gateway-agent +hermes plugins list +hermes tools list +``` + +Example runtime configuration: + +```bash +export MEMORY_GATEWAY_URL=http://127.0.0.1:1934 +export MEMORY_GATEWAY_API_KEY= +export MEMORY_GATEWAY_AUTO_SEARCH=true +export MEMORY_GATEWAY_AUTO_APPEND_EPISODE=true +export MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false +``` + +## OpenClaw + +`openclaw.plugin.yaml` is a best-effort draft manifest. Adjust field names to the actual OpenClaw runtime schema before production use. + +## Tools-Only Mode + +Agent runtimes can call: + +- `memory_search` +- `memory_append_episode` +- `memory_commit_session` +- `memory_upsert` +- `memory_feedback` + +Tools-only mode does not automatically remember anything. The agent policy must decide when to call tools. + +## Lifecycle Hooks + +Best-effort hooks: + +- `on_session_start`: initializes session memory context without writing long-term memory. +- `pre_llm_call`: searches memory and returns compact memory context. +- `post_llm_call`: appends a safe candidate episode when policy allows it. +- `on_session_end`: commits session only when `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=true`. + +Plugin support for hooks depends on the agent runtime context API. This plugin should be described as tools-only plus best-effort hooks unless the target runtime has been verified. + +Verified boundaries: + +- Tools-only is usable through the registered `memory_gateway` toolset. +- `pre_llm_call` automatic search has passed hook-probe and real `hermes chat -Q -q` validation. +- `post_llm_call` automatic candidate episode append has passed hook-probe and real `hermes chat -Q -q` validation. +- `on_session_end` auto commit is off by default, stays off when `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false`, and commits when `MEMORY_GATEWAY_AUTO_COMMIT_SESSION=true`. + +## Defaults + +- Automatic search can be enabled. +- Automatic append episode can be enabled. +- Automatic commit is disabled by default. +- Automatic direct long-term upsert is disabled by default. + +## Safety And Privacy + +The plugin rejects memory writes containing passwords, API keys, bearer tokens, cookies, private keys, SSH keys, one-time verification codes, large logs, full raw transcripts, and chain-of-thought. + +The plugin writes summarized candidate episodes. It does not store full raw conversations. Long-term memory should normally be produced by `memory_commit_session`, allowing Memory Gateway and EverMemOS to deduplicate, detect conflicts, and route review drafts. + +Direct long-term `memory_upsert` is high risk and is not called automatically. If a user asks to forget or delete a memory, the agent should call `memory_feedback` or a delete-capable tool instead of silently keeping the memory. + +Script output is redacted by default: no API key, headers, cookies, tokens, or raw result payloads are printed. Set `MEMORY_GATEWAY_PLUGIN_DEBUG_RAW=true` only for local debugging with non-sensitive test data. + +Hook trace is disabled by default. Set `MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS=true` to write minimal hook events to `plugins/memory-gateway-agent/.tmp/hook_trace.log`. Trace entries contain hook name, timestamp, shortened session id, Gateway action, and status only; they do not include user or assistant message bodies. + +## Cleanup Test Data + +Integration tests use: + +- `user_id=test_user_memory_gateway_plugin` +- tags such as `integration_test`, `plugin`, and `safe_to_delete` + +Run cleanup: + +```bash +cd /home/tom/memory-gateway +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/cleanup_test_memories.py +``` + +The cleanup script refuses non-`test_user_` users. It first tries `DELETE /v1/memory/{memory_id}` for local test memories. If deletion fails, it falls back to `memory_feedback` with `incorrect`. Current cleanup is limited by the search API: it can only clean local `MemoryRecord` rows returned by search, not arbitrary OpenViking context rows. + +## Local Smoke Test + +```bash +cd /home/tom/memory-gateway +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/health.py +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/smoke_test.py +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/hermes_smoke_check.py +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/gateway_e2e_check.py +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/hermes_hook_probe.py +PYTHONPATH=plugins/memory-gateway-agent python plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py +``` diff --git a/plugins/memory-gateway-agent/__init__.py b/plugins/memory-gateway-agent/__init__.py new file mode 100644 index 0000000..215b78a --- /dev/null +++ b/plugins/memory-gateway-agent/__init__.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + +try: + from . import schemas, tools + from .memory_gateway_plugin.config import load_config + from .memory_gateway_plugin import lifecycle + from .memory_gateway_plugin.trace import trace_hook +except ImportError: + import sys + from pathlib import Path + + _PLUGIN_ROOT = Path(__file__).resolve().parent + if str(_PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(_PLUGIN_ROOT)) + + import schemas # type: ignore[no-redef] + import tools # type: ignore[no-redef] + from memory_gateway_plugin.config import load_config # type: ignore[no-redef] + from memory_gateway_plugin import lifecycle # type: ignore[no-redef] + from memory_gateway_plugin.trace import trace_hook # type: ignore[no-redef] + +TOOLSET = "memory_gateway" +_LAST_USER_MESSAGES: dict[str, str] = {} + + +def _context_from_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + cfg = load_config() + return { + "user_id": kwargs.get("user_id") or kwargs.get("user") or cfg.default_user_id, + "agent_id": kwargs.get("agent_id") or kwargs.get("agent") or cfg.default_agent_id or "hermes_agent", + "workspace_id": kwargs.get("workspace_id") or kwargs.get("workspace") or cfg.default_workspace_id, + "session_id": kwargs.get("session_id") or kwargs.get("task_id") or "", + "user_message": kwargs.get("user_message") or kwargs.get("prompt") or kwargs.get("message") or "", + "assistant_response": kwargs.get("assistant_response") or kwargs.get("response") or "", + "conversation_history": kwargs.get("conversation_history") or [], + "model": kwargs.get("model") or "", + "platform": kwargs.get("platform") or "", + } + + +def on_session_start(**kwargs: Any) -> dict[str, Any]: + session_id = kwargs.get("session_id") or kwargs.get("task_id") or "" + trace_hook("on_session_start", session_id=str(session_id), gateway_action="", gateway_called=False, ok=True) + return { + "status": "ok", + "memory_gateway": "session_initialized", + "session_id": session_id, + } + + +def pre_llm_call(**kwargs: Any) -> dict[str, str]: + context = _context_from_kwargs(kwargs) + if context.get("session_id") and context.get("user_message"): + _LAST_USER_MESSAGES[context["session_id"]] = context["user_message"] + result = lifecycle.on_conversation_start(context) + trace_hook( + "pre_llm_call", + session_id=context.get("session_id", ""), + gateway_action="memory_search", + gateway_called=bool(result.get("raw")), + ok=bool(result.get("ok")), + reason=str(result.get("error") or result.get("reason") or ""), + ) + if not result.get("ok") or not result.get("memory_context"): + return {} + return {"context": "Relevant Memory Gateway context:\n" + result["memory_context"]} + + +def post_llm_call(**kwargs: Any) -> dict[str, Any] | None: + context = _context_from_kwargs(kwargs) + if not context.get("user_message") and context.get("session_id"): + context["user_message"] = _LAST_USER_MESSAGES.get(context["session_id"], "") + result = lifecycle.after_user_message(context) + trace_hook( + "post_llm_call", + session_id=context.get("session_id", ""), + gateway_action="append_episode", + gateway_called=bool(result.get("raw")), + ok=bool(result.get("ok")), + reason=str(result.get("error") or result.get("reason") or ""), + ) + if result.get("ok"): + return None + return {"memory_gateway_error": result.get("error") or result.get("reason") or "append_failed"} + + +def on_session_end(**kwargs: Any) -> dict[str, Any] | None: + context = _context_from_kwargs(kwargs) + result = lifecycle.on_session_end(context) + trace_hook( + "on_session_end", + session_id=context.get("session_id", ""), + gateway_action="commit_session", + gateway_called=bool(result.get("raw")), + ok=bool(result.get("ok")), + reason=str(result.get("error") or result.get("reason") or ""), + ) + if context.get("session_id"): + _LAST_USER_MESSAGES.pop(context["session_id"], None) + if result.get("ok"): + return None + return {"memory_gateway_error": result.get("error") or "commit_failed"} + + +def register(ctx: Any) -> None: + for name, schema in schemas.TOOL_SCHEMAS.items(): + ctx.register_tool( + name=name, + toolset=TOOLSET, + schema=schema, + handler=tools.HANDLERS[name], + ) + + if hasattr(ctx, "register_hook"): + ctx.register_hook("on_session_start", on_session_start) + ctx.register_hook("pre_llm_call", pre_llm_call) + ctx.register_hook("post_llm_call", post_llm_call) + ctx.register_hook("on_session_end", on_session_end) diff --git a/plugins/memory-gateway-agent/hermes.plugin.yaml b/plugins/memory-gateway-agent/hermes.plugin.yaml new file mode 100644 index 0000000..e5a9358 --- /dev/null +++ b/plugins/memory-gateway-agent/hermes.plugin.yaml @@ -0,0 +1,41 @@ +name: memory-gateway-agent +runtime: hermes +version: 0.1.0 +description: Hermes plugin adapter for Memory Gateway v1. Provides tools-only mode plus best-effort lifecycle hooks. +entrypoint: register +provides_tools: + - memory_search + - memory_append_episode + - memory_commit_session + - memory_upsert + - memory_feedback +provides_hooks: + - on_session_start + - pre_llm_call + - post_llm_call + - on_session_end +env: + MEMORY_GATEWAY_URL: http://127.0.0.1:1934 + MEMORY_GATEWAY_AUTO_SEARCH: "true" + MEMORY_GATEWAY_AUTO_APPEND_EPISODE: "true" + MEMORY_GATEWAY_AUTO_COMMIT_SESSION: "false" +tools: + memory_search: + description: Search Memory Gateway with user/agent/workspace/session ACL. + memory_append_episode: + description: Append a safe summarized candidate episode. + memory_commit_session: + description: Ask Gateway/EverMemOS to consolidate session episodes. + memory_upsert: + description: Upsert a stable memory through Gateway. + memory_feedback: + description: Send feedback for a memory record. +hooks: + on_session_start: __init__:on_session_start + pre_llm_call: __init__:pre_llm_call + post_llm_call: __init__:post_llm_call + on_session_end: __init__:on_session_end +notes: + - Hooks are best-effort and depend on the Hermes runtime context API. + - Without hook support, the plugin remains usable as tools-only. + - This plugin does not store full raw conversations. diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/__init__.py b/plugins/memory-gateway-agent/memory_gateway_plugin/__init__.py new file mode 100644 index 0000000..8adb7bb --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/__init__.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +from typing import Any, Callable + +from . import lifecycle +from .tools import memory_append_episode, memory_commit_session, memory_feedback, memory_search, memory_upsert + +_logger = logging.getLogger(__name__) + +__all__ = [ + "register", + "memory_search", + "memory_append_episode", + "memory_commit_session", + "memory_upsert", + "memory_feedback", +] + + +TOOLS: dict[str, Callable[..., dict[str, Any]]] = { + "memory_search": memory_search, + "memory_append_episode": memory_append_episode, + "memory_commit_session": memory_commit_session, + "memory_upsert": memory_upsert, + "memory_feedback": memory_feedback, +} + + +def _try_call(target: Any, method_names: list[str], *args: Any, **kwargs: Any) -> bool: + for name in method_names: + method = getattr(target, name, None) + if callable(method): + try: + method(*args, **kwargs) + return True + except TypeError: + try: + method(args[0], args[1]) + return True + except Exception as exc: + _logger.debug("[_try_call] %s(%s, %s) failed: %s", name, args, kwargs, exc) + return False + except Exception as exc: + _logger.debug("[_try_call] %s(%s, %s) failed: %s", name, args, kwargs, exc) + return False + return False + + +def register(ctx: Any) -> dict[str, Any]: + registered_tools: list[str] = [] + registered_hooks: list[str] = [] + + for name, func in TOOLS.items(): + if _try_call(ctx, ["register_tool", "add_tool", "tool"], name, func): + registered_tools.append(name) + + hook_map = { + "pre_llm_call": lifecycle.on_conversation_start, + "post_llm_call": lifecycle.after_user_message, + "session_end": lifecycle.on_session_end, + "after_task_complete": lifecycle.after_task_complete, + } + for name, func in hook_map.items(): + if _try_call(ctx, ["register_hook", "add_hook", "hook"], name, func): + registered_hooks.append(name) + + return { + "ok": True, + "mode": "tools-and-hooks" if registered_hooks else "tools-only" if registered_tools else "manual", + "registered_tools": registered_tools, + "registered_hooks": registered_hooks, + } + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/client.py b/plugins/memory-gateway-agent/memory_gateway_plugin/client.py new file mode 100644 index 0000000..a6de036 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/client.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +import logging +import time +import urllib.error +import urllib.request +from typing import Any + +from .config import PluginConfig, load_config + +_logger = logging.getLogger(__name__) + + +def _short_error(value: Any, max_chars: int = 500) -> str: + text = str(value).replace("\n", " ").strip() + return text[:max_chars] + + +class MemoryGatewayClient: + def __init__(self, config: PluginConfig | None = None) -> None: + self.config = config or load_config() + + def _headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if self.config.api_key: + headers["X-API-Key"] = self.config.api_key + return headers + + def _post(self, endpoint: str, payload: dict[str, Any], retries: int = 3, backoff: float = 1.0) -> dict[str, Any]: + url = self.config.gateway_url.rstrip("/") + endpoint + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + last_error: Exception | None = None + for attempt in range(retries): + request = urllib.request.Request(url, data=body, headers=self._headers(), method="POST") + try: + with urllib.request.urlopen(request, timeout=self.config.timeout) as response: + raw = response.read().decode("utf-8") + data = json.loads(raw) if raw else {} + return { + "ok": True, + "status_code": getattr(response, "status", 200), + "endpoint": endpoint, + "data": data, + } + except urllib.error.HTTPError as exc: + # Typically, client errors (4xx) shouldn't be retried unless specifically handled. + # Since HTTPError is a subclass of URLError, we catch it first. + if exc.code < 500 and exc.code != 429: + try: + body_text = exc.read().decode("utf-8") + except Exception: + body_text = exc.reason + _logger.error(f"HTTPError in _post to {endpoint}: {exc.code} {body_text}") + return { + "ok": False, + "status_code": exc.code, + "endpoint": endpoint, + "error": _short_error(body_text), + } + last_error = exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_error = exc + except Exception as exc: + _logger.error("Unexpected error in _post to %s: %s", endpoint, exc, exc_info=True) + return { + "ok": False, + "status_code": None, + "endpoint": endpoint, + "error": _short_error(exc), + } + + if attempt < retries - 1: + time.sleep(backoff * (2 ** attempt)) + + # Exhausted retries + error_msg = str(last_error) if last_error else "Max retries exceeded" + _logger.error("Failed _post to %s after %d attempts. Last error: %s", endpoint, retries, last_error) + return { + "ok": False, + "status_code": None, + "endpoint": endpoint, + "error": error_msg, + } + + def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._post("/v1/memory/search", payload) + + def append_episode(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._post("/v1/episodes", payload) + + def commit_session(self, session_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._post(f"/v1/sessions/{session_id}/commit", payload) + + def upsert_memory(self, payload: dict[str, Any]) -> dict[str, Any]: + return self._post("/v1/memory", payload) + + def send_feedback(self, memory_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._post(f"/v1/memory/{memory_id}/feedback", payload) + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/config.py b/plugins/memory-gateway-agent/memory_gateway_plugin/config.py new file mode 100644 index 0000000..82f9162 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/config.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + + +def _env_bool(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class PluginConfig: + gateway_url: str = "http://127.0.0.1:1934" + api_key: str = "" + default_user_id: str = "" + default_agent_id: str = "" + default_workspace_id: str = "" + auto_search: bool = True + auto_append_episode: bool = True + auto_commit_session: bool = False + review_mode: bool = True + timeout: int = 30 + + @classmethod + def from_env(cls) -> "PluginConfig": + try: + timeout_val = int(os.environ.get("MEMORY_GATEWAY_TIMEOUT", "30")) + except ValueError: + timeout_val = 30 + + return cls( + gateway_url=os.environ.get("MEMORY_GATEWAY_URL", cls.gateway_url).rstrip("/"), + api_key=os.environ.get("MEMORY_GATEWAY_API_KEY", ""), + default_user_id=os.environ.get("MEMORY_GATEWAY_DEFAULT_USER_ID", ""), + default_agent_id=os.environ.get("MEMORY_GATEWAY_DEFAULT_AGENT_ID", ""), + default_workspace_id=os.environ.get("MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID", ""), + auto_search=_env_bool("MEMORY_GATEWAY_AUTO_SEARCH", True), + auto_append_episode=_env_bool("MEMORY_GATEWAY_AUTO_APPEND_EPISODE", True), + auto_commit_session=_env_bool("MEMORY_GATEWAY_AUTO_COMMIT_SESSION", False), + review_mode=_env_bool("MEMORY_GATEWAY_REVIEW_MODE", True), + timeout=timeout_val, + ) + + +def load_config() -> PluginConfig: + return PluginConfig.from_env() + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/lifecycle.py b/plugins/memory-gateway-agent/memory_gateway_plugin/lifecycle.py new file mode 100644 index 0000000..8b7ea25 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/lifecycle.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +from .client import MemoryGatewayClient +from .config import PluginConfig, load_config +from .policy import build_episode_summary, should_append_episode, should_commit_session, should_search_memory +from .tools import memory_append_episode, memory_commit_session, memory_search + + +def _get(context: dict[str, Any], key: str, default: str = "") -> str: + value = context.get(key, default) + return "" if value is None else str(value) + + +def compact_memory_context(search_result: dict[str, Any], limit: int = 5) -> str: + if not search_result.get("ok"): + return "" + data = search_result.get("data", {}) + rows = [] + for item in data.get("results", [])[:limit]: + memory = item.get("memory") or item.get("openviking") or {} + summary = memory.get("summary") or memory.get("abstract") or memory.get("content") or "" + namespace = memory.get("namespace", "") + memory_id = memory.get("id") or memory.get("uri") or "" + if summary: + rows.append(f"- {memory_id} [{namespace}]: {summary[:240]}") + return "\n".join(rows) + + +def on_conversation_start(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]: + cfg = config or load_config() + user_message = _get(context, "user_message") or _get(context, "query") + if not should_search_memory(user_message, context, cfg): + return {"ok": True, "memory_context": ""} + user_id = _get(context, "user_id", cfg.default_user_id) + if not user_id: + return {"ok": False, "error": "user_id_required"} + try: + limit_val = int(context.get("limit", 5)) + except (ValueError, TypeError): + limit_val = 5 + + result = memory_search( + query=user_message, + user_id=user_id, + agent_id=_get(context, "agent_id", cfg.default_agent_id), + workspace_id=_get(context, "workspace_id", cfg.default_workspace_id), + session_id=_get(context, "session_id"), + limit=limit_val, + client=client, + ) + return {"ok": result.get("ok", False), "memory_context": compact_memory_context(result), "raw": result} + + +def after_user_message(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]: + cfg = config or load_config() + user_message = _get(context, "user_message") + assistant_response = _get(context, "assistant_response") + if not should_append_episode(user_message, assistant_response, context, cfg): + return {"ok": True, "appended": False, "reason": "policy_skip"} + user_id = _get(context, "user_id", cfg.default_user_id) + session_id = _get(context, "session_id") + if not user_id or not session_id: + return {"ok": False, "error": "user_id_and_session_id_required"} + summary = build_episode_summary(user_message, assistant_response, context) + result = memory_append_episode( + user_id=user_id, + agent_id=_get(context, "agent_id", cfg.default_agent_id), + workspace_id=_get(context, "workspace_id", cfg.default_workspace_id), + session_id=session_id, + episode_summary=summary, + tags=["plugin-candidate"], + client=client, + ) + return {"ok": result.get("ok", False), "appended": result.get("ok", False), "raw": result} + + +def after_task_complete(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]: + return _maybe_commit(context, client, config) + + +def on_session_end(context: dict[str, Any], client: MemoryGatewayClient | None = None, config: PluginConfig | None = None) -> dict[str, Any]: + return _maybe_commit(context, client, config) + + +def _maybe_commit(context: dict[str, Any], client: MemoryGatewayClient | None, config: PluginConfig | None) -> dict[str, Any]: + cfg = config or load_config() + if not should_commit_session(context, cfg): + return {"ok": True, "committed": False, "reason": "auto_commit_disabled"} + user_id = _get(context, "user_id", cfg.default_user_id) + session_id = _get(context, "session_id") + if not user_id or not session_id: + return {"ok": False, "error": "user_id_and_session_id_required"} + try: + min_importance_val = float(context.get("min_importance", 0.6)) + except (ValueError, TypeError): + min_importance_val = 0.6 + + result = memory_commit_session( + user_id=user_id, + agent_id=_get(context, "agent_id", cfg.default_agent_id), + workspace_id=_get(context, "workspace_id", cfg.default_workspace_id), + session_id=session_id, + min_importance=min_importance_val, + client=client, + ) + return {"ok": result.get("ok", False), "committed": result.get("ok", False), "raw": result} + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/output.py b/plugins/memory-gateway-agent/memory_gateway_plugin/output.py new file mode 100644 index 0000000..202aed7 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/output.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import os +from typing import Any + + +SENSITIVE_KEYS = ("api_key", "apikey", "authorization", "token", "cookie", "secret", "password", "x-api-key") + + +def debug_raw_enabled() -> bool: + return os.environ.get("MEMORY_GATEWAY_PLUGIN_DEBUG_RAW", "").strip().lower() in {"1", "true", "yes", "on"} + + +def short_id(value: Any, prefix: int = 8, suffix: int = 4) -> str: + text = "" if value is None else str(value) + if len(text) <= prefix + suffix + 3: + return text + return f"{text[:prefix]}...{text[-suffix:]}" + + +import re + +def redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: ("" if key.lower() in SENSITIVE_KEYS else redact(item)) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, str): + lowered = value.lower() + sensitive_markers = ("api_key=", "password=", "token=", "bearer ", "cookie:", "private key") + if any(marker in lowered for marker in sensitive_markers): + return "" + return value + + +def summarize_data(data: Any) -> Any: + if debug_raw_enabled(): + return redact(data) + if isinstance(data, list): + return {"count": len(data)} + if not isinstance(data, dict): + return data + if "results" in data: + return { + "count": len(data.get("results") or []), + "total": data.get("total"), + "local_total": data.get("local_total"), + "openviking_total": data.get("openviking_total"), + "searched_namespaces": data.get("searched_namespaces", []), + } + if "id" in data: + return { + "id": short_id(data.get("id")), + "namespace": data.get("namespace"), + "memory_type": data.get("memory_type"), + "source": data.get("source"), + } + if "memory_id" in data: + return {"status": data.get("status"), "memory_id": short_id(data.get("memory_id")), "feedback": data.get("feedback")} + if "promoted" in data or "consolidation" in data: + return { + "status": data.get("status"), + "promoted_count": len(data.get("promoted") or []), + "archived_count": len(data.get("archived_episode_ids") or []), + "consolidation_status": (data.get("consolidation") or {}).get("status") if isinstance(data.get("consolidation"), dict) else None, + } + allowed = {"ok", "status", "gateway", "service", "version", "healthy", "endpoint", "status_code", "error", "count"} + return {key: redact(value) for key, value in data.items() if key in allowed} + + +def summarize_result(result: dict[str, Any]) -> dict[str, Any]: + return { + "ok": bool(result.get("ok")), + "endpoint": result.get("endpoint"), + "status_code": result.get("status_code"), + "error": redact(result.get("error", "")), + "data": summarize_data(result.get("data")), + } + + +def dumps_safe(payload: Any, *, indent: int = 2) -> str: + return json.dumps(redact(payload), ensure_ascii=False, indent=indent, default=str) diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/policy.py b/plugins/memory-gateway-agent/memory_gateway_plugin/policy.py new file mode 100644 index 0000000..494b2f3 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/policy.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from typing import Any + +from .config import PluginConfig, load_config +from .safety import validate_memory_write + + +REMEMBER_RE = re.compile(r"记住|请保存|remember this|save this|keep in memory", re.I) +STABLE_SIGNAL_RE = re.compile( + r"偏好|长期|约束|架构决策|决策|结论|workflow|工作流|preference|constraint|decision|always|以后都|project fact", + re.I, +) +SMALL_TALK_RE = re.compile(r"^\s*(你好|hi|hello|谢谢|thanks|ok|好的|收到|再见)[。.!!\s\w]*$", re.I) + + +def should_search_memory(user_message: str, context: dict[str, Any] | None = None, config: PluginConfig | None = None) -> bool: + cfg = config or load_config() + if not cfg.auto_search: + return False + return bool(user_message and user_message.strip()) + + +def should_append_episode( + user_message: str, + assistant_response: str = "", + context: dict[str, Any] | None = None, + config: PluginConfig | None = None, +) -> bool: + cfg = config or load_config() + if not cfg.auto_append_episode: + return False + combined = "\n".join(part for part in [user_message, assistant_response] if part) + if not combined.strip() or SMALL_TALK_RE.match(combined.strip()): + return False + if not validate_memory_write(combined)["allowed"]: + return False + return bool(REMEMBER_RE.search(combined) or STABLE_SIGNAL_RE.search(combined)) + + +def build_episode_summary(user_message: str, assistant_response: str = "", context: dict[str, Any] | None = None) -> str: + parts = [] + if REMEMBER_RE.search(user_message or ""): + parts.append(f"用户明确要求记住:{user_message.strip()}") + elif user_message: + parts.append(f"用户输入中的可复用信息:{user_message.strip()}") + if assistant_response and STABLE_SIGNAL_RE.search(assistant_response): + parts.append(f"助手结论:{assistant_response.strip()}") + summary = " ".join(parts).strip() + return summary[:1000] + + +def should_commit_session(context: dict[str, Any] | None = None, config: PluginConfig | None = None) -> bool: + cfg = config or load_config() + if cfg.auto_commit_session: + return True + return bool((context or {}).get("force_commit")) + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/safety.py b/plugins/memory-gateway-agent/memory_gateway_plugin/safety.py new file mode 100644 index 0000000..9745ea8 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/safety.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import re +from typing import Any + + +SECRET_PATTERNS = [ + r"\bpassword\s*[:=]", + r"\bapi[_-]?key\s*[:=]", + r"\btoken\s*[:=]", + r"\bsecret\s*[:=]", + r"\bbearer\s+[a-z0-9._\-]{12,}", + r"\bcookie\s*[:=]", + r"\bsession[_ -]?id\s*[:=]", + r"-----BEGIN [A-Z ]*PRIVATE KEY-----", + r"\bssh-rsa\s+[a-z0-9+/=]{40,}", + r"\bone[- ]?time (?:password|code)\b", + r"\botp\s*[:=]?\s*\d{4,8}\b", + r"\b验证码\s*[::]?\s*\d{4,8}\b", +] + +CHAT_LINE_RE = re.compile(r"^\s*(user|assistant|system|用户|助手|模型|human|ai)\s*[::]", re.I) +LOG_LINE_RE = re.compile(r"\b(ERROR|WARN|INFO|DEBUG|TRACE)\b|^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}") +CHAIN_OF_THOUGHT_RE = re.compile(r"chain[- ]of[- ]thought|逐步推理|隐藏推理|internal reasoning", re.I) + + +def detect_secret(content: str) -> tuple[bool, str]: + for pattern in SECRET_PATTERNS: + if re.search(pattern, content, re.I): + return True, "secret_like_content" + return False, "" + + +def detect_raw_transcript(content: str) -> tuple[bool, str]: + lines = [line for line in content.splitlines() if line.strip()] + chat_lines = sum(1 for line in lines if CHAT_LINE_RE.search(line)) + if chat_lines >= 4: + return True, "raw_chat_transcript" + if "完整原始对话" in content or "full transcript" in content.lower(): + return True, "raw_chat_transcript" + return False, "" + + +def detect_large_log(content: str) -> tuple[bool, str]: + lines = [line for line in content.splitlines() if line.strip()] + log_lines = sum(1 for line in lines if LOG_LINE_RE.search(line)) + if len(content) > 4000 or len(lines) > 40 or log_lines >= 8: + return True, "large_or_raw_log" + return False, "" + + +def detect_low_value_memory(content: str) -> tuple[bool, str]: + normalized = re.sub(r"\s+", " ", content).strip().lower() + stable_signal = re.search(r"记住|偏好|长期|决策|结论|约束|preference|remember|decision|constraint", normalized, re.I) + if stable_signal: + return False, "" + if len(normalized) < 12: + return True, "too_short" + small_talk = { + "hi", + "hello", + "thanks", + "thank you", + "ok", + "好的", + "谢谢", + "你好", + "收到", + "再见", + } + if normalized in small_talk: + return True, "small_talk" + return False, "" + + +def sanitize_memory_content(content: str) -> str: + sanitized = content.strip() + sanitized = re.sub(r"\b(password|api[_-]?key|token|secret)\s*[:=]\s*\S+", r"\1=", sanitized, flags=re.I) + sanitized = re.sub(r"\bbearer\s+[a-z0-9._\-]{12,}", "Bearer ", sanitized, flags=re.I) + sanitized = re.sub(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", "", sanitized, flags=re.I | re.S) + return sanitized + + +def validate_memory_write(content: str, *, allow_low_value: bool = False) -> dict[str, Any]: + if not content or not content.strip(): + return {"allowed": False, "reason": "empty_content", "sanitized_content": ""} + checks = [detect_secret, detect_raw_transcript, detect_large_log] + for check in checks: + blocked, reason = check(content) + if blocked: + return {"allowed": False, "reason": reason, "sanitized_content": ""} + if CHAIN_OF_THOUGHT_RE.search(content): + return {"allowed": False, "reason": "chain_of_thought", "sanitized_content": ""} + low_value, reason = detect_low_value_memory(content) + if low_value and not allow_low_value: + return {"allowed": False, "reason": reason, "sanitized_content": ""} + return {"allowed": True, "reason": "ok", "sanitized_content": sanitize_memory_content(content)} diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/schemas.py b/plugins/memory-gateway-agent/memory_gateway_plugin/schemas.py new file mode 100644 index 0000000..7207a9f --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/schemas.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class AgentContext: + user_id: str + agent_id: str = "" + workspace_id: str = "" + session_id: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/tools.py b/plugins/memory-gateway-agent/memory_gateway_plugin/tools.py new file mode 100644 index 0000000..3417152 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/tools.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import Any + +from .client import MemoryGatewayClient +from .config import PluginConfig, load_config +from .safety import validate_memory_write + + +FEEDBACK_MAP = { + "confirm": "useful", + "correct": "useful", + "useful": "useful", + "delete": "incorrect", + "reject": "incorrect", + "incorrect": "incorrect", + "duplicate": "duplicate", + "outdated": "outdated", + "not_useful": "not_useful", +} + + +def _client(client: MemoryGatewayClient | None = None) -> MemoryGatewayClient: + return client or MemoryGatewayClient() + + +def _context_payload(user_id: str, agent_id: str = "", workspace_id: str = "", session_id: str = "") -> dict[str, Any]: + payload: dict[str, Any] = {"user_id": user_id} + if agent_id: + payload["agent_id"] = agent_id + if workspace_id: + payload["workspace_id"] = workspace_id + if session_id: + payload["session_id"] = session_id + return payload + + +def memory_search( + query: str, + user_id: str, + agent_id: str = "", + workspace_id: str = "", + session_id: str = "", + namespaces: list[str] | None = None, + memory_types: list[str] | None = None, + tags: list[str] | None = None, + limit: int = 5, + client: MemoryGatewayClient | None = None, +) -> dict[str, Any]: + if not query or not query.strip(): + return {"ok": False, "error": "query_required"} + payload = _context_payload(user_id, agent_id, workspace_id, session_id) + payload.update( + { + "query": query.strip(), + "namespaces": namespaces or [], + "memory_types": memory_types or [], + "tags": tags or [], + "limit": limit, + } + ) + return _client(client).search_memory(payload) + + +def memory_append_episode( + user_id: str, + agent_id: str, + session_id: str, + content: str = "", + episode_summary: str = "", + workspace_id: str = "", + source: str = "conversation", + tags: list[str] | None = None, + importance: float | None = None, + confidence: float | None = None, + client: MemoryGatewayClient | None = None, +) -> dict[str, Any]: + candidate = (episode_summary or content or "").strip() + validation = validate_memory_write(candidate) + if not validation["allowed"]: + return {"ok": False, "error": "memory_write_rejected", "reason": validation["reason"]} + payload = _context_payload(user_id, agent_id, workspace_id, session_id) + payload.update({"content": validation["sanitized_content"], "tags": tags or [], "source": source}) + if importance is not None: + payload["events"] = [{"type": "importance_hint", "value": importance}] + if confidence is not None: + payload.setdefault("events", []).append({"type": "confidence_hint", "value": confidence}) + return _client(client).append_episode(payload) + + +def memory_commit_session( + user_id: str, + agent_id: str, + session_id: str, + workspace_id: str = "", + promote: bool = True, + min_importance: float = 0.6, + client: MemoryGatewayClient | None = None, +) -> dict[str, Any]: + payload = _context_payload(user_id, agent_id, workspace_id, session_id) + payload.update({"promote": promote, "min_importance": min_importance}) + return _client(client).commit_session(session_id, payload) + + +def memory_upsert( + user_id: str, + agent_id: str, + content: str, + workspace_id: str = "", + namespace: str = "", + memory_type: str = "fact", + summary: str = "", + tags: list[str] | None = None, + importance: float = 0.5, + confidence: float = 0.8, + visibility: str = "private", + source: str = "agent", + client: MemoryGatewayClient | None = None, +) -> dict[str, Any]: + validation = validate_memory_write(content) + if not validation["allowed"]: + return {"ok": False, "error": "memory_write_rejected", "reason": validation["reason"]} + payload = _context_payload(user_id, agent_id, workspace_id) + payload.update( + { + "namespace": namespace or None, + "memory_type": memory_type, + "content": validation["sanitized_content"], + "summary": summary or None, + "tags": tags or [], + "importance": importance, + "confidence": confidence, + "visibility": visibility, + "source": source, + } + ) + return _client(client).upsert_memory(payload) + + +def memory_feedback( + user_id: str, + agent_id: str, + memory_id: str, + feedback: str, + workspace_id: str = "", + session_id: str = "", + comment: str = "", + client: MemoryGatewayClient | None = None, +) -> dict[str, Any]: + mapped_feedback = FEEDBACK_MAP.get(feedback, feedback) + payload = _context_payload(user_id, agent_id, workspace_id, session_id) + payload.update({"feedback": mapped_feedback, "comment": comment or None}) + return _client(client).send_feedback(memory_id, payload) + + +def default_context(config: PluginConfig | None = None) -> dict[str, str]: + cfg = config or load_config() + return { + "user_id": cfg.default_user_id, + "agent_id": cfg.default_agent_id, + "workspace_id": cfg.default_workspace_id, + } + diff --git a/plugins/memory-gateway-agent/memory_gateway_plugin/trace.py b/plugins/memory-gateway-agent/memory_gateway_plugin/trace.py new file mode 100644 index 0000000..0e2a707 --- /dev/null +++ b/plugins/memory-gateway-agent/memory_gateway_plugin/trace.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .output import redact, short_id + + +def trace_enabled() -> bool: + return os.environ.get("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", "").strip().lower() in {"1", "true", "yes", "on"} + + +def trace_path() -> Path: + return Path(__file__).resolve().parents[1] / ".tmp" / "hook_trace.log" + + +def trace_hook( + hook_name: str, + *, + session_id: str = "", + gateway_action: str = "", + gateway_called: bool = False, + ok: bool | None = None, + audit_delta: int | None = None, + reason: str = "", +) -> None: + if not trace_enabled(): + return + path = trace_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload: dict[str, Any] = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "hook": hook_name, + "session_id": short_id(session_id), + "gateway_action": gateway_action, + "gateway_called": gateway_called, + "ok": ok, + "audit_delta": audit_delta, + "reason": reason[:160] if reason else "", + } + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(redact(payload), ensure_ascii=False, default=str) + "\n") diff --git a/plugins/memory-gateway-agent/openclaw.plugin.yaml b/plugins/memory-gateway-agent/openclaw.plugin.yaml new file mode 100644 index 0000000..4c3e8f3 --- /dev/null +++ b/plugins/memory-gateway-agent/openclaw.plugin.yaml @@ -0,0 +1,26 @@ +name: memory-gateway-agent +runtime: openclaw +version: 0.1.0 +description: Draft OpenClaw plugin manifest for Memory Gateway v1. Adjust field names to the actual OpenClaw runtime schema before production use. +entrypoint: memory_gateway_plugin:register +config: + gateway_url: ${MEMORY_GATEWAY_URL:-http://127.0.0.1:1934} + api_key_env: MEMORY_GATEWAY_API_KEY +tools: + - name: memory_search + - name: memory_append_episode + - name: memory_commit_session + - name: memory_upsert + - name: memory_feedback +hooks: + - name: pre_llm_call + handler: memory_gateway_plugin.lifecycle:on_conversation_start + - name: post_llm_call + handler: memory_gateway_plugin.lifecycle:after_user_message + - name: session_end + handler: memory_gateway_plugin.lifecycle:on_session_end +safety: + stores_full_raw_conversation: false + rejects_secrets: true + long_term_commit_via_evermemos: true + diff --git a/plugins/memory-gateway-agent/plugin.yaml b/plugins/memory-gateway-agent/plugin.yaml new file mode 100644 index 0000000..4da0b0b --- /dev/null +++ b/plugins/memory-gateway-agent/plugin.yaml @@ -0,0 +1,55 @@ +name: memory-gateway-agent +version: 0.1.0 +description: Generic AI Agent plugin adapter for the existing Memory Gateway v1 HTTP API. +type: agent-plugin +provides_tools: + - memory_search + - memory_append_episode + - memory_commit_session + - memory_upsert + - memory_feedback +provides_hooks: + - pre_llm_call + - post_llm_call + - session_end + - after_task_complete +requires_env: [] +entrypoint: register +transport: + type: http + target: ${MEMORY_GATEWAY_URL:-http://127.0.0.1:1934} +privacy: + stores_full_raw_conversation: false + writes_long_term_directly_by_default: false + auto_commit_session_default: false +configuration: + MEMORY_GATEWAY_URL: + default: http://127.0.0.1:1934 + MEMORY_GATEWAY_API_KEY: + secret: true + required: false + MEMORY_GATEWAY_DEFAULT_USER_ID: + required: false + MEMORY_GATEWAY_DEFAULT_AGENT_ID: + required: false + MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID: + required: false + MEMORY_GATEWAY_AUTO_SEARCH: + default: "true" + MEMORY_GATEWAY_AUTO_APPEND_EPISODE: + default: "true" + MEMORY_GATEWAY_AUTO_COMMIT_SESSION: + default: "false" + MEMORY_GATEWAY_REVIEW_MODE: + default: "true" +tools: + - memory_search + - memory_append_episode + - memory_commit_session + - memory_upsert + - memory_feedback +hooks: + - pre_llm_call + - post_llm_call + - session_end + - after_task_complete diff --git a/plugins/memory-gateway-agent/policies/memory_policy.md b/plugins/memory-gateway-agent/policies/memory_policy.md new file mode 100644 index 0000000..9fe61cd --- /dev/null +++ b/plugins/memory-gateway-agent/policies/memory_policy.md @@ -0,0 +1,34 @@ +# Memory Gateway Agent Policy + +Use Memory Gateway as a shared memory adapter. It is not a transcript store. + +At conversation start: + +- Search memory when previous context may matter. +- Use `memory_search` with the current `user_id`, `agent_id`, `workspace_id`, and `session_id`. +- Inject only compact relevant memory summaries into the working context. + +During a task: + +- Write only candidate episode summaries with `memory_append_episode`. +- Save stable preferences, long-term project facts, architecture decisions, durable constraints, reusable workflows, and completed task conclusions. +- Do not save complete raw conversations, chain-of-thought, large logs, one-time values, or secrets. + +At task or session completion: + +- Use `memory_commit_session` to let Memory Gateway and EverMemOS decide what can be promoted. +- Do not promote all episodes directly to long-term memory. +- Conflicting or high-value memories should enter review rather than overwrite existing memory. + +When the user says to forget or reject memory: + +- Use `memory_feedback` with `incorrect`, `outdated`, or `not_useful`. +- Use delete-capable tools only when the runtime exposes them and access control allows it. + +Default automation: + +- Auto search may be enabled. +- Auto append episode may be enabled for safe summaries. +- Auto commit is disabled by default. +- Auto direct long-term upsert is disabled by default. + diff --git a/plugins/memory-gateway-agent/policies/safety_filter.md b/plugins/memory-gateway-agent/policies/safety_filter.md new file mode 100644 index 0000000..e7e8a68 --- /dev/null +++ b/plugins/memory-gateway-agent/policies/safety_filter.md @@ -0,0 +1,24 @@ +# Memory Gateway Safety Filter + +The plugin must reject memory writes that contain: + +- passwords +- API keys +- tokens +- secrets +- bearer tokens +- cookies +- session IDs +- private keys +- SSH keys +- one-time passwords or verification codes +- large raw logs +- full chat transcripts +- chain-of-thought or hidden reasoning +- unconfirmed sensitive personal attributes +- low-value temporary chatter + +The plugin stores summaries rather than raw messages. If a message is useful but contains sensitive detail, redact the sensitive detail before writing. If redaction would remove the meaning, reject the write. + +Long-term memory should normally be created by session commit and EverMemOS consolidation, not by direct upsert. + diff --git a/plugins/memory-gateway-agent/schemas.py b/plugins/memory-gateway-agent/schemas.py new file mode 100644 index 0000000..ed90068 --- /dev/null +++ b/plugins/memory-gateway-agent/schemas.py @@ -0,0 +1,107 @@ +from __future__ import annotations + + +MEMORY_SEARCH = { + "name": "memory_search", + "description": "Search accessible Memory Gateway records for the current user/agent context.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query. Must not be empty."}, + "user_id": {"type": "string", "description": "Memory Gateway user id."}, + "agent_id": {"type": "string", "description": "Calling agent id, for ACL and namespace routing."}, + "workspace_id": {"type": "string", "description": "Optional workspace/project id."}, + "session_id": {"type": "string", "description": "Optional session id for session-scoped context."}, + "namespaces": {"type": "array", "items": {"type": "string"}, "description": "Optional namespace filters."}, + "memory_types": {"type": "array", "items": {"type": "string"}, "description": "Optional memory type filters."}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tag filters."}, + "limit": {"type": "integer", "description": "Maximum result count.", "default": 5}, + }, + "required": ["query", "user_id", "agent_id"], + }, +} + +MEMORY_APPEND_EPISODE = { + "name": "memory_append_episode", + "description": "Append a safe summarized candidate episode. Does not save full raw conversation or directly promote long-term memory.", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "Safe summarized episode content. Do not pass raw transcripts."}, + "episode_summary": {"type": "string", "description": "Optional prebuilt summary. Used instead of content when provided."}, + "user_id": {"type": "string", "description": "Memory Gateway user id."}, + "agent_id": {"type": "string", "description": "Calling agent id."}, + "workspace_id": {"type": "string", "description": "Optional workspace/project id."}, + "session_id": {"type": "string", "description": "Current session id."}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tags."}, + "source": {"type": "string", "description": "Source label.", "default": "conversation"}, + }, + "required": ["content", "user_id", "agent_id", "session_id"], + }, +} + +MEMORY_COMMIT_SESSION = { + "name": "memory_commit_session", + "description": "Commit a session through Memory Gateway and EverMemOS. Promotes only what consolidation accepts.", + "parameters": { + "type": "object", + "properties": { + "user_id": {"type": "string", "description": "Memory Gateway user id."}, + "agent_id": {"type": "string", "description": "Calling agent id."}, + "workspace_id": {"type": "string", "description": "Optional workspace/project id."}, + "session_id": {"type": "string", "description": "Session id to commit."}, + "promote": {"type": "boolean", "description": "Whether promotion is allowed.", "default": True}, + "min_importance": {"type": "number", "description": "Minimum importance threshold.", "default": 0.6}, + }, + "required": ["user_id", "agent_id", "session_id"], + }, +} + +MEMORY_UPSERT = { + "name": "memory_upsert", + "description": "High-risk direct memory write. Use only for stable, concise, user-approved long-term memory; do not call automatically.", + "parameters": { + "type": "object", + "properties": { + "user_id": {"type": "string", "description": "Memory Gateway user id."}, + "agent_id": {"type": "string", "description": "Calling agent id."}, + "workspace_id": {"type": "string", "description": "Optional workspace/project id."}, + "namespace": {"type": "string", "description": "Optional explicit namespace, e.g. user/{user_id}/long_term."}, + "memory_type": {"type": "string", "description": "Memory type, e.g. preference, decision, fact, procedure."}, + "content": {"type": "string", "description": "Stable memory content. Do not pass full raw conversation."}, + "summary": {"type": "string", "description": "Optional concise summary."}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Optional tags."}, + "importance": {"type": "number", "description": "Importance score 0..1.", "default": 0.5}, + "confidence": {"type": "number", "description": "Confidence score 0..1.", "default": 0.8}, + "visibility": {"type": "string", "description": "Memory visibility.", "default": "private"}, + }, + "required": ["user_id", "agent_id", "content", "memory_type"], + }, +} + +MEMORY_FEEDBACK = { + "name": "memory_feedback", + "description": "Send quality feedback for an existing memory record.", + "parameters": { + "type": "object", + "properties": { + "memory_id": {"type": "string", "description": "Memory id to mark."}, + "user_id": {"type": "string", "description": "Memory Gateway user id."}, + "agent_id": {"type": "string", "description": "Calling agent id."}, + "workspace_id": {"type": "string", "description": "Optional workspace/project id."}, + "session_id": {"type": "string", "description": "Optional session id."}, + "feedback": {"type": "string", "description": "Feedback, e.g. confirm, correct, delete, reject, incorrect, duplicate, outdated."}, + "comment": {"type": "string", "description": "Optional feedback comment."}, + }, + "required": ["memory_id", "user_id", "agent_id", "feedback"], + }, +} + +TOOL_SCHEMAS = { + "memory_search": MEMORY_SEARCH, + "memory_append_episode": MEMORY_APPEND_EPISODE, + "memory_commit_session": MEMORY_COMMIT_SESSION, + "memory_upsert": MEMORY_UPSERT, + "memory_feedback": MEMORY_FEEDBACK, +} + diff --git a/plugins/memory-gateway-agent/scripts/cleanup_test_memories.py b/plugins/memory-gateway-agent/scripts/cleanup_test_memories.py new file mode 100644 index 0000000..8fa8633 --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/cleanup_test_memories.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) + +from memory_gateway_plugin.output import dumps_safe, short_id, summarize_data + + +USER_ID = "test_user_memory_gateway_plugin" +AGENT_ID = "test_hermes_memory_gateway_plugin" +WORKSPACE_ID = "test_workspace_memory_gateway_plugin" +SESSION_ID = "test_session_memory_gateway_plugin_001" + + +def _assert_test_user(user_id: str) -> None: + if not user_id.startswith("test_user_"): + raise ValueError("cleanup_refuses_non_test_user") + + +def _gateway_url() -> str: + return os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/") + + +def _api_key() -> str: + return os.environ.get("MEMORY_GATEWAY_API_KEY", "") + + +def _request(method: str, endpoint: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + headers = {"Content-Type": "application/json"} + if _api_key(): + headers["X-API-Key"] = _api_key() + body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(_gateway_url() + endpoint, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as response: + raw = response.read().decode("utf-8") + return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}} + except urllib.error.HTTPError as exc: + return {"ok": False, "status_code": exc.code, "error": str(exc.reason)[:300]} + except Exception as exc: + return {"ok": False, "status_code": None, "error": str(exc)[:300]} + + +def _search_candidates() -> dict[str, Any]: + return _request( + "POST", + "/v1/memory/search", + { + "query": "integration_test plugin safe_to_delete Memory Gateway plugin", + "user_id": USER_ID, + "agent_id": AGENT_ID, + "workspace_id": WORKSPACE_ID, + "session_id": SESSION_ID, + "tags": ["integration_test"], + "limit": 100, + }, + ) + + +def _audit_candidate_ids() -> list[str]: + result = _request("GET", "/v1/audit?limit=1000") + if not result.get("ok"): + return [] + ids: list[str] = [] + for row in result.get("data") or []: + if row.get("actor_user_id") != USER_ID: + continue + if row.get("actor_agent_id") not in {AGENT_ID, None, ""}: + continue + if row.get("target_type") == "memory" and row.get("action") in {"upsert_memory", "feedback:incorrect", "feedback:duplicate", "feedback:outdated"}: + target_id = row.get("target_id") + if target_id and target_id not in ids: + ids.append(target_id) + return ids + + +def _memory_from_result(item: dict[str, Any]) -> dict[str, Any] | None: + memory = item.get("memory") + if isinstance(memory, dict) and memory.get("id"): + return memory + return None + + +def _is_cleanup_candidate(memory: dict[str, Any]) -> bool: + if memory.get("user_id") != USER_ID: + return False + tags = set(memory.get("tags") or []) + return bool(tags.intersection({"integration_test", "safe_to_delete", "plugin"})) + + +def _delete_memory(memory_id: str) -> dict[str, Any]: + query = urllib.parse.urlencode({"user_id": USER_ID, "agent_id": AGENT_ID, "workspace_id": WORKSPACE_ID, "session_id": SESSION_ID}) + return _request("DELETE", f"/v1/memory/{urllib.parse.quote(memory_id)}?{query}") + + +def _feedback_memory(memory_id: str) -> dict[str, Any]: + return _request( + "POST", + f"/v1/memory/{urllib.parse.quote(memory_id)}/feedback", + { + "user_id": USER_ID, + "agent_id": AGENT_ID, + "workspace_id": WORKSPACE_ID, + "session_id": SESSION_ID, + "feedback": "incorrect", + "comment": "cleanup marker for integration test memory", + }, + ) + + +def run(user_id: str = USER_ID) -> dict[str, Any]: + _assert_test_user(user_id) + if user_id != USER_ID: + return {"ok": False, "error": "script_is_scoped_to_fixed_test_user", "user_id": user_id} + + search = _search_candidates() + if not search.get("ok"): + return {"ok": False, "search": {"ok": False, "status_code": search.get("status_code"), "error": search.get("error")}, "deleted": 0, "feedback_marked": 0, "skipped": 0} + + rows = (search.get("data") or {}).get("results") or [] + memory_ids = _audit_candidate_ids() + deleted = 0 + feedback_marked = 0 + skipped = 0 + unable: list[dict[str, Any]] = [] + touched: list[str] = [] + + for item in rows: + memory = _memory_from_result(item) + if not memory or not _is_cleanup_candidate(memory): + skipped += 1 + continue + memory_id = memory["id"] + if memory_id not in memory_ids: + memory_ids.append(memory_id) + + for memory_id in memory_ids: + deletion = _delete_memory(memory_id) + if deletion.get("ok"): + deleted += 1 + touched.append(short_id(memory_id)) + continue + if deletion.get("status_code") == 404: + skipped += 1 + continue + feedback = _feedback_memory(memory_id) + if feedback.get("ok"): + feedback_marked += 1 + touched.append(short_id(memory_id)) + else: + unable.append({"memory_id": short_id(memory_id), "delete_status": deletion.get("status_code"), "feedback_status": feedback.get("status_code"), "reason": feedback.get("error") or deletion.get("error")}) + + return { + "ok": not unable, + "search": {"ok": True, "status_code": search.get("status_code"), "data": summarize_data(search.get("data"))}, + "deleted": deleted, + "feedback_marked": feedback_marked, + "skipped": skipped, + "unable_count": len(unable), + "unable": unable, + "touched_memory_ids": touched, + "limitation": "search API returns local MemoryRecord rows plus OpenViking context; cleanup only deletes local MemoryRecord rows for the fixed test user.", + } + + +def main() -> int: + try: + result = run(os.environ.get("MEMORY_GATEWAY_CLEANUP_USER_ID", USER_ID)) + except ValueError as exc: + result = {"ok": False, "error": str(exc), "deleted": 0, "feedback_marked": 0, "skipped": 0} + print(dumps_safe(result)) + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/memory-gateway-agent/scripts/gateway_e2e_check.py b/plugins/memory-gateway-agent/scripts/gateway_e2e_check.py new file mode 100644 index 0000000..8a8a24c --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/gateway_e2e_check.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) + +from memory_gateway_plugin.client import MemoryGatewayClient +from memory_gateway_plugin.config import PluginConfig +from memory_gateway_plugin.output import debug_raw_enabled, dumps_safe, redact, short_id, summarize_data, summarize_result +from memory_gateway_plugin.tools import ( + memory_append_episode, + memory_commit_session, + memory_feedback, + memory_search, + memory_upsert, +) + + +USER_ID = "test_user_memory_gateway_plugin" +AGENT_ID = "test_hermes_memory_gateway_plugin" +WORKSPACE_ID = "test_workspace_memory_gateway_plugin" +SESSION_ID = "test_session_memory_gateway_plugin_001" + + +def _short(value: Any, max_chars: int = 700) -> str: + text = json.dumps(redact(value), ensure_ascii=False, default=str) + return text[:max_chars] + + +def _summary_data(data: dict[str, Any] | None) -> dict[str, Any]: + return summarize_data(data) if isinstance(summarize_data(data), dict) else {} + + +def _result_detail(result: dict[str, Any]) -> str: + return _short(summarize_result(result)) + + +@dataclass +class Step: + name: str + ok: bool + endpoint: str = "" + status_code: int | None = None + detail: str = "" + data: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "ok": self.ok, + "endpoint": self.endpoint, + "status_code": self.status_code, + "detail": self.detail, + "data": _summary_data(self.data), + } + + +def _request(method: str, url: str, payload: dict[str, Any] | None = None, api_key: str = "") -> dict[str, Any]: + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key + body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + raw = response.read().decode("utf-8") + return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}} + except urllib.error.HTTPError as exc: + try: + body_text = exc.read().decode("utf-8") + except Exception: + body_text = str(exc.reason) + return {"ok": False, "status_code": exc.code, "error": body_text[:700]} + except Exception as exc: + return {"ok": False, "status_code": None, "error": str(exc)[:700]} + + +def _health(config: PluginConfig) -> Step: + endpoint = "/health" + result = _request("GET", config.gateway_url.rstrip("/") + endpoint, api_key=config.api_key) + return Step("health", bool(result.get("ok")), endpoint, result.get("status_code"), _result_detail(result), result.get("data")) + + +def _ensure_user(config: PluginConfig) -> Step: + endpoint = "/v1/users" + result = _request( + "POST", + config.gateway_url.rstrip("/") + endpoint, + {"user_id": USER_ID, "display_name": "Memory Gateway Plugin Integration Test", "preferences": {"purpose": "integration_test"}}, + api_key=config.api_key, + ) + ok = bool(result.get("ok")) or result.get("status_code") in {200, 201, 409} + return Step("ensure_user", ok, endpoint, result.get("status_code"), _result_detail(result), result.get("data")) + + +def _client(config: PluginConfig) -> MemoryGatewayClient: + return MemoryGatewayClient(config) + + +def run() -> dict[str, Any]: + config = PluginConfig.from_env() + client = _client(config) + steps: list[Step] = [] + + steps.append(_health(config)) + if not steps[-1].ok: + return {"ok": False, "steps": [s.to_dict() for s in steps]} + + steps.append(_ensure_user(config)) + + search_1 = memory_search( + query="Memory Gateway plugin integration test", + user_id=USER_ID, + agent_id=AGENT_ID, + workspace_id=WORKSPACE_ID, + session_id=SESSION_ID, + limit=5, + client=client, + ) + steps.append(Step("memory_search_initial", bool(search_1.get("ok")), "/v1/memory/search", search_1.get("status_code"), _result_detail(search_1), search_1.get("data"))) + + episode = memory_append_episode( + user_id=USER_ID, + agent_id=AGENT_ID, + workspace_id=WORKSPACE_ID, + session_id=SESSION_ID, + content="Integration test: user prefers Memory Gateway plugin to store only summarized episodes, not raw transcripts.", + tags=["integration_test", "plugin"], + source="agent", + importance=0.2, + confidence=0.5, + client=client, + ) + steps.append(Step("memory_append_episode", bool(episode.get("ok")), "/v1/episodes", episode.get("status_code"), _result_detail(episode), episode.get("data"))) + + commit = memory_commit_session( + user_id=USER_ID, + agent_id=AGENT_ID, + workspace_id=WORKSPACE_ID, + session_id=SESSION_ID, + promote=True, + min_importance=0.1, + client=client, + ) + commit_detail = _result_detail(commit) + if commit.get("ok") and not (commit.get("data") or {}).get("promoted"): + commit_detail += " | promotion may be empty while commit endpoint succeeded" + steps.append(Step("memory_commit_session", bool(commit.get("ok")), f"/v1/sessions/{SESSION_ID}/commit", commit.get("status_code"), commit_detail, commit.get("data"))) + + upsert = memory_upsert( + user_id=USER_ID, + agent_id=AGENT_ID, + workspace_id=WORKSPACE_ID, + namespace=f"user/{USER_ID}/long_term", + memory_type="preference", + content="Integration test memory: this should be removable or clearly tagged as test data.", + tags=["integration_test", "plugin", "safe_to_delete"], + importance=0.1, + confidence=0.5, + source="agent", + client=client, + ) + steps.append(Step("memory_upsert", bool(upsert.get("ok")), "/v1/memory", upsert.get("status_code"), _result_detail(upsert), upsert.get("data"))) + + search_2 = memory_search( + query="Integration test memory summarized episodes raw transcripts", + user_id=USER_ID, + agent_id=AGENT_ID, + workspace_id=WORKSPACE_ID, + session_id=SESSION_ID, + limit=10, + client=client, + ) + result_count = len((search_2.get("data") or {}).get("results", [])) + detail = _result_detail(search_2) + if search_2.get("ok") and result_count == 0: + detail += " | search succeeded but returned no results; indexing or OpenViking sync may be asynchronous" + steps.append(Step("memory_search_after_write", bool(search_2.get("ok")), "/v1/memory/search", search_2.get("status_code"), detail, search_2.get("data"))) + + memory_id = ((upsert.get("data") or {}).get("memory") or upsert.get("data") or {}).get("id") + if memory_id: + feedback = memory_feedback( + user_id=USER_ID, + agent_id=AGENT_ID, + workspace_id=WORKSPACE_ID, + session_id=SESSION_ID, + memory_id=memory_id, + feedback="reject", + comment="Integration test cleanup marker; safe to ignore/delete.", + client=client, + ) + steps.append(Step("memory_feedback", bool(feedback.get("ok")), f"/v1/memory/{memory_id}/feedback", feedback.get("status_code"), _result_detail(feedback), feedback.get("data"))) + else: + steps.append(Step("memory_feedback", False, "/v1/memory/{memory_id}/feedback", None, "skipped because memory_upsert did not return memory id")) + + required = {"health", "memory_search_initial", "memory_append_episode", "memory_commit_session", "memory_upsert", "memory_search_after_write", "memory_feedback"} + ok = all(step.ok for step in steps if step.name in required) + return {"ok": ok, "gateway_url": config.gateway_url, "debug_raw": debug_raw_enabled(), "test_identity": {"user_id": USER_ID, "agent_id": AGENT_ID, "workspace_id": WORKSPACE_ID, "session_id": SESSION_ID}, "steps": [s.to_dict() for s in steps]} + + +def main() -> int: + result = run() + print(dumps_safe(result)) + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/memory-gateway-agent/scripts/health.py b/plugins/memory-gateway-agent/scripts/health.py new file mode 100644 index 0000000..e195e2d --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/health.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) + +from memory_gateway_plugin.config import load_config +from memory_gateway_plugin.output import dumps_safe, summarize_data + + +def main() -> None: + config = load_config() + request = urllib.request.Request(config.gateway_url.rstrip("/") + "/health", method="GET") + if config.api_key: + request.add_header("X-API-Key", config.api_key) + try: + with urllib.request.urlopen(request, timeout=config.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + print(dumps_safe({"ok": True, "endpoint": "/health", "status_code": getattr(response, "status", 200), "data": summarize_data(payload)})) + except urllib.error.URLError as exc: + print(dumps_safe({"ok": False, "endpoint": "/health", "status_code": None, "error": str(exc)[:300]})) + + +if __name__ == "__main__": + main() diff --git a/plugins/memory-gateway-agent/scripts/hermes_hook_probe.py b/plugins/memory-gateway-agent/scripts/hermes_hook_probe.py new file mode 100644 index 0000000..1a69a7e --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/hermes_hook_probe.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +USER_ID = "test_user_memory_gateway_plugin" +AGENT_ID = "test_hermes_memory_gateway_plugin" +WORKSPACE_ID = "test_workspace_memory_gateway_plugin" +SESSION_ID = "test_session_memory_gateway_plugin_001" + + +def _ensure_paths() -> None: + plugin_root = Path(__file__).resolve().parents[1] + hermes_repo = Path(os.environ.get("HERMES_REPO", "/home/tom/.hermes/hermes-agent")) + hermes_cli = hermes_repo / "hermes_cli" + for path in [plugin_root, hermes_repo, hermes_cli]: + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + + +_ensure_paths() + +from memory_gateway_plugin.output import dumps_safe, summarize_data + + +def _request(method: str, url: str, payload: dict[str, Any] | None = None, api_key: str = "") -> dict[str, Any]: + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key + body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + raw = response.read().decode("utf-8") + return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}} + except urllib.error.HTTPError as exc: + try: + body_text = exc.read().decode("utf-8") + except Exception: + body_text = str(exc.reason) + return {"ok": False, "status_code": exc.code, "error": body_text[:500]} + except Exception as exc: + return {"ok": False, "status_code": None, "error": str(exc)[:500]} + + +def _ensure_user() -> dict[str, Any]: + gateway_url = os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/") + api_key = os.environ.get("MEMORY_GATEWAY_API_KEY", "") + return _request( + "POST", + gateway_url + "/v1/users", + {"user_id": USER_ID, "display_name": "Memory Gateway Hook Probe", "preferences": {"purpose": "hook_probe"}}, + api_key=api_key, + ) + + +def _audit_count(action: str) -> int: + gateway_url = os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/") + api_key = os.environ.get("MEMORY_GATEWAY_API_KEY", "") + result = _request("GET", gateway_url + "/v1/audit?limit=1000", api_key=api_key) + if not result.get("ok"): + return -1 + rows = result.get("data") or [] + return sum( + 1 + for row in rows + if row.get("action") == action + and row.get("actor_user_id") == USER_ID + and row.get("actor_agent_id") == AGENT_ID + ) + + +def _hook_report(manager: Any, hook_name: str, payload: dict[str, Any], audit_action: str = "") -> dict[str, Any]: + registered = hook_name in getattr(manager, "_hooks", {}) and bool(manager._hooks[hook_name]) + before = _audit_count(audit_action) if audit_action else -1 + try: + result = manager.invoke_hook(hook_name, **payload) + after = _audit_count(audit_action) if audit_action else -1 + return { + "registered": registered, + "invoked": True, + "result_type": type(result).__name__, + "result": summarize_data(result), + "audit_action": audit_action, + "audit_delta": (after - before) if before >= 0 and after >= 0 else None, + "error": "", + } + except Exception as exc: + return { + "registered": registered, + "invoked": False, + "result_type": "", + "result": None, + "audit_action": audit_action, + "audit_delta": None, + "error": str(exc)[:500], + } + + +def run(auto_commit: bool = False) -> dict[str, Any]: + os.environ.setdefault("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934") + os.environ["MEMORY_GATEWAY_DEFAULT_USER_ID"] = USER_ID + os.environ["MEMORY_GATEWAY_DEFAULT_AGENT_ID"] = AGENT_ID + os.environ["MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID"] = WORKSPACE_ID + os.environ["MEMORY_GATEWAY_AUTO_COMMIT_SESSION"] = "true" if auto_commit else "false" + + from plugins import PluginManager + + ensure_user = _ensure_user() + manager = PluginManager() + manager.discover_and_load() + + base = { + "user_id": USER_ID, + "agent_id": AGENT_ID, + "workspace_id": WORKSPACE_ID, + "session_id": SESSION_ID, + "task_id": SESSION_ID, + "model": "hook-probe", + "platform": "cli", + } + hooks = { + "on_session_start": dict(base), + "pre_llm_call": { + **base, + "user_message": "Memory Gateway plugin integration test memory preference", + "conversation_history": [], + "is_first_turn": True, + }, + "post_llm_call": { + **base, + "user_message": "请记住:Memory Gateway plugin hook probe 偏好保存简短摘要型 episode。", + "assistant_response": "已记录为候选摘要,后续由 session commit 判断是否提升为长期记忆。", + }, + "on_session_end": dict(base), + } + + audit_actions = { + "pre_llm_call": "memory_search", + "post_llm_call": "append_episode", + "on_session_end": "commit_session", + } + reports = {name: _hook_report(manager, name, payload, audit_actions.get(name, "")) for name, payload in hooks.items()} + plugin = manager._plugins.get("memory-gateway-agent") + return { + "ok": all(item["registered"] and item["invoked"] for item in reports.values()), + "auto_commit": auto_commit, + "ensure_user": {"ok": ensure_user.get("ok"), "status_code": ensure_user.get("status_code"), "data": summarize_data(ensure_user.get("data"))}, + "plugin": { + "enabled": bool(plugin and plugin.enabled), + "tools_registered": sorted(getattr(plugin, "tools_registered", []) if plugin else []), + "hooks_registered": sorted(getattr(plugin, "hooks_registered", []) if plugin else []), + "error": getattr(plugin, "error", None) if plugin else "plugin_not_found", + }, + "hooks": reports, + } + + +def main() -> int: + auto_commit = os.environ.get("MEMORY_GATEWAY_AUTO_COMMIT_SESSION", "").strip().lower() in {"1", "true", "yes", "on"} + result = run(auto_commit=auto_commit) + print(dumps_safe(result)) + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py b/plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py new file mode 100644 index 0000000..6153112 --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) + +from memory_gateway_plugin.output import dumps_safe, short_id, summarize_data + + +USER_ID = "test_user_memory_gateway_plugin" +AGENT_ID = "test_hermes_memory_gateway_plugin" +WORKSPACE_ID = "test_workspace_memory_gateway_plugin" +SESSION_ID = "test_session_memory_gateway_plugin_interactive_002" +PROMPT = "Please remember this integration test preference: Memory Gateway plugin should store summarized episodes, not raw transcripts." + + +def _request(method: str, url: str, payload: dict[str, Any] | None = None, api_key: str = "") -> dict[str, Any]: + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key + body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + raw = response.read().decode("utf-8") + return {"ok": True, "status_code": getattr(response, "status", 200), "data": json.loads(raw) if raw else {}} + except urllib.error.HTTPError as exc: + return {"ok": False, "status_code": exc.code, "error": str(exc.reason)[:300]} + except Exception as exc: + return {"ok": False, "status_code": None, "error": str(exc)[:300]} + + +def _gateway_url() -> str: + return os.environ.get("MEMORY_GATEWAY_URL", "http://127.0.0.1:1934").rstrip("/") + + +def _api_key() -> str: + return os.environ.get("MEMORY_GATEWAY_API_KEY", "") + + +def _audit_counts() -> dict[str, int]: + result = _request("GET", _gateway_url() + "/v1/audit?limit=1000", api_key=_api_key()) + rows = result.get("data") or [] + actions = {"memory_search": 0, "append_episode": 0, "commit_session": 0} + for row in rows: + if row.get("actor_user_id") != USER_ID or row.get("actor_agent_id") != AGENT_ID: + continue + action = row.get("action") + if action in actions: + actions[action] += 1 + return actions + + +def _run_cmd(args: list[str], timeout: int = 20, env: dict[str, str] | None = None) -> dict[str, Any]: + try: + completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=env, check=False) + return {"ok": completed.returncode == 0, "returncode": completed.returncode, "stdout_chars": len(completed.stdout), "stderr_chars": len(completed.stderr)} + except FileNotFoundError: + return {"ok": False, "returncode": None, "error": "command_not_found"} + except subprocess.TimeoutExpired: + return {"ok": False, "returncode": None, "error": "timeout"} + + +def _manual_instructions(reason: str) -> dict[str, Any]: + return { + "mode": "manual", + "reason": reason, + "commands": [ + "hermes plugins list", + "hermes tools list", + "MEMORY_GATEWAY_URL=http://127.0.0.1:1934 MEMORY_GATEWAY_DEFAULT_USER_ID=test_user_memory_gateway_plugin MEMORY_GATEWAY_DEFAULT_AGENT_ID=test_hermes_memory_gateway_plugin MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID=test_workspace_memory_gateway_plugin MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false hermes chat -Q -q 'Please remember this integration test preference: Memory Gateway plugin should store summarized episodes, not raw transcripts.' --source memory-gateway-plugin-test --toolsets memory_gateway", + "python plugins/memory-gateway-agent/scripts/hermes_interactive_session_check.py", + ], + "expected": [ + "Gateway audit memory_search count increases for the test user/agent.", + "Gateway audit append_episode count increases for the test user/agent.", + "commit_session count does not increase while MEMORY_GATEWAY_AUTO_COMMIT_SESSION=false.", + ], + } + + +def run() -> dict[str, Any]: + hermes = shutil.which("hermes") or "/home/tom/.local/bin/hermes" + plugin_list = _run_cmd([hermes, "plugins", "list"], timeout=10) + tools_list = _run_cmd([hermes, "tools", "list"], timeout=10) + health = _request("GET", _gateway_url() + "/health", api_key=_api_key()) + if not health.get("ok"): + return {"ok": False, "mode": "blocked", "plugin_list": plugin_list, "tools_list": tools_list, "gateway_health": {"ok": False, "status_code": health.get("status_code"), "error": health.get("error")}, "manual": _manual_instructions("gateway_unhealthy")} + + before = _audit_counts() + env = os.environ.copy() + env.update( + { + "MEMORY_GATEWAY_URL": _gateway_url(), + "MEMORY_GATEWAY_DEFAULT_USER_ID": USER_ID, + "MEMORY_GATEWAY_DEFAULT_AGENT_ID": AGENT_ID, + "MEMORY_GATEWAY_DEFAULT_WORKSPACE_ID": WORKSPACE_ID, + "MEMORY_GATEWAY_AUTO_SEARCH": "true", + "MEMORY_GATEWAY_AUTO_APPEND_EPISODE": "true", + "MEMORY_GATEWAY_AUTO_COMMIT_SESSION": os.environ.get("MEMORY_GATEWAY_AUTO_COMMIT_SESSION", "false"), + } + ) + chat = _run_cmd( + [hermes, "chat", "-Q", "-q", PROMPT, "--source", "memory-gateway-plugin-test", "--toolsets", "memory_gateway"], + timeout=int(os.environ.get("MEMORY_GATEWAY_PLUGIN_CHAT_TIMEOUT", "180")), + env=env, + ) + after = _audit_counts() + delta = {key: after.get(key, 0) - before.get(key, 0) for key in before} + auto_commit = env["MEMORY_GATEWAY_AUTO_COMMIT_SESSION"].strip().lower() in {"1", "true", "yes", "on"} + expected_commit = delta.get("commit_session", 0) > 0 if auto_commit else delta.get("commit_session", 0) == 0 + passed = chat.get("ok") and delta.get("memory_search", 0) > 0 and delta.get("append_episode", 0) > 0 and expected_commit + return { + "ok": bool(passed), + "mode": "auto" if chat.get("ok") else "manual", + "plugin_list": plugin_list, + "tools_list": tools_list, + "gateway_health": {"ok": True, "status_code": health.get("status_code"), "data": summarize_data(health.get("data"))}, + "chat": chat, + "auto_commit": auto_commit, + "audit_before": before, + "audit_after": after, + "audit_delta": delta, + "test_identity": {"user_id": USER_ID, "agent_id": AGENT_ID, "workspace_id": WORKSPACE_ID, "session_id": short_id(SESSION_ID)}, + "manual": None if chat.get("ok") else _manual_instructions(chat.get("error", "chat_command_failed")), + } + + +def main() -> int: + result = run() + print(dumps_safe(result)) + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/memory-gateway-agent/scripts/hermes_smoke_check.py b/plugins/memory-gateway-agent/scripts/hermes_smoke_check.py new file mode 100644 index 0000000..08189bb --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/hermes_smoke_check.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from pathlib import Path + + +class FakeHermesContext: + def __init__(self) -> None: + self.registered_tools = [] + self.registered_hooks = [] + + def register_tool(self, name, toolset, schema, handler, **kwargs): + self.registered_tools.append( + { + "name": name, + "toolset": toolset, + "schema": schema, + "handler_callable": callable(handler), + "kwargs": kwargs, + } + ) + + def register_hook(self, hook_name, callback): + self.registered_hooks.append({"name": hook_name, "handler_callable": callable(callback)}) + + +def load_plugin_module(): + plugin_dir = Path(__file__).resolve().parents[1] + init_file = plugin_dir / "__init__.py" + module_name = "hermes_plugins.memory_gateway_agent_smoke" + if "hermes_plugins" not in sys.modules: + parent = types.ModuleType("hermes_plugins") + parent.__path__ = [] + sys.modules["hermes_plugins"] = parent + spec = importlib.util.spec_from_file_location( + module_name, + init_file, + submodule_search_locations=[str(plugin_dir)], + ) + if spec is None or spec.loader is None: + raise RuntimeError("Cannot create plugin module spec") + module = importlib.util.module_from_spec(spec) + module.__package__ = module_name + module.__path__ = [str(plugin_dir)] + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def main() -> None: + module = load_plugin_module() + ctx = FakeHermesContext() + module.register(ctx) + print( + json.dumps( + { + "ok": True, + "has_register": callable(getattr(module, "register", None)), + "registered_tools": ctx.registered_tools, + "registered_hooks": ctx.registered_hooks, + }, + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/memory-gateway-agent/scripts/smoke_test.py b/plugins/memory-gateway-agent/scripts/smoke_test.py new file mode 100644 index 0000000..3d64a10 --- /dev/null +++ b/plugins/memory-gateway-agent/scripts/smoke_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +import uuid +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) + +from memory_gateway_plugin.output import dumps_safe, summarize_result +from memory_gateway_plugin.tools import memory_append_episode, memory_commit_session, memory_search + + +def main() -> None: + user_id = "plugin_smoke_user" + agent_id = "plugin_smoke_agent" + session_id = f"plugin_smoke_{uuid.uuid4().hex[:8]}" + episode = memory_append_episode( + user_id=user_id, + agent_id=agent_id, + session_id=session_id, + episode_summary="结论:Memory Gateway Agent Plugin smoke test 写入短期 episode。", + tags=["smoke-test"], + ) + commit = memory_commit_session(user_id=user_id, agent_id=agent_id, session_id=session_id) + search = memory_search(query="Memory Gateway Agent Plugin smoke test", user_id=user_id, agent_id=agent_id, session_id=session_id) + print(dumps_safe({"episode": summarize_result(episode), "commit": summarize_result(commit), "search": summarize_result(search)})) + + +if __name__ == "__main__": + main() diff --git a/plugins/memory-gateway-agent/tests/test_cleanup_requires_test_user.py b/plugins/memory-gateway-agent/tests/test_cleanup_requires_test_user.py new file mode 100644 index 0000000..56b8000 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_cleanup_requires_test_user.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def _load_cleanup(): + path = Path(__file__).resolve().parents[1] / "scripts" / "cleanup_test_memories.py" + spec = importlib.util.spec_from_file_location("cleanup_test_memories_guard_test", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_cleanup_requires_test_user(): + module = _load_cleanup() + + try: + module.run("real_user") + except ValueError as exc: + assert "cleanup_refuses_non_test_user" in str(exc) + else: + raise AssertionError("cleanup accepted a non-test user") diff --git a/plugins/memory-gateway-agent/tests/test_cleanup_test_memories_imports.py b/plugins/memory-gateway-agent/tests/test_cleanup_test_memories_imports.py new file mode 100644 index 0000000..c0984a9 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_cleanup_test_memories_imports.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def test_cleanup_test_memories_imports(): + path = Path(__file__).resolve().parents[1] / "scripts" / "cleanup_test_memories.py" + spec = importlib.util.spec_from_file_location("cleanup_test_memories", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + assert callable(module.run) + assert module.USER_ID.startswith("test_user_") diff --git a/plugins/memory-gateway-agent/tests/test_client.py b/plugins/memory-gateway-agent/tests/test_client.py new file mode 100644 index 0000000..db41c13 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_client.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import io +import json +import urllib.error + +from memory_gateway_plugin.client import MemoryGatewayClient +from memory_gateway_plugin.config import PluginConfig + + +class FakeResponse: + status = 200 + + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def test_client_search_success(monkeypatch): + seen = {} + + def fake_urlopen(request, timeout): + seen["url"] = request.full_url + seen["timeout"] = timeout + return FakeResponse({"results": [], "total": 0}) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway", timeout=7)) + result = client.search_memory({"user_id": "u", "query": "demo"}) + + assert result["ok"] is True + assert result["endpoint"] == "/v1/memory/search" + assert seen["url"] == "http://gateway/v1/memory/search" + assert seen["timeout"] == 7 + + +def test_client_network_error(monkeypatch): + def fake_urlopen(request, timeout): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway")) + result = client.search_memory({"user_id": "u", "query": "demo"}) + + assert result["ok"] is False + assert result["status_code"] is None + assert "connection refused" in result["error"] + + +def test_commit_session_calls_correct_endpoint(monkeypatch): + seen = {} + + def fake_urlopen(request, timeout): + seen["url"] = request.full_url + return FakeResponse({"session_id": "sess_1"}) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway")) + result = client.commit_session("sess_1", {"user_id": "u", "session_id": "sess_1"}) + + assert result["ok"] is True + assert seen["url"] == "http://gateway/v1/sessions/sess_1/commit" + + +def test_feedback_calls_correct_endpoint(monkeypatch): + seen = {} + + def fake_urlopen(request, timeout): + seen["url"] = request.full_url + return FakeResponse({"status": "ok"}) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway")) + result = client.send_feedback("mem_1", {"user_id": "u", "feedback": "incorrect"}) + + assert result["ok"] is True + assert seen["url"] == "http://gateway/v1/memory/mem_1/feedback" + + +def test_client_http_error(monkeypatch): + def fake_urlopen(request, timeout): + raise urllib.error.HTTPError( + url=request.full_url, + code=401, + msg="unauthorized", + hdrs=None, + fp=io.BytesIO(b'{"detail":"Invalid or missing API key"}'), + ) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + client = MemoryGatewayClient(PluginConfig(gateway_url="http://gateway")) + result = client.search_memory({"user_id": "u", "query": "demo"}) + + assert result["ok"] is False + assert result["status_code"] == 401 + assert result["endpoint"] == "/v1/memory/search" + diff --git a/plugins/memory-gateway-agent/tests/test_debug_raw_disabled_by_default.py b/plugins/memory-gateway-agent/tests/test_debug_raw_disabled_by_default.py new file mode 100644 index 0000000..ff4a186 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_debug_raw_disabled_by_default.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from memory_gateway_plugin.output import debug_raw_enabled, summarize_data + + +def test_debug_raw_disabled_by_default(monkeypatch): + monkeypatch.delenv("MEMORY_GATEWAY_PLUGIN_DEBUG_RAW", raising=False) + + assert debug_raw_enabled() is False + assert summarize_data({"results": [{"memory": {"content": "raw"}}], "total": 1}) == { + "count": 1, + "total": 1, + "local_total": None, + "openviking_total": None, + "searched_namespaces": [], + } diff --git a/plugins/memory-gateway-agent/tests/test_gateway_e2e_script_imports.py b/plugins/memory-gateway-agent/tests/test_gateway_e2e_script_imports.py new file mode 100644 index 0000000..fbd1e83 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_gateway_e2e_script_imports.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def test_gateway_e2e_script_imports(): + path = Path(__file__).resolve().parents[1] / "scripts" / "gateway_e2e_check.py" + spec = importlib.util.spec_from_file_location("gateway_e2e_check", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + assert callable(module.run) + assert module.USER_ID == "test_user_memory_gateway_plugin" diff --git a/plugins/memory-gateway-agent/tests/test_hermes_hook_probe_imports.py b/plugins/memory-gateway-agent/tests/test_hermes_hook_probe_imports.py new file mode 100644 index 0000000..838083e --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hermes_hook_probe_imports.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +def test_hermes_hook_probe_script_imports(): + path = Path(__file__).resolve().parents[1] / "scripts" / "hermes_hook_probe.py" + spec = importlib.util.spec_from_file_location("hermes_hook_probe", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + assert callable(module.run) + assert module.SESSION_ID == "test_session_memory_gateway_plugin_001" diff --git a/plugins/memory-gateway-agent/tests/test_hermes_register_hooks.py b/plugins/memory-gateway-agent/tests/test_hermes_register_hooks.py new file mode 100644 index 0000000..4660140 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hermes_register_hooks.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from test_hermes_register_tools import FakeHermesContext, load_plugin_module + + +def test_register_registers_expected_hooks(): + module = load_plugin_module() + ctx = FakeHermesContext() + + module.register(ctx) + + assert [item[0] for item in ctx.registered_hooks] == [ + "on_session_start", + "pre_llm_call", + "post_llm_call", + "on_session_end", + ] + assert all(callable(item[1]) for item in ctx.registered_hooks) + + +def test_hook_callbacks_accept_kwargs(): + module = load_plugin_module() + + assert isinstance(module.on_session_start(session_id="s", extra="x"), dict) + assert isinstance(module.pre_llm_call(user_message="", session_id="s", extra="x"), dict) + assert module.post_llm_call(user_message="hi", assistant_response="hello", extra="x") is None + assert module.on_session_end(session_id="s", extra="x") is None + diff --git a/plugins/memory-gateway-agent/tests/test_hermes_register_tools.py b/plugins/memory-gateway-agent/tests/test_hermes_register_tools.py new file mode 100644 index 0000000..42e9515 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hermes_register_tools.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + + +class FakeHermesContext: + def __init__(self) -> None: + self.registered_tools = [] + self.registered_hooks = [] + + def register_tool(self, name, toolset, schema, handler, **kwargs): + self.registered_tools.append((name, toolset, schema, handler, kwargs)) + + def register_hook(self, hook_name, callback): + self.registered_hooks.append((hook_name, callback)) + + +def load_plugin_module(): + plugin_dir = Path(__file__).resolve().parents[1] + if "hermes_plugins" not in sys.modules: + parent = types.ModuleType("hermes_plugins") + parent.__path__ = [] + sys.modules["hermes_plugins"] = parent + spec = importlib.util.spec_from_file_location( + "hermes_plugins.memory_gateway_agent_test", + plugin_dir / "__init__.py", + submodule_search_locations=[str(plugin_dir)], + ) + module = importlib.util.module_from_spec(spec) + module.__package__ = "hermes_plugins.memory_gateway_agent_test" + module.__path__ = [str(plugin_dir)] + sys.modules["hermes_plugins.memory_gateway_agent_test"] = module + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_register_registers_five_tools(): + module = load_plugin_module() + ctx = FakeHermesContext() + + module.register(ctx) + + assert [item[0] for item in ctx.registered_tools] == [ + "memory_search", + "memory_append_episode", + "memory_commit_session", + "memory_upsert", + "memory_feedback", + ] + assert all(item[1] == "memory_gateway" for item in ctx.registered_tools) + + +def test_registered_handlers_are_callable(): + module = load_plugin_module() + ctx = FakeHermesContext() + + module.register(ctx) + + assert all(callable(item[3]) for item in ctx.registered_tools) diff --git a/plugins/memory-gateway-agent/tests/test_hermes_schemas.py b/plugins/memory-gateway-agent/tests/test_hermes_schemas.py new file mode 100644 index 0000000..d99c5e0 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hermes_schemas.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from test_hermes_register_tools import load_plugin_module + + +def test_tool_schemas_exist_for_all_tools(): + module = load_plugin_module() + schemas = module.schemas.TOOL_SCHEMAS + + assert set(schemas) == { + "memory_search", + "memory_append_episode", + "memory_commit_session", + "memory_upsert", + "memory_feedback", + } + + +def test_tool_schemas_have_required_fields(): + module = load_plugin_module() + schemas = module.schemas.TOOL_SCHEMAS + + assert schemas["memory_search"]["parameters"]["required"] == ["query", "user_id", "agent_id"] + assert schemas["memory_append_episode"]["parameters"]["required"] == ["content", "user_id", "agent_id", "session_id"] + assert schemas["memory_commit_session"]["parameters"]["required"] == ["user_id", "agent_id", "session_id"] + assert schemas["memory_upsert"]["parameters"]["required"] == ["user_id", "agent_id", "content", "memory_type"] + assert schemas["memory_feedback"]["parameters"]["required"] == ["memory_id", "user_id", "agent_id", "feedback"] + + +def test_upsert_schema_warns_high_risk(): + module = load_plugin_module() + + description = module.schemas.TOOL_SCHEMAS["memory_upsert"]["description"].lower() + assert "high-risk" in description + assert "do not call automatically" in description + diff --git a/plugins/memory-gateway-agent/tests/test_hook_auto_commit_disabled_by_default.py b/plugins/memory-gateway-agent/tests/test_hook_auto_commit_disabled_by_default.py new file mode 100644 index 0000000..0af207e --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hook_auto_commit_disabled_by_default.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from memory_gateway_plugin.config import PluginConfig +from memory_gateway_plugin.lifecycle import on_session_end + + +class CountingClient: + def __init__(self) -> None: + self.commit_calls = 0 + + def commit_session(self, session_id, payload): + self.commit_calls += 1 + return {"ok": True, "data": {"session_id": session_id, "payload": payload}} + + +def test_hook_auto_commit_disabled_by_default(): + client = CountingClient() + + result = on_session_end( + {"user_id": "u", "agent_id": "a", "session_id": "s"}, + client=client, + config=PluginConfig(auto_commit_session=False), + ) + + assert result["ok"] is True + assert result["committed"] is False + assert result["reason"] == "auto_commit_disabled" + assert client.commit_calls == 0 diff --git a/plugins/memory-gateway-agent/tests/test_hook_post_llm_does_not_save_raw_transcript.py b/plugins/memory-gateway-agent/tests/test_hook_post_llm_does_not_save_raw_transcript.py new file mode 100644 index 0000000..0b91f5a --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hook_post_llm_does_not_save_raw_transcript.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from memory_gateway_plugin.config import PluginConfig +from memory_gateway_plugin.lifecycle import after_user_message + + +class RecordingClient: + def __init__(self) -> None: + self.payloads = [] + + def append_episode(self, payload): + self.payloads.append(payload) + return {"ok": True, "data": payload} + + +def test_hook_post_llm_does_not_save_raw_transcript(): + client = RecordingClient() + raw_transcript = "user: a\nassistant: b\nuser: c\nassistant: d" + + result = after_user_message( + { + "user_id": "u", + "agent_id": "a", + "session_id": "s", + "user_message": raw_transcript, + "assistant_response": "请记住这个完整原始对话。", + }, + client=client, + config=PluginConfig(auto_append_episode=True), + ) + + assert result["ok"] is True + assert result["appended"] is False + assert result["reason"] == "policy_skip" + assert client.payloads == [] diff --git a/plugins/memory-gateway-agent/tests/test_hook_pre_llm_search_failure_non_blocking.py b/plugins/memory-gateway-agent/tests/test_hook_pre_llm_search_failure_non_blocking.py new file mode 100644 index 0000000..d76228d --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hook_pre_llm_search_failure_non_blocking.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + + +def _load_root_plugin(): + plugin_dir = Path(__file__).resolve().parents[1] + if "hermes_plugins" not in sys.modules: + parent = types.ModuleType("hermes_plugins") + parent.__path__ = [] # type: ignore[attr-defined] + sys.modules["hermes_plugins"] = parent + module_name = "hermes_plugins.memory_gateway_agent_pre_llm_test" + spec = importlib.util.spec_from_file_location( + module_name, + plugin_dir / "__init__.py", + submodule_search_locations=[str(plugin_dir)], + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + module.__package__ = module_name + module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined] + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def test_hook_pre_llm_search_failure_non_blocking(monkeypatch): + plugin = _load_root_plugin() + + class FailingLifecycle: + @staticmethod + def on_conversation_start(context): + return {"ok": False, "error": "network_error"} + + monkeypatch.setattr(plugin, "lifecycle", FailingLifecycle) + + assert plugin.pre_llm_call(user_id="u", agent_id="a", session_id="s", user_message="search") == {} diff --git a/plugins/memory-gateway-agent/tests/test_hook_trace_disabled_by_default.py b/plugins/memory-gateway-agent/tests/test_hook_trace_disabled_by_default.py new file mode 100644 index 0000000..47221c8 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hook_trace_disabled_by_default.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from memory_gateway_plugin.trace import trace_enabled + + +def test_hook_trace_disabled_by_default(monkeypatch): + monkeypatch.delenv("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", raising=False) + + assert trace_enabled() is False diff --git a/plugins/memory-gateway-agent/tests/test_hook_trace_does_not_log_api_key.py b/plugins/memory-gateway-agent/tests/test_hook_trace_does_not_log_api_key.py new file mode 100644 index 0000000..1f1471f --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hook_trace_does_not_log_api_key.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from memory_gateway_plugin.trace import trace_hook + + +def test_hook_trace_does_not_log_api_key(monkeypatch, tmp_path): + import memory_gateway_plugin.trace as trace_mod + + path = tmp_path / "hook_trace.log" + monkeypatch.setenv("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", "true") + monkeypatch.setenv("MEMORY_GATEWAY_API_KEY", "sk-should-not-appear") + monkeypatch.setattr(trace_mod, "trace_path", lambda: path) + + trace_hook("post_llm_call", session_id="s", gateway_action="append_episode", gateway_called=True, ok=True) + + text = path.read_text(encoding="utf-8") + assert "sk-should-not-appear" not in text + assert "api_key" not in text.lower() diff --git a/plugins/memory-gateway-agent/tests/test_hook_trace_redacts_content.py b/plugins/memory-gateway-agent/tests/test_hook_trace_redacts_content.py new file mode 100644 index 0000000..bfd170c --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_hook_trace_redacts_content.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from memory_gateway_plugin.trace import trace_hook + + +def test_hook_trace_redacts_content(monkeypatch, tmp_path): + import memory_gateway_plugin.trace as trace_mod + + path = tmp_path / "hook_trace.log" + monkeypatch.setenv("MEMORY_GATEWAY_PLUGIN_TRACE_HOOKS", "true") + monkeypatch.setattr(trace_mod, "trace_path", lambda: path) + + trace_hook("pre_llm_call", session_id="test_session_1234567890", gateway_action="memory_search", gateway_called=True, ok=True, reason="password=abc") + + text = path.read_text(encoding="utf-8") + assert "password=abc" not in text + assert "pre_llm_call" in text + assert "test_ses" in text diff --git a/plugins/memory-gateway-agent/tests/test_interactive_session_check_imports.py b/plugins/memory-gateway-agent/tests/test_interactive_session_check_imports.py new file mode 100644 index 0000000..bf14814 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_interactive_session_check_imports.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def test_interactive_session_check_imports(): + path = Path(__file__).resolve().parents[1] / "scripts" / "hermes_interactive_session_check.py" + spec = importlib.util.spec_from_file_location("hermes_interactive_session_check", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + assert callable(module.run) + assert module.SESSION_ID == "test_session_memory_gateway_plugin_interactive_002" diff --git a/plugins/memory-gateway-agent/tests/test_lifecycle.py b/plugins/memory-gateway-agent/tests/test_lifecycle.py new file mode 100644 index 0000000..98ddae7 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_lifecycle.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from memory_gateway_plugin import register +from memory_gateway_plugin.config import PluginConfig +from memory_gateway_plugin.lifecycle import after_user_message, on_conversation_start, on_session_end + + +class FakeClient: + def search_memory(self, payload): + return { + "ok": True, + "data": { + "results": [ + { + "memory": { + "id": "mem_1", + "namespace": "user/u/long_term", + "summary": "用户偏好中文输出。", + } + } + ] + }, + } + + def append_episode(self, payload): + return {"ok": True, "data": payload} + + def commit_session(self, session_id, payload): + return {"ok": True, "data": {"session_id": session_id}} + + +def test_lifecycle_hooks_do_not_crash_when_ctx_missing_features(): + result = register(object()) + + assert result["ok"] is True + assert result["mode"] == "manual" + + +def test_lifecycle_search_returns_compact_context(): + result = on_conversation_start( + {"user_id": "u", "agent_id": "a", "session_id": "s", "user_message": "之前偏好是什么?"}, + client=FakeClient(), + config=PluginConfig(auto_search=True), + ) + + assert result["ok"] is True + assert "用户偏好中文输出" in result["memory_context"] + + +def test_lifecycle_append_policy_accepts_stable_preference(): + result = after_user_message( + {"user_id": "u", "agent_id": "a", "session_id": "s", "user_message": "请记住:我偏好中文。"}, + client=FakeClient(), + config=PluginConfig(auto_append_episode=True), + ) + + assert result["ok"] is True + assert result["appended"] is True + + +def test_lifecycle_session_end_auto_commit_disabled(): + result = on_session_end( + {"user_id": "u", "agent_id": "a", "session_id": "s"}, + client=FakeClient(), + config=PluginConfig(auto_commit_session=False), + ) + + assert result["ok"] is True + assert result["committed"] is False + diff --git a/plugins/memory-gateway-agent/tests/test_output_redaction.py b/plugins/memory-gateway-agent/tests/test_output_redaction.py new file mode 100644 index 0000000..bfec896 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_output_redaction.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from memory_gateway_plugin.output import dumps_safe, redact, short_id + + +def test_output_redaction_hides_secret_fields(): + payload = { + "api_key": "sk-test", + "headers": {"Authorization": "Bearer abc"}, + "nested": {"cookie": "sid=abc"}, + "safe": "value", + } + + text = dumps_safe(payload) + + assert "sk-test" not in text + assert "Bearer abc" not in text + assert "sid=abc" not in text + assert "value" in text + assert redact("password=abc") == "" + + +def test_output_redaction_shortens_memory_ids(): + assert short_id("mem_1234567890abcdef") == "mem_1234...cdef" diff --git a/plugins/memory-gateway-agent/tests/test_policy.py b/plugins/memory-gateway-agent/tests/test_policy.py new file mode 100644 index 0000000..7c916d7 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_policy.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from memory_gateway_plugin.config import PluginConfig +from memory_gateway_plugin.policy import should_append_episode, should_commit_session, should_search_memory + + +def test_policy_should_append_for_explicit_remember(): + assert should_append_episode("请记住:我偏好中文技术说明。", "", {}, PluginConfig()) + + +def test_policy_should_not_append_for_small_talk(): + assert not should_append_episode("你好", "", {}, PluginConfig()) + + +def test_policy_should_search_when_enabled(): + assert should_search_memory("这个项目之前有什么约束?", {}, PluginConfig(auto_search=True)) + + +def test_policy_should_commit_only_when_enabled_or_forced(): + assert not should_commit_session({}, PluginConfig(auto_commit_session=False)) + assert should_commit_session({"force_commit": True}, PluginConfig(auto_commit_session=False)) + diff --git a/plugins/memory-gateway-agent/tests/test_safety.py b/plugins/memory-gateway-agent/tests/test_safety.py new file mode 100644 index 0000000..725e100 --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_safety.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from memory_gateway_plugin.safety import detect_large_log, sanitize_memory_content, validate_memory_write + + +def test_safety_rejects_private_key(): + content = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----" + result = validate_memory_write(content) + + assert result["allowed"] is False + assert result["reason"] == "secret_like_content" + + +def test_safety_rejects_large_log(): + content = "\n".join(f"2026-05-06 10:00:{i:02d} ERROR failure" for i in range(10)) + blocked, reason = detect_large_log(content) + + assert blocked is True + assert reason == "large_or_raw_log" + + +def test_safety_sanitizes_secret_when_called_directly(): + assert "sk-test" not in sanitize_memory_content("api_key=sk-test") + diff --git a/plugins/memory-gateway-agent/tests/test_tools.py b/plugins/memory-gateway-agent/tests/test_tools.py new file mode 100644 index 0000000..1c7965a --- /dev/null +++ b/plugins/memory-gateway-agent/tests/test_tools.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from memory_gateway_plugin.tools import memory_append_episode, memory_feedback, memory_search, memory_upsert + + +class FakeClient: + def __init__(self): + self.calls = [] + + def search_memory(self, payload): + self.calls.append(("search", payload)) + return {"ok": True, "data": {"results": []}} + + def append_episode(self, payload): + self.calls.append(("append", payload)) + return {"ok": True, "data": payload} + + def upsert_memory(self, payload): + self.calls.append(("upsert", payload)) + return {"ok": True, "data": payload} + + def send_feedback(self, memory_id, payload): + self.calls.append(("feedback", memory_id, payload)) + return {"ok": True, "data": payload} + + +def test_memory_search_empty_query_rejected(): + client = FakeClient() + result = memory_search(query="", user_id="u", agent_id="a", client=client) + + assert result["ok"] is False + assert client.calls == [] + + +def test_append_episode_rejects_api_key(): + result = memory_append_episode( + user_id="u", + agent_id="a", + session_id="s", + episode_summary="api_key=sk-secret", + client=FakeClient(), + ) + + assert result["ok"] is False + assert result["reason"] == "secret_like_content" + + +def test_append_episode_rejects_password(): + result = memory_append_episode( + user_id="u", + agent_id="a", + session_id="s", + episode_summary="password=hunter2", + client=FakeClient(), + ) + + assert result["ok"] is False + assert result["reason"] == "secret_like_content" + + +def test_append_episode_rejects_raw_transcript(): + content = "\n".join(["User: hi", "Assistant: hello", "User: remember this", "Assistant: ok"]) + result = memory_append_episode(user_id="u", agent_id="a", session_id="s", episode_summary=content, client=FakeClient()) + + assert result["ok"] is False + assert result["reason"] == "raw_chat_transcript" + + +def test_append_episode_accepts_stable_preference(): + client = FakeClient() + result = memory_append_episode( + user_id="u", + agent_id="a", + session_id="s", + episode_summary="用户稳定偏好:以后所有技术方案都使用中文输出。", + tags=["preference"], + client=client, + ) + + assert result["ok"] is True + assert client.calls[0][0] == "append" + + +def test_upsert_uses_long_term_namespace_when_provided(): + client = FakeClient() + namespace = "user/u/long_term" + result = memory_upsert( + user_id="u", + agent_id="a", + namespace=namespace, + memory_type="preference", + content="用户稳定偏好:使用中文输出。", + client=client, + ) + + assert result["ok"] is True + assert client.calls[0][1]["namespace"] == namespace + + +def test_feedback_calls_correct_endpoint(): + client = FakeClient() + result = memory_feedback(user_id="u", agent_id="a", memory_id="mem_1", feedback="reject", client=client) + + assert result["ok"] is True + assert client.calls[0] == ("feedback", "mem_1", {"user_id": "u", "agent_id": "a", "feedback": "incorrect", "comment": None}) + diff --git a/plugins/memory-gateway-agent/tools.py b/plugins/memory-gateway-agent/tools.py new file mode 100644 index 0000000..92e7db4 --- /dev/null +++ b/plugins/memory-gateway-agent/tools.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +from typing import Any + +try: + from .memory_gateway_plugin import tools as impl +except ImportError: + from memory_gateway_plugin import tools as impl + + +def _json_result(payload: dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, default=str) + + +def memory_search(args: dict[str, Any], **_: Any) -> str: + return _json_result(impl.memory_search(**args)) + + +def memory_append_episode(args: dict[str, Any], **_: Any) -> str: + return _json_result(impl.memory_append_episode(**args)) + + +def memory_commit_session(args: dict[str, Any], **_: Any) -> str: + return _json_result(impl.memory_commit_session(**args)) + + +def memory_upsert(args: dict[str, Any], **_: Any) -> str: + return _json_result(impl.memory_upsert(**args)) + + +def memory_feedback(args: dict[str, Any], **_: Any) -> str: + return _json_result(impl.memory_feedback(**args)) + + +HANDLERS = { + "memory_search": memory_search, + "memory_append_episode": memory_append_episode, + "memory_commit_session": memory_commit_session, + "memory_upsert": memory_upsert, + "memory_feedback": memory_feedback, +}