Add Memory Gateway agent plugin

This commit is contained in:
2026-05-06 16:10:04 +08:00
parent e65731a273
commit c44af407d4
48 changed files with 3111 additions and 0 deletions

View File

@ -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
```

View File

@ -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)

View File

@ -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.

View File

@ -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,
}

View File

@ -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)

View File

@ -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()

View File

@ -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}

View File

@ -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: ("<redacted>" 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 "<redacted>"
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)

View File

@ -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"))

View File

@ -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=<redacted>", sanitized, flags=re.I)
sanitized = re.sub(r"\bbearer\s+[a-z0-9._\-]{12,}", "Bearer <redacted>", sanitized, flags=re.I)
sanitized = re.sub(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", "<redacted-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)}

View File

@ -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)

View File

@ -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,
}

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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,
}

View File

@ -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())

View File

@ -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())

View File

@ -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()

View File

@ -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())

View File

@ -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())

View File

@ -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()

View File

@ -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()

View File

@ -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")

View File

@ -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_")

View File

@ -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"

View File

@ -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": [],
}

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 == []

View File

@ -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") == {}

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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") == "<redacted>"
def test_output_redaction_shortens_memory_ids():
assert short_id("mem_1234567890abcdef") == "mem_1234...cdef"

View File

@ -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))

View File

@ -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")

View File

@ -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})

View File

@ -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,
}