Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions plugins/memory/supermemory/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Supermemory Memory Provider

Semantic long-term memory with profile recall, semantic search, explicit memory tools, and session-end conversation ingest.
Semantic long-term memory with profile recall, semantic search, explicit memory tools, and full-session conversation ingest (one ingest per session) for richer profiles.

## Requirements

Expand Down Expand Up @@ -45,22 +45,34 @@ Config file: `$HERMES_HOME/supermemory.json`

## Tools

| Tool | Description |
|------|-------------|
| `supermemory_store` | Store an explicit memory |
| `supermemory_search` | Search memories by semantic similarity |
| `supermemory_forget` | Forget a memory by ID or best-match query |
| `supermemory_profile` | Retrieve persistent profile and recent context |
Kebab-case names are registered for the agent; snake_case aliases remain supported.

| Tool | Alias | Description |
|------|-------|-------------|
| `supermemory-save` | `supermemory_store` | Store an explicit memory |
| `supermemory-search` | `supermemory_search` | Search memories by semantic similarity |
| `supermemory-forget` | `supermemory_forget` | Forget a memory by ID or best-match query |
| `supermemory-profile` | `supermemory_profile` | Retrieve persistent profile and recent context |

## Source attribution

All Supermemory API calls send `x-sm-source: hermes`, and document writes stamp
`metadata.sm_source: hermes`. This is a **functional routing key, not telemetry**:
it groups Hermes-written memories into a dedicated "Hermes" Space in the
Supermemory app, so you can filter, browse, and bulk-manage them per source agent
(alongside Codex, Claude Code, etc.) from the Supermemory UI.

## Behavior

When enabled, Hermes can:

- prefetch relevant memory context before each turn
- store cleaned conversation turns after each completed response
- ingest the full session on session end for richer graph updates
- buffer the full conversation and ingest it as **one session** at session end (or on `/reset`, branch, compression, or shutdown)
- ingest the full session to the conversations endpoint for richer profile/graph updates
- expose explicit tools for search, store, forget, and profile access

The session is written once via the conversations endpoint, which drives Supermemory's entity extraction and profile building while keeping a clean, retrievable full transcript.

## Profile-Scoped Containers

Use `{identity}` in the `container_tag` to scope memories per Hermes profile:
Expand All @@ -87,7 +99,7 @@ For advanced setups (e.g. OpenClaw-style multi-workspace), you can enable custom
```

When enabled:
- `supermemory_search`, `supermemory_store`, `supermemory_forget`, and `supermemory_profile` accept an optional `container_tag` parameter
- `supermemory-search`, `supermemory-save`, `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
Expand Down
179 changes: 142 additions & 37 deletions plugins/memory/supermemory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,22 @@ def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode
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._client = Supermemory(api_key=api_key, timeout=timeout, max_retries=0)
self._client = Supermemory(
api_key=api_key,
timeout=timeout,
max_retries=0,
default_headers={"x-sm-source": "hermes"},
)

def _merge_metadata(self, metadata: Optional[dict]) -> dict:
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
# app so the user can filter / bulk-manage them per source agent. This is a
# functional routing key for the user, not vendor telemetry.
merged = {"sm_source": "hermes", **(metadata or {})}
legacy_source = merged.pop("source", None)
if legacy_source and "type" not in merged:
merged["type"] = str(legacy_source)
return merged

def add_memory(self, content: str, metadata: Optional[dict] = None, *,
entity_context: str = "", container_tag: Optional[str] = None,
Expand All @@ -280,7 +295,7 @@ def add_memory(self, content: str, metadata: Optional[dict] = None, *,
"container_tags": [tag],
}
if metadata:
kwargs["metadata"] = metadata
kwargs["metadata"] = self._merge_metadata(metadata)
if entity_context:
kwargs["entity_context"] = _clamp_entity_context(entity_context)
if custom_id:
Expand Down Expand Up @@ -349,18 +364,22 @@ def forget_by_query(self, query: str, *, container_tag: Optional[str] = None) ->
preview = (target.get("memory") or "")[:100]
return {"success": True, "message": f'Forgot: "{preview}"', "id": memory_id}

def ingest_conversation(self, session_id: str, messages: list[dict]) -> None:
payload = json.dumps({
def ingest_conversation(self, session_id: str, messages: list[dict], metadata: dict | None = None) -> None:
payload: dict = {
"conversationId": session_id,
"messages": messages,
"containerTags": [self._container_tag],
}).encode("utf-8")
}
if metadata:
payload["metadata"] = self._merge_metadata(metadata)

req = urllib.request.Request(
_CONVERSATIONS_URL,
data=payload,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"x-sm-source": "hermes",
},
method="POST",
)
Expand Down Expand Up @@ -447,6 +466,7 @@ def __init__(self):
self._custom_containers: List[str] = []
self._custom_container_instructions = ""
self._allowed_containers: List[str] = []
self._session_turns: List[Dict[str, str]] = []

@property
def name(self) -> str:
Expand Down Expand Up @@ -501,13 +521,13 @@ def initialize(self, session_id: str, **kwargs) -> None:
self._search_mode = self._config["search_mode"]
self._entity_context = self._config["entity_context"]
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)

self._session_turns = []

agent_context = kwargs.get("agent_context", "")
self._write_enabled = agent_context not in {"cron", "flush", "subagent"}
self._active = bool(self._api_key)
Expand All @@ -534,7 +554,7 @@ def system_prompt_block(self) -> str:
lines = [
"# Supermemory",
f"Active. Container: {self._container_tag}.",
"Use supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile for explicit memory operations.",
"Use supermemory-search, supermemory-save, supermemory-forget, and supermemory-profile (aliases: supermemory_search, supermemory_store, supermemory_forget, supermemory_profile).",
]
if self._enable_custom_containers and self._custom_containers:
tags_str = ", ".join(self._allowed_containers)
Expand Down Expand Up @@ -567,31 +587,11 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st

clean_user = _clean_text_for_capture(user_content)
clean_assistant = _clean_text_for_capture(assistant_content)
if not clean_user or not clean_assistant:
if not clean_user and not clean_assistant:
return
if self._capture_mode == "all":
if len(clean_user) < _MIN_CAPTURE_LENGTH or len(clean_assistant) < _MIN_CAPTURE_LENGTH:
return
if _is_trivial_message(clean_user):
return

content = (
f"[role: user]\n{clean_user}\n[user:end]\n\n"
f"[role: assistant]\n{clean_assistant}\n[assistant:end]"
)
metadata = {"source": "hermes", "type": "conversation_turn"}

def _run():
try:
self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context)
except Exception:
logger.debug("Supermemory sync_turn failed", exc_info=True)

if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=2.0)
self._sync_thread = None
self._sync_thread = threading.Thread(target=_run, daemon=True, name="supermemory-sync")
self._sync_thread.start()
# Buffer every turn for the single full-session document written at end/switch/shutdown
self._session_turns.append({"user": clean_user, "assistant": clean_assistant})

def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if not self._active or not self._write_enabled or not self._client or not self._session_id:
Expand All @@ -609,12 +609,68 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if len(cleaned) == 1 and len(cleaned[0].get("content", "")) < 20:
return
try:
self._client.ingest_conversation(self._session_id, cleaned)
self._client.ingest_conversation(
self._session_id,
cleaned,
metadata={
"type": "full_session",
"session_id": self._session_id,
"message_count": len(cleaned),
},
)
except urllib.error.HTTPError:
logger.warning("Supermemory session ingest failed", exc_info=True)
except Exception:
logger.warning("Supermemory session ingest failed", exc_info=True)

# Clear buffer so shutdown() doesn't duplicate on normal exit
self._session_turns = []

def on_session_switch(
self,
new_session_id: str,
*,
parent_session_id: str = "",
reset: bool = False,
**kwargs,
) -> None:
"""Flush any buffered turns from the old session as one document, then reset for the new session."""
if not self._active or not self._write_enabled or not self._client:
self._session_id = str(new_session_id or "").strip() or self._session_id
self._session_turns = []
return

old_session_id = self._session_id
old_turns = list(self._session_turns)

# Flush previous session via conversations ingest (with metadata)
if old_turns and old_session_id:
messages: list[dict] = []
for turn in old_turns:
if turn.get("user"):
messages.append({"role": "user", "content": turn["user"]})
if turn.get("assistant"):
messages.append({"role": "assistant", "content": turn["assistant"]})

try:
self._client.ingest_conversation(
old_session_id,
messages,
metadata={
"type": "full_session",
"session_id": old_session_id,
"message_count": len(old_turns) * 2,
"partial": not reset,
},
)
except Exception:
logger.debug("Supermemory session-switch ingest failed", exc_info=True)

# Reset for new session
self._session_id = str(new_session_id or "").strip() or old_session_id
self._session_turns = []
self._turn_count = 0

def on_memory_write(self, action: str, target: str, content: str) -> None:
if not self._active or not self._write_enabled or not self._client:
return
Expand All @@ -625,7 +681,7 @@ def _run():
try:
self._client.add_memory(
content.strip(),
metadata={"source": "hermes_memory", "target": target, "type": "explicit_memory"},
metadata={"target": target, "type": "explicit_memory"},
entity_context=self._entity_context,
)
except Exception:
Expand All @@ -638,6 +694,31 @@ def _run():
self._write_thread.start()

def shutdown(self) -> None:
# Emergency fallback (crashes only). Buffer is cleared on normal on_session_end().
if self._active and self._write_enabled and self._client and self._session_turns and self._session_id:
logger.warning("Supermemory: Saving session via shutdown (session=%s, turns=%d)", self._session_id, len(self._session_turns))

messages: list[dict] = []
for turn in self._session_turns:
if turn.get("user"):
messages.append({"role": "user", "content": turn["user"]})
if turn.get("assistant"):
messages.append({"role": "assistant", "content": turn["assistant"]})

try:
self._client.ingest_conversation(
self._session_id,
messages,
metadata={
"type": "full_session",
"session_id": self._session_id,
"message_count": len(self._session_turns) * 2,
"partial": True,
},
)
except Exception:
logger.debug("Supermemory shutdown ingest failed", exc_info=True)

for attr_name in ("_prefetch_thread", "_sync_thread", "_write_thread"):
thread = getattr(self, attr_name, None)
if thread and thread.is_alive():
Expand Down Expand Up @@ -665,8 +746,25 @@ def _resolve_tool_container_tag(self, args: dict) -> Optional[str]:
return sanitized

def get_tool_schemas(self) -> List[Dict[str, Any]]:
def with_kebab_aliases(schemas: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
aliases = {
"supermemory_store": "supermemory-save",
"supermemory_search": "supermemory-search",
"supermemory_forget": "supermemory-forget",
"supermemory_profile": "supermemory-profile",
}
expanded = list(schemas)
for schema in schemas:
kebab = aliases.get(schema.get("name", ""))
if not kebab:
continue
copy = json.loads(json.dumps(schema))
copy["name"] = kebab
expanded.append(copy)
return expanded

if not self._enable_custom_containers:
return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA]
return with_kebab_aliases([STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA])

# When multi-container is enabled, add optional container_tag to relevant tools
container_param = {
Expand All @@ -678,7 +776,7 @@ def get_tool_schemas(self) -> List[Dict[str, Any]]:
schema = json.loads(json.dumps(base)) # deep copy
schema["parameters"]["properties"]["container_tag"] = container_param
schemas.append(schema)
return schemas
return with_kebab_aliases(schemas)

def _tool_store(self, args: dict) -> str:
content = str(args.get("content") or "").strip()
Expand All @@ -692,7 +790,7 @@ def _tool_store(self, args: dict) -> str:
if not isinstance(metadata, dict):
metadata = {}
metadata.setdefault("type", _detect_category(content))
metadata["source"] = "hermes_tool"
metadata.pop("source", None)
try:
result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context, container_tag=tag)
preview = content[:80] + ("..." if len(content) > 80 else "")
Expand Down Expand Up @@ -777,6 +875,13 @@ def _tool_profile(self, args: dict) -> str:
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
if not self._active or not self._client:
return tool_error("Supermemory is not configured")
aliases = {
"supermemory-save": "supermemory_store",
"supermemory-search": "supermemory_search",
"supermemory-forget": "supermemory_forget",
"supermemory-profile": "supermemory_profile",
}
tool_name = aliases.get(tool_name, tool_name)
if tool_name == "supermemory_store":
return self._tool_store(args)
if tool_name == "supermemory_search":
Expand Down
2 changes: 1 addition & 1 deletion plugins/memory/supermemory/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: supermemory
version: 1.0.0
version: 1.0.1
description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest."
pip_dependencies:
- supermemory
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"524706+Twanislas@users.noreply.github.com": "Twanislas",
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
"kchuang1015@users.noreply.github.com": "kchuang1015",
"maheshthedev@gmail.com": "MaheshtheDev",
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
"45688690+fujinice@users.noreply.github.com": "fujinice",
"276689385+carltonawong@users.noreply.github.com": "carltonawong",
Expand Down
Loading
Loading