feat(supermemory): add multi-container, search_mode, identity template, and env var override (#5933)

Based on PR #5413 spec by MaheshtheDev (Mahesh Sanikommu).

Changes:
- Add search_mode config (hybrid/memories/documents) passed to SDK
- Add {identity} template support in container_tag for profile-scoped containers
- Add SUPERMEMORY_CONTAINER_TAG env var override (priority over config)
- Add multi-container mode: enable_custom_container_tags, custom_containers,
  custom_container_instructions in supermemory.json
- Dynamic tool schemas when multi-container enabled (optional container_tag param)
- Whitelist validation for custom container tags in tool calls
- Simplify get_config_schema() to only prompt for API key during setup
- Defer container_tag sanitization to initialize() (after template resolution)
- Add custom_id support to documents.add calls
- Update README with multi-container docs, search_mode, identity template,
  support links (Discord, email)
- Update memory-providers.md with new features and multi-container example
- Update memory-provider-plugin.md with minimal vs full schema guidance
- Add 12 new tests covering identity template, search_mode, multi-container,
  config schema, and env var override
This commit is contained in:
Teknium 2026-04-07 14:03:46 -07:00 committed by GitHub
parent 678a87c477
commit 7b18eeee9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 395 additions and 52 deletions

View file

@ -17,7 +17,7 @@ Or manually:
```bash ```bash
hermes config set memory.provider supermemory hermes config set memory.provider supermemory
echo 'SUPERMEMORY_API_KEY=your-key-here' >> ~/.hermes/.env echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
``` ```
## Config ## Config
@ -26,15 +26,23 @@ Config file: `$HERMES_HOME/supermemory.json`
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `container_tag` | `hermes` | Container tag used for search and writes | | `container_tag` | `hermes` | Container tag used for search and writes. Supports `{identity}` template for profile-scoped tags (e.g. `hermes-{identity}``hermes-coder`). |
| `auto_recall` | `true` | Inject relevant memory context before turns | | `auto_recall` | `true` | Inject relevant memory context before turns |
| `auto_capture` | `true` | Store cleaned user-assistant turns after each response | | `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
| `max_recall_results` | `10` | Max recalled items to format into context | | `max_recall_results` | `10` | Max recalled items to format into context |
| `profile_frequency` | `50` | Include profile facts on first turn and every N turns | | `profile_frequency` | `50` | Include profile facts on first turn and every N turns |
| `capture_mode` | `all` | Skip tiny or trivial turns by default | | `capture_mode` | `all` | Skip tiny or trivial turns by default |
| `search_mode` | `hybrid` | Search mode: `hybrid` (profile + memories), `memories` (memories only), `documents` (documents only) |
| `entity_context` | built-in default | Extraction guidance passed to Supermemory | | `entity_context` | built-in default | Extraction guidance passed to Supermemory |
| `api_timeout` | `5.0` | Timeout for SDK and ingest requests | | `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
### Environment Variables
| Variable | Description |
|----------|-------------|
| `SUPERMEMORY_API_KEY` | API key (required) |
| `SUPERMEMORY_CONTAINER_TAG` | Override container tag (takes priority over config file) |
## Tools ## Tools
| Tool | Description | | Tool | Description |
@ -52,3 +60,40 @@ When enabled, Hermes can:
- store cleaned conversation turns after each completed response - store cleaned conversation turns after each completed response
- ingest the full session on session end for richer graph updates - ingest the full session on session end for richer graph updates
- expose explicit tools for search, store, forget, and profile access - expose explicit tools for search, store, forget, and profile access
## Profile-Scoped Containers
Use `{identity}` in the `container_tag` to scope memories per Hermes profile:
```json
{
"container_tag": "hermes-{identity}"
}
```
For a profile named `coder`, this resolves to `hermes-coder`. The default profile resolves to `hermes-default`. Without `{identity}`, all profiles share the same container.
## Multi-Container Mode
For advanced setups (e.g. OpenClaw-style multi-workspace), you can enable custom container tags so the agent can read/write across multiple named containers:
```json
{
"container_tag": "hermes",
"enable_custom_container_tags": true,
"custom_containers": ["project-alpha", "project-beta", "shared-knowledge"],
"custom_container_instructions": "Use project-alpha for coding tasks, project-beta for research, and shared-knowledge for team-wide facts."
}
```
When enabled:
- `supermemory_search`, `supermemory_store`, `supermemory_forget`, and `supermemory_profile` accept an optional `container_tag` parameter
- The tag must be in the whitelist: primary container + `custom_containers`
- Automatic operations (turn sync, prefetch, memory write mirroring, session ingest) always use the **primary** container only
- Custom container instructions are injected into the system prompt
## Support
- [Supermemory Discord](https://supermemory.link/discord)
- [support@supermemory.com](mailto:support@supermemory.com)
- [supermemory.ai](https://supermemory.ai)

View file

@ -26,6 +26,8 @@ _DEFAULT_CONTAINER_TAG = "hermes"
_DEFAULT_MAX_RECALL_RESULTS = 10 _DEFAULT_MAX_RECALL_RESULTS = 10
_DEFAULT_PROFILE_FREQUENCY = 50 _DEFAULT_PROFILE_FREQUENCY = 50
_DEFAULT_CAPTURE_MODE = "all" _DEFAULT_CAPTURE_MODE = "all"
_DEFAULT_SEARCH_MODE = "hybrid"
_VALID_SEARCH_MODES = ("hybrid", "memories", "documents")
_DEFAULT_API_TIMEOUT = 5.0 _DEFAULT_API_TIMEOUT = 5.0
_MIN_CAPTURE_LENGTH = 10 _MIN_CAPTURE_LENGTH = 10
_MAX_ENTITY_CONTEXT_LENGTH = 1500 _MAX_ENTITY_CONTEXT_LENGTH = 1500
@ -59,8 +61,12 @@ def _default_config() -> dict:
"max_recall_results": _DEFAULT_MAX_RECALL_RESULTS, "max_recall_results": _DEFAULT_MAX_RECALL_RESULTS,
"profile_frequency": _DEFAULT_PROFILE_FREQUENCY, "profile_frequency": _DEFAULT_PROFILE_FREQUENCY,
"capture_mode": _DEFAULT_CAPTURE_MODE, "capture_mode": _DEFAULT_CAPTURE_MODE,
"search_mode": _DEFAULT_SEARCH_MODE,
"entity_context": _DEFAULT_ENTITY_CONTEXT, "entity_context": _DEFAULT_ENTITY_CONTEXT,
"api_timeout": _DEFAULT_API_TIMEOUT, "api_timeout": _DEFAULT_API_TIMEOUT,
"enable_custom_container_tags": False,
"custom_containers": [],
"custom_container_instructions": "",
} }
@ -100,7 +106,10 @@ def _load_supermemory_config(hermes_home: str) -> dict:
except Exception: except Exception:
logger.debug("Failed to parse %s", config_path, exc_info=True) logger.debug("Failed to parse %s", config_path, exc_info=True)
config["container_tag"] = _sanitize_tag(str(config.get("container_tag", _DEFAULT_CONTAINER_TAG))) # Keep raw container_tag — template variables like {identity} are resolved
# in initialize(), and _sanitize_tag runs AFTER resolution.
raw_tag = str(config.get("container_tag", _DEFAULT_CONTAINER_TAG)).strip()
config["container_tag"] = raw_tag if raw_tag else _DEFAULT_CONTAINER_TAG
config["auto_recall"] = _as_bool(config.get("auto_recall"), True) config["auto_recall"] = _as_bool(config.get("auto_recall"), True)
config["auto_capture"] = _as_bool(config.get("auto_capture"), True) config["auto_capture"] = _as_bool(config.get("auto_capture"), True)
try: try:
@ -112,11 +121,23 @@ def _load_supermemory_config(hermes_home: str) -> dict:
except Exception: except Exception:
config["profile_frequency"] = _DEFAULT_PROFILE_FREQUENCY config["profile_frequency"] = _DEFAULT_PROFILE_FREQUENCY
config["capture_mode"] = "everything" if config.get("capture_mode") == "everything" else "all" config["capture_mode"] = "everything" if config.get("capture_mode") == "everything" else "all"
raw_search_mode = str(config.get("search_mode", _DEFAULT_SEARCH_MODE)).strip().lower()
config["search_mode"] = raw_search_mode if raw_search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
config["entity_context"] = _clamp_entity_context(str(config.get("entity_context", _DEFAULT_ENTITY_CONTEXT))) config["entity_context"] = _clamp_entity_context(str(config.get("entity_context", _DEFAULT_ENTITY_CONTEXT)))
try: try:
config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT)))) config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT))))
except Exception: except Exception:
config["api_timeout"] = _DEFAULT_API_TIMEOUT config["api_timeout"] = _DEFAULT_API_TIMEOUT
# Multi-container support
config["enable_custom_container_tags"] = _as_bool(config.get("enable_custom_container_tags"), False)
raw_containers = config.get("custom_containers", [])
if isinstance(raw_containers, list):
config["custom_containers"] = [_sanitize_tag(str(t)) for t in raw_containers if t]
else:
config["custom_containers"] = []
config["custom_container_instructions"] = str(config.get("custom_container_instructions", "")).strip()
return config return config
@ -240,28 +261,41 @@ def _is_trivial_message(text: str) -> bool:
class _SupermemoryClient: class _SupermemoryClient:
def __init__(self, api_key: str, timeout: float, container_tag: str): def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
from supermemory import Supermemory from supermemory import Supermemory
self._api_key = api_key self._api_key = api_key
self._container_tag = container_tag self._container_tag = container_tag
self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
self._timeout = timeout self._timeout = timeout
self._client = Supermemory(api_key=api_key, timeout=timeout, max_retries=0) self._client = Supermemory(api_key=api_key, timeout=timeout, max_retries=0)
def add_memory(self, content: str, metadata: Optional[dict] = None, *, entity_context: str = "") -> dict: def add_memory(self, content: str, metadata: Optional[dict] = None, *,
kwargs = { entity_context: str = "", container_tag: Optional[str] = None,
custom_id: Optional[str] = None) -> dict:
tag = container_tag or self._container_tag
kwargs: dict[str, Any] = {
"content": content.strip(), "content": content.strip(),
"container_tags": [self._container_tag], "container_tags": [tag],
} }
if metadata: if metadata:
kwargs["metadata"] = metadata kwargs["metadata"] = metadata
if entity_context: if entity_context:
kwargs["entity_context"] = _clamp_entity_context(entity_context) kwargs["entity_context"] = _clamp_entity_context(entity_context)
if custom_id:
kwargs["custom_id"] = custom_id
result = self._client.documents.add(**kwargs) result = self._client.documents.add(**kwargs)
return {"id": getattr(result, "id", "")} return {"id": getattr(result, "id", "")}
def search_memories(self, query: str, *, limit: int = 5) -> list[dict]: def search_memories(self, query: str, *, limit: int = 5,
response = self._client.search.memories(q=query, container_tag=self._container_tag, limit=limit) container_tag: Optional[str] = None,
search_mode: Optional[str] = None) -> list[dict]:
tag = container_tag or self._container_tag
mode = search_mode or self._search_mode
kwargs: dict[str, Any] = {"q": query, "container_tag": tag, "limit": limit}
if mode in _VALID_SEARCH_MODES:
kwargs["search_mode"] = mode
response = self._client.search.memories(**kwargs)
results = [] results = []
for item in (getattr(response, "results", None) or []): for item in (getattr(response, "results", None) or []):
results.append({ results.append({
@ -273,8 +307,10 @@ class _SupermemoryClient:
}) })
return results return results
def get_profile(self, query: Optional[str] = None) -> dict: def get_profile(self, query: Optional[str] = None, *,
kwargs = {"container_tag": self._container_tag} container_tag: Optional[str] = None) -> dict:
tag = container_tag or self._container_tag
kwargs: dict[str, Any] = {"container_tag": tag}
if query: if query:
kwargs["q"] = query kwargs["q"] = query
response = self._client.profile(**kwargs) response = self._client.profile(**kwargs)
@ -296,18 +332,19 @@ class _SupermemoryClient:
}) })
return {"static": static, "dynamic": dynamic, "search_results": search_results} return {"static": static, "dynamic": dynamic, "search_results": search_results}
def forget_memory(self, memory_id: str) -> None: def forget_memory(self, memory_id: str, *, container_tag: Optional[str] = None) -> None:
self._client.memories.forget(container_tag=self._container_tag, id=memory_id) tag = container_tag or self._container_tag
self._client.memories.forget(container_tag=tag, id=memory_id)
def forget_by_query(self, query: str) -> dict: def forget_by_query(self, query: str, *, container_tag: Optional[str] = None) -> dict:
results = self.search_memories(query, limit=5) results = self.search_memories(query, limit=5, container_tag=container_tag)
if not results: if not results:
return {"success": False, "message": "No matching memory found to forget."} return {"success": False, "message": "No matching memory found to forget."}
target = results[0] target = results[0]
memory_id = target.get("id", "") memory_id = target.get("id", "")
if not memory_id: if not memory_id:
return {"success": False, "message": "Best matching memory has no id."} return {"success": False, "message": "Best matching memory has no id."}
self.forget_memory(memory_id) self.forget_memory(memory_id, container_tag=container_tag)
preview = (target.get("memory") or "")[:100] preview = (target.get("memory") or "")[:100]
return {"success": True, "message": f'Forgot: "{preview}"', "id": memory_id} return {"success": True, "message": f'Forgot: "{preview}"', "id": memory_id}
@ -398,11 +435,17 @@ class SupermemoryMemoryProvider(MemoryProvider):
self._max_recall_results = _DEFAULT_MAX_RECALL_RESULTS self._max_recall_results = _DEFAULT_MAX_RECALL_RESULTS
self._profile_frequency = _DEFAULT_PROFILE_FREQUENCY self._profile_frequency = _DEFAULT_PROFILE_FREQUENCY
self._capture_mode = _DEFAULT_CAPTURE_MODE self._capture_mode = _DEFAULT_CAPTURE_MODE
self._search_mode = _DEFAULT_SEARCH_MODE
self._entity_context = _DEFAULT_ENTITY_CONTEXT self._entity_context = _DEFAULT_ENTITY_CONTEXT
self._api_timeout = _DEFAULT_API_TIMEOUT self._api_timeout = _DEFAULT_API_TIMEOUT
self._hermes_home = "" self._hermes_home = ""
self._write_enabled = True self._write_enabled = True
self._active = False self._active = False
# Multi-container support
self._enable_custom_containers = False
self._custom_containers: List[str] = []
self._custom_container_instructions = ""
self._allowed_containers: List[str] = []
@property @property
def name(self) -> str: def name(self) -> str:
@ -419,16 +462,11 @@ class SupermemoryMemoryProvider(MemoryProvider):
return False return False
def get_config_schema(self): def get_config_schema(self):
# Only prompt for the API key during `hermes memory setup`.
# All other options are documented for $HERMES_HOME/supermemory.json
# or the SUPERMEMORY_CONTAINER_TAG env var.
return [ return [
{"key": "api_key", "description": "Supermemory API key", "secret": True, "required": True, "env_var": "SUPERMEMORY_API_KEY", "url": "https://supermemory.ai"}, {"key": "api_key", "description": "Supermemory API key", "secret": True, "required": True, "env_var": "SUPERMEMORY_API_KEY", "url": "https://supermemory.ai"},
{"key": "container_tag", "description": "Container tag for reads and writes", "default": _DEFAULT_CONTAINER_TAG},
{"key": "auto_recall", "description": "Enable automatic recall before each turn", "default": "true", "choices": ["true", "false"]},
{"key": "auto_capture", "description": "Enable automatic capture after each completed turn", "default": "true", "choices": ["true", "false"]},
{"key": "max_recall_results", "description": "Maximum recalled items to inject", "default": str(_DEFAULT_MAX_RECALL_RESULTS)},
{"key": "profile_frequency", "description": "Include profile facts on first turn and every N turns", "default": str(_DEFAULT_PROFILE_FREQUENCY)},
{"key": "capture_mode", "description": "Capture mode", "default": _DEFAULT_CAPTURE_MODE, "choices": ["all", "everything"]},
{"key": "entity_context", "description": "Extraction guidance passed to Supermemory", "default": _DEFAULT_ENTITY_CONTEXT},
{"key": "api_timeout", "description": "Timeout in seconds for SDK and ingest calls", "default": str(_DEFAULT_API_TIMEOUT)},
] ]
def save_config(self, values, hermes_home): def save_config(self, values, hermes_home):
@ -446,14 +484,29 @@ class SupermemoryMemoryProvider(MemoryProvider):
self._turn_count = 0 self._turn_count = 0
self._config = _load_supermemory_config(self._hermes_home) self._config = _load_supermemory_config(self._hermes_home)
self._api_key = os.environ.get("SUPERMEMORY_API_KEY", "") self._api_key = os.environ.get("SUPERMEMORY_API_KEY", "")
self._container_tag = self._config["container_tag"]
# Resolve container tag: env var > config > default.
# Supports {identity} template for profile-scoped containers.
env_tag = os.environ.get("SUPERMEMORY_CONTAINER_TAG", "").strip()
raw_tag = env_tag or self._config["container_tag"]
identity = kwargs.get("agent_identity", "default")
self._container_tag = _sanitize_tag(raw_tag.replace("{identity}", identity))
self._auto_recall = self._config["auto_recall"] self._auto_recall = self._config["auto_recall"]
self._auto_capture = self._config["auto_capture"] self._auto_capture = self._config["auto_capture"]
self._max_recall_results = self._config["max_recall_results"] self._max_recall_results = self._config["max_recall_results"]
self._profile_frequency = self._config["profile_frequency"] self._profile_frequency = self._config["profile_frequency"]
self._capture_mode = self._config["capture_mode"] self._capture_mode = self._config["capture_mode"]
self._search_mode = self._config["search_mode"]
self._entity_context = self._config["entity_context"] self._entity_context = self._config["entity_context"]
self._api_timeout = self._config["api_timeout"] self._api_timeout = self._config["api_timeout"]
# Multi-container setup
self._enable_custom_containers = self._config["enable_custom_container_tags"]
self._custom_containers = self._config["custom_containers"]
self._custom_container_instructions = self._config["custom_container_instructions"]
self._allowed_containers = [self._container_tag] + list(self._custom_containers)
agent_context = kwargs.get("agent_context", "") agent_context = kwargs.get("agent_context", "")
self._write_enabled = agent_context not in ("cron", "flush", "subagent") self._write_enabled = agent_context not in ("cron", "flush", "subagent")
self._active = bool(self._api_key) self._active = bool(self._api_key)
@ -464,6 +517,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
api_key=self._api_key, api_key=self._api_key,
timeout=self._api_timeout, timeout=self._api_timeout,
container_tag=self._container_tag, container_tag=self._container_tag,
search_mode=self._search_mode,
) )
except Exception: except Exception:
logger.warning("Supermemory initialization failed", exc_info=True) logger.warning("Supermemory initialization failed", exc_info=True)
@ -476,11 +530,18 @@ class SupermemoryMemoryProvider(MemoryProvider):
def system_prompt_block(self) -> str: def system_prompt_block(self) -> str:
if not self._active: if not self._active:
return "" return ""
return ( lines = [
"# Supermemory\n" "# Supermemory",
f"Active. Container: {self._container_tag}.\n" f"Active. Container: {self._container_tag}.",
"Use supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile for explicit memory operations." "Use supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile for explicit memory operations.",
) ]
if self._enable_custom_containers and self._custom_containers:
tags_str = ", ".join(self._allowed_containers)
lines.append(f"\nMulti-container mode enabled. Available containers: {tags_str}.")
lines.append("Pass an optional container_tag to supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile to target a specific container.")
if self._custom_container_instructions:
lines.append(f"\n{self._custom_container_instructions}")
return "\n".join(lines)
def prefetch(self, query: str, *, session_id: str = "") -> str: def prefetch(self, query: str, *, session_id: str = "") -> str:
if not self._active or not self._auto_recall or not self._client or not query.strip(): if not self._active or not self._auto_recall or not self._client or not query.strip():
@ -582,22 +643,62 @@ class SupermemoryMemoryProvider(MemoryProvider):
thread.join(timeout=5.0) thread.join(timeout=5.0)
setattr(self, attr_name, None) setattr(self, attr_name, None)
def _resolve_tool_container_tag(self, args: dict) -> Optional[str]:
"""Validate and resolve container_tag from tool call args.
Returns None (use primary) if multi-container is disabled or no tag provided.
Returns the validated tag if it's in the allowed list.
Raises ValueError if the tag is not whitelisted.
"""
if not self._enable_custom_containers:
return None
tag = str(args.get("container_tag") or "").strip()
if not tag:
return None
sanitized = _sanitize_tag(tag)
if sanitized not in self._allowed_containers:
raise ValueError(
f"Container tag '{sanitized}' is not allowed. "
f"Allowed: {', '.join(self._allowed_containers)}"
)
return sanitized
def get_tool_schemas(self) -> List[Dict[str, Any]]: def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA] if not self._enable_custom_containers:
return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA]
# When multi-container is enabled, add optional container_tag to relevant tools
container_param = {
"type": "string",
"description": f"Optional container tag. Allowed: {', '.join(self._allowed_containers)}. Defaults to primary ({self._container_tag}).",
}
schemas = []
for base in [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA]:
schema = json.loads(json.dumps(base)) # deep copy
schema["parameters"]["properties"]["container_tag"] = container_param
schemas.append(schema)
return schemas
def _tool_store(self, args: dict) -> str: def _tool_store(self, args: dict) -> str:
content = str(args.get("content") or "").strip() content = str(args.get("content") or "").strip()
if not content: if not content:
return tool_error("content is required") return tool_error("content is required")
try:
tag = self._resolve_tool_container_tag(args)
except ValueError as exc:
return tool_error(str(exc))
metadata = args.get("metadata") or {} metadata = args.get("metadata") or {}
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
metadata = {} metadata = {}
metadata.setdefault("type", _detect_category(content)) metadata.setdefault("type", _detect_category(content))
metadata["source"] = "hermes_tool" metadata["source"] = "hermes_tool"
try: try:
result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context) result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context, container_tag=tag)
preview = content[:80] + ("..." if len(content) > 80 else "") preview = content[:80] + ("..." if len(content) > 80 else "")
return json.dumps({"saved": True, "id": result.get("id", ""), "preview": preview}) resp: dict[str, Any] = {"saved": True, "id": result.get("id", ""), "preview": preview}
if tag:
resp["container_tag"] = tag
return json.dumps(resp)
except Exception as exc: except Exception as exc:
return tool_error(f"Failed to store memory: {exc}") return tool_error(f"Failed to store memory: {exc}")
@ -605,22 +706,29 @@ class SupermemoryMemoryProvider(MemoryProvider):
query = str(args.get("query") or "").strip() query = str(args.get("query") or "").strip()
if not query: if not query:
return tool_error("query is required") return tool_error("query is required")
try:
tag = self._resolve_tool_container_tag(args)
except ValueError as exc:
return tool_error(str(exc))
try: try:
limit = max(1, min(20, int(args.get("limit", 5) or 5))) limit = max(1, min(20, int(args.get("limit", 5) or 5)))
except Exception: except Exception:
limit = 5 limit = 5
try: try:
results = self._client.search_memories(query, limit=limit) results = self._client.search_memories(query, limit=limit, container_tag=tag)
formatted = [] formatted = []
for item in results: for item in results:
entry = {"id": item.get("id", ""), "content": item.get("memory", "")} entry: dict[str, Any] = {"id": item.get("id", ""), "content": item.get("memory", "")}
if item.get("similarity") is not None: if item.get("similarity") is not None:
try: try:
entry["similarity"] = round(float(item["similarity"]) * 100) entry["similarity"] = round(float(item["similarity"]) * 100)
except Exception: except Exception:
pass pass
formatted.append(entry) formatted.append(entry)
return json.dumps({"results": formatted, "count": len(formatted)}) resp: dict[str, Any] = {"results": formatted, "count": len(formatted)}
if tag:
resp["container_tag"] = tag
return json.dumps(resp)
except Exception as exc: except Exception as exc:
return tool_error(f"Search failed: {exc}") return tool_error(f"Search failed: {exc}")
@ -629,28 +737,39 @@ class SupermemoryMemoryProvider(MemoryProvider):
query = str(args.get("query") or "").strip() query = str(args.get("query") or "").strip()
if not memory_id and not query: if not memory_id and not query:
return tool_error("Provide either id or query") return tool_error("Provide either id or query")
try:
tag = self._resolve_tool_container_tag(args)
except ValueError as exc:
return tool_error(str(exc))
try: try:
if memory_id: if memory_id:
self._client.forget_memory(memory_id) self._client.forget_memory(memory_id, container_tag=tag)
return json.dumps({"forgotten": True, "id": memory_id}) return json.dumps({"forgotten": True, "id": memory_id})
return json.dumps(self._client.forget_by_query(query)) return json.dumps(self._client.forget_by_query(query, container_tag=tag))
except Exception as exc: except Exception as exc:
return tool_error(f"Forget failed: {exc}") return tool_error(f"Forget failed: {exc}")
def _tool_profile(self, args: dict) -> str: def _tool_profile(self, args: dict) -> str:
query = str(args.get("query") or "").strip() or None query = str(args.get("query") or "").strip() or None
try: try:
profile = self._client.get_profile(query=query) tag = self._resolve_tool_container_tag(args)
except ValueError as exc:
return tool_error(str(exc))
try:
profile = self._client.get_profile(query=query, container_tag=tag)
sections = [] sections = []
if profile["static"]: if profile["static"]:
sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in profile["static"])) sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in profile["static"]))
if profile["dynamic"]: if profile["dynamic"]:
sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in profile["dynamic"])) sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in profile["dynamic"]))
return json.dumps({ resp: dict[str, Any] = {
"profile": "\n\n".join(sections), "profile": "\n\n".join(sections),
"static_count": len(profile["static"]), "static_count": len(profile["static"]),
"dynamic_count": len(profile["dynamic"]), "dynamic_count": len(profile["dynamic"]),
}) }
if tag:
resp["container_tag"] = tag
return json.dumps(resp)
except Exception as exc: except Exception as exc:
return tool_error(f"Profile failed: {exc}") return tool_error(f"Profile failed: {exc}")

View file

@ -13,10 +13,11 @@ from plugins.memory.supermemory import (
class FakeClient: class FakeClient:
def __init__(self, api_key: str, timeout: float, container_tag: str): def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
self.api_key = api_key self.api_key = api_key
self.timeout = timeout self.timeout = timeout
self.container_tag = container_tag self.container_tag = container_tag
self.search_mode = search_mode
self.add_calls = [] self.add_calls = []
self.search_results = [] self.search_results = []
self.profile_response = {"static": [], "dynamic": [], "search_results": []} self.profile_response = {"static": [], "dynamic": [], "search_results": []}
@ -24,24 +25,27 @@ class FakeClient:
self.forgotten_ids = [] self.forgotten_ids = []
self.forget_by_query_response = {"success": True, "message": "Forgot"} self.forget_by_query_response = {"success": True, "message": "Forgot"}
def add_memory(self, content, metadata=None, *, entity_context=""): def add_memory(self, content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
self.add_calls.append({ self.add_calls.append({
"content": content, "content": content,
"metadata": metadata, "metadata": metadata,
"entity_context": entity_context, "entity_context": entity_context,
"container_tag": container_tag,
"custom_id": custom_id,
}) })
return {"id": "mem_123"} return {"id": "mem_123"}
def search_memories(self, query, *, limit=5): def search_memories(self, query, *, limit=5, container_tag=None, search_mode=None):
return self.search_results return self.search_results
def get_profile(self, query=None): def get_profile(self, query=None, *, container_tag=None):
return self.profile_response return self.profile_response
def forget_memory(self, memory_id): def forget_memory(self, memory_id, *, container_tag=None):
self.forgotten_ids.append(memory_id) self.forgotten_ids.append(memory_id)
def forget_by_query(self, query): def forget_by_query(self, query, *, container_tag=None):
return self.forget_by_query_response return self.forget_by_query_response
def ingest_conversation(self, session_id, messages): def ingest_conversation(self, session_id, messages):
@ -82,7 +86,8 @@ def test_is_available_false_when_import_missing(monkeypatch):
def test_load_and_save_config_round_trip(tmp_path): def test_load_and_save_config_round_trip(tmp_path):
_save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path)) _save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path))
cfg = _load_supermemory_config(str(tmp_path)) cfg = _load_supermemory_config(str(tmp_path))
assert cfg["container_tag"] == "demo_tag" # container_tag is kept raw — sanitization happens in initialize() after template resolution
assert cfg["container_tag"] == "demo-tag"
assert cfg["auto_capture"] is False assert cfg["auto_capture"] is False
assert cfg["auto_recall"] is True assert cfg["auto_recall"] is True
@ -176,7 +181,8 @@ def test_shutdown_joins_and_clears_threads(provider, monkeypatch):
started = threading.Event() started = threading.Event()
release = threading.Event() release = threading.Event()
def slow_add_memory(content, metadata=None, *, entity_context=""): def slow_add_memory(content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
started.set() started.set()
release.wait(timeout=1) release.wait(timeout=1)
provider._client.add_calls.append({ provider._client.add_calls.append({
@ -255,3 +261,151 @@ def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch):
p = SupermemoryMemoryProvider() p = SupermemoryMemoryProvider()
result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"})) result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"}))
assert "error" in result assert "error" in result
# -- Identity template tests --------------------------------------------------
def test_identity_template_resolved_in_container_tag(monkeypatch, tmp_path):
"""container_tag with {identity} resolves to profile-scoped tag."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli", agent_identity="coder")
assert p._container_tag == "hermes_coder"
def test_identity_template_default_profile(monkeypatch, tmp_path):
"""Without agent_identity kwarg, {identity} resolves to 'default'."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._container_tag == "hermes_default"
def test_container_tag_env_var_override(monkeypatch, tmp_path):
"""SUPERMEMORY_CONTAINER_TAG env var overrides config."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setenv("SUPERMEMORY_CONTAINER_TAG", "env-override")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._container_tag == "env_override"
# -- Search mode tests --------------------------------------------------------
def test_search_mode_config_passed_to_client(monkeypatch, tmp_path):
"""search_mode from config is passed to _SupermemoryClient."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"search_mode": "memories"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._search_mode == "memories"
assert p._client.search_mode == "memories"
def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
"""Invalid search_mode falls back to 'hybrid'."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"search_mode": "invalid_mode"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._search_mode == "hybrid"
# -- Multi-container tests ----------------------------------------------------
def test_multi_container_disabled_by_default(provider):
"""Multi-container is off by default; schemas have no container_tag param."""
assert provider._enable_custom_containers is False
schemas = provider.get_tool_schemas()
for s in schemas:
assert "container_tag" not in s["parameters"]["properties"]
def test_multi_container_enabled_adds_schema_param(monkeypatch, tmp_path):
"""When enabled, tool schemas include container_tag parameter."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["project-alpha", "shared"],
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._enable_custom_containers is True
assert p._allowed_containers == ["hermes", "project_alpha", "shared"]
schemas = p.get_tool_schemas()
for s in schemas:
assert "container_tag" in s["parameters"]["properties"]
def test_multi_container_tool_store_with_custom_tag(monkeypatch, tmp_path):
"""supermemory_store uses the resolved container_tag when multi-container is enabled."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["project-alpha"],
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
result = json.loads(p.handle_tool_call("supermemory_store", {
"content": "test memory",
"container_tag": "project-alpha",
}))
assert result["saved"] is True
assert result["container_tag"] == "project_alpha"
assert p._client.add_calls[-1]["container_tag"] == "project_alpha"
def test_multi_container_rejects_unlisted_tag(monkeypatch, tmp_path):
"""Tool calls with a non-whitelisted container_tag return an error."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["allowed-tag"],
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
result = json.loads(p.handle_tool_call("supermemory_store", {
"content": "test",
"container_tag": "forbidden-tag",
}))
assert "error" in result
assert "not allowed" in result["error"]
def test_multi_container_system_prompt_includes_instructions(monkeypatch, tmp_path):
"""system_prompt_block includes container list and instructions when multi-container is enabled."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["docs"],
"custom_container_instructions": "Use docs for documentation context.",
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
block = p.system_prompt_block()
assert "Multi-container mode enabled" in block
assert "docs" in block
assert "Use docs for documentation context." in block
def test_get_config_schema_minimal():
"""get_config_schema only returns the API key field."""
p = SupermemoryMemoryProvider()
schema = p.get_config_schema()
assert len(schema) == 1
assert schema[0]["key"] == "api_key"
assert schema[0]["secret"] is True

View file

@ -110,6 +110,10 @@ def get_config_schema(self):
Fields with `secret: True` and `env_var` go to `.env`. Non-secret fields are passed to `save_config()`. Fields with `secret: True` and `env_var` go to `.env`. Non-secret fields are passed to `save_config()`.
:::tip Minimal vs Full Schema
Every field in `get_config_schema()` is prompted during `hermes memory setup`. Providers with many options should keep the schema minimal — only include fields the user **must** configure (API key, required credentials). Document optional settings in a config file reference (e.g. `$HERMES_HOME/myprovider.json`) rather than prompting for them all during setup. This keeps the setup wizard fast while still supporting advanced configuration. See the Supermemory provider for an example — it only prompts for the API key; all other options live in `supermemory.json`.
:::
## Save Config ## Save Config
```python ```python

View file

@ -400,26 +400,47 @@ Semantic long-term memory with profile recall, semantic search, explicit memory
hermes memory setup # select "supermemory" hermes memory setup # select "supermemory"
# Or manually: # Or manually:
hermes config set memory.provider supermemory hermes config set memory.provider supermemory
echo 'SUPERMEMORY_API_KEY=your-key-here' >> ~/.hermes/.env echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
``` ```
**Config:** `$HERMES_HOME/supermemory.json` **Config:** `$HERMES_HOME/supermemory.json`
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `container_tag` | `hermes` | Container tag used for search and writes | | `container_tag` | `hermes` | Container tag used for search and writes. Supports `{identity}` template for profile-scoped tags. |
| `auto_recall` | `true` | Inject relevant memory context before turns | | `auto_recall` | `true` | Inject relevant memory context before turns |
| `auto_capture` | `true` | Store cleaned user-assistant turns after each response | | `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
| `max_recall_results` | `10` | Max recalled items to format into context | | `max_recall_results` | `10` | Max recalled items to format into context |
| `profile_frequency` | `50` | Include profile facts on first turn and every N turns | | `profile_frequency` | `50` | Include profile facts on first turn and every N turns |
| `capture_mode` | `all` | Skip tiny or trivial turns by default | | `capture_mode` | `all` | Skip tiny or trivial turns by default |
| `search_mode` | `hybrid` | Search mode: `hybrid`, `memories`, or `documents` |
| `api_timeout` | `5.0` | Timeout for SDK and ingest requests | | `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
**Environment variables:** `SUPERMEMORY_API_KEY` (required), `SUPERMEMORY_CONTAINER_TAG` (overrides config).
**Key features:** **Key features:**
- Automatic context fencing — strips recalled memories from captured turns to prevent recursive memory pollution - Automatic context fencing — strips recalled memories from captured turns to prevent recursive memory pollution
- Session-end conversation ingest for richer graph-level knowledge building - Session-end conversation ingest for richer graph-level knowledge building
- Profile facts injected on first turn and at configurable intervals - Profile facts injected on first turn and at configurable intervals
- Trivial message filtering (skips "ok", "thanks", etc.) - Trivial message filtering (skips "ok", "thanks", etc.)
- **Profile-scoped containers** — use `{identity}` in `container_tag` (e.g. `hermes-{identity}``hermes-coder`) to isolate memories per Hermes profile
- **Multi-container mode** — enable `enable_custom_container_tags` with a `custom_containers` list to let the agent read/write across named containers. Automatic operations (sync, prefetch) stay on the primary container.
<details>
<summary>Multi-container example</summary>
```json
{
"container_tag": "hermes",
"enable_custom_container_tags": true,
"custom_containers": ["project-alpha", "shared-knowledge"],
"custom_container_instructions": "Use project-alpha for coding context."
}
```
</details>
**Support:** [Discord](https://supermemory.link/discord) · [support@supermemory.com](mailto:support@supermemory.com)
--- ---
@ -434,7 +455,7 @@ echo 'SUPERMEMORY_API_KEY=your-key-here' >> ~/.hermes/.env
| **Holographic** | Local | Free | 2 | None | HRR algebra + trust scoring | | **Holographic** | Local | Free | 2 | None | HRR algebra + trust scoring |
| **RetainDB** | Cloud | $20/mo | 5 | `requests` | Delta compression | | **RetainDB** | Cloud | $20/mo | 5 | `requests` | Delta compression |
| **ByteRover** | Local/Cloud | Free/Paid | 3 | `brv` CLI | Pre-compression extraction | | **ByteRover** | Local/Cloud | Free/Paid | 3 | `brv` CLI | Pre-compression extraction |
| **Supermemory** | Cloud | Paid | 4 | `supermemory` | Context fencing + session graph ingest | | **Supermemory** | Cloud | Paid | 4 | `supermemory` | Context fencing + session graph ingest + multi-container |
## Profile Isolation ## Profile Isolation