diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md
new file mode 100644
index 0000000000..465d46838f
--- /dev/null
+++ b/plugins/memory/supermemory/README.md
@@ -0,0 +1,54 @@
+# Supermemory Memory Provider
+
+Semantic long-term memory with profile recall, semantic search, explicit memory tools, and session-end conversation ingest.
+
+## Requirements
+
+- `pip install supermemory`
+- Supermemory API key from [supermemory.ai](https://supermemory.ai)
+
+## Setup
+
+```bash
+hermes memory setup # select "supermemory"
+```
+
+Or manually:
+
+```bash
+hermes config set memory.provider supermemory
+echo "SUPERMEMORY_API_KEY=***" >> ~/.hermes/.env
+```
+
+## Config
+
+Config file: `$HERMES_HOME/supermemory.json`
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `container_tag` | `hermes` | Container tag used for search and writes |
+| `auto_recall` | `true` | Inject relevant memory context before turns |
+| `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
+| `max_recall_results` | `10` | Max recalled items to format into context |
+| `profile_frequency` | `50` | Include profile facts on first turn and every N turns |
+| `capture_mode` | `all` | Skip tiny or trivial turns by default |
+| `entity_context` | built-in default | Extraction guidance passed to Supermemory |
+| `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
+
+## 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 |
+
+## 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
+- expose explicit tools for search, store, forget, and profile access
diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py
new file mode 100644
index 0000000000..05583fae35
--- /dev/null
+++ b/plugins/memory/supermemory/__init__.py
@@ -0,0 +1,657 @@
+"""Supermemory memory plugin using the MemoryProvider interface.
+
+Provides semantic long-term memory with profile recall, semantic search,
+explicit memory tools, cleaned turn capture, and session-end conversation ingest.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import threading
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from agent.memory_provider import MemoryProvider
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_CONTAINER_TAG = "hermes"
+_DEFAULT_MAX_RECALL_RESULTS = 10
+_DEFAULT_PROFILE_FREQUENCY = 50
+_DEFAULT_CAPTURE_MODE = "all"
+_DEFAULT_API_TIMEOUT = 5.0
+_MIN_CAPTURE_LENGTH = 10
+_MAX_ENTITY_CONTEXT_LENGTH = 1500
+_CONVERSATIONS_URL = "https://api.supermemory.ai/v4/conversations"
+_TRIVIAL_RE = re.compile(
+ r"^(ok|okay|thanks|thank you|got it|sure|yes|no|yep|nope|k|ty|thx|np)\.?$",
+ re.IGNORECASE,
+)
+_CONTEXT_STRIP_RE = re.compile(
+ r"[\s\S]*?\s*", re.DOTALL
+)
+_CONTAINERS_STRIP_RE = re.compile(
+ r"[\s\S]*?\s*", re.DOTALL
+)
+_DEFAULT_ENTITY_CONTEXT = (
+ "User-assistant conversation. Format: [role: user]...[user:end] and "
+ "[role: assistant]...[assistant:end].\n\n"
+ "Only extract things useful in future conversations. Most messages are not worth remembering.\n\n"
+ "Remember lasting personal facts, preferences, routines, tools, ongoing projects, working context, "
+ "and explicit requests to remember something.\n\n"
+ "Do not remember temporary intents, one-time tasks, assistant actions, implementation details, or in-progress status.\n\n"
+ "When in doubt, store less."
+)
+
+
+def _default_config() -> dict:
+ return {
+ "container_tag": _DEFAULT_CONTAINER_TAG,
+ "auto_recall": True,
+ "auto_capture": True,
+ "max_recall_results": _DEFAULT_MAX_RECALL_RESULTS,
+ "profile_frequency": _DEFAULT_PROFILE_FREQUENCY,
+ "capture_mode": _DEFAULT_CAPTURE_MODE,
+ "entity_context": _DEFAULT_ENTITY_CONTEXT,
+ "api_timeout": _DEFAULT_API_TIMEOUT,
+ }
+
+
+def _sanitize_tag(raw: str) -> str:
+ tag = re.sub(r"[^a-zA-Z0-9_]", "_", raw or "")
+ tag = re.sub(r"_+", "_", tag)
+ return tag.strip("_") or _DEFAULT_CONTAINER_TAG
+
+
+def _clamp_entity_context(text: str) -> str:
+ if not text:
+ return _DEFAULT_ENTITY_CONTEXT
+ text = text.strip()
+ return text[:_MAX_ENTITY_CONTEXT_LENGTH]
+
+
+def _as_bool(value: Any, default: bool) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in ("true", "1", "yes", "y", "on"):
+ return True
+ if lowered in ("false", "0", "no", "n", "off"):
+ return False
+ return default
+
+
+def _load_supermemory_config(hermes_home: str) -> dict:
+ config = _default_config()
+ config_path = Path(hermes_home) / "supermemory.json"
+ if config_path.exists():
+ try:
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
+ if isinstance(raw, dict):
+ config.update({k: v for k, v in raw.items() if v is not None})
+ except Exception:
+ logger.debug("Failed to parse %s", config_path, exc_info=True)
+
+ config["container_tag"] = _sanitize_tag(str(config.get("container_tag", _DEFAULT_CONTAINER_TAG)))
+ config["auto_recall"] = _as_bool(config.get("auto_recall"), True)
+ config["auto_capture"] = _as_bool(config.get("auto_capture"), True)
+ try:
+ config["max_recall_results"] = max(1, min(20, int(config.get("max_recall_results", _DEFAULT_MAX_RECALL_RESULTS))))
+ except Exception:
+ config["max_recall_results"] = _DEFAULT_MAX_RECALL_RESULTS
+ try:
+ config["profile_frequency"] = max(1, min(500, int(config.get("profile_frequency", _DEFAULT_PROFILE_FREQUENCY))))
+ except Exception:
+ config["profile_frequency"] = _DEFAULT_PROFILE_FREQUENCY
+ config["capture_mode"] = "everything" if config.get("capture_mode") == "everything" else "all"
+ config["entity_context"] = _clamp_entity_context(str(config.get("entity_context", _DEFAULT_ENTITY_CONTEXT)))
+ try:
+ config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT))))
+ except Exception:
+ config["api_timeout"] = _DEFAULT_API_TIMEOUT
+ return config
+
+
+def _save_supermemory_config(values: dict, hermes_home: str) -> None:
+ config_path = Path(hermes_home) / "supermemory.json"
+ existing = {}
+ if config_path.exists():
+ try:
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
+ if isinstance(raw, dict):
+ existing = raw
+ except Exception:
+ existing = {}
+ existing.update(values)
+ config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+def _detect_category(text: str) -> str:
+ lowered = text.lower()
+ if re.search(r"prefer|like|love|hate|want", lowered):
+ return "preference"
+ if re.search(r"decided|will use|going with", lowered):
+ return "decision"
+ if re.search(r"\bis\b|\bare\b|\bhas\b|\bhave\b", lowered):
+ return "fact"
+ return "other"
+
+
+def _format_relative_time(iso_timestamp: str) -> str:
+ try:
+ dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
+ now = datetime.now(timezone.utc)
+ seconds = (now - dt).total_seconds()
+ if seconds < 1800:
+ return "just now"
+ if seconds < 3600:
+ return f"{int(seconds / 60)}m ago"
+ if seconds < 86400:
+ return f"{int(seconds / 3600)}h ago"
+ if seconds < 604800:
+ return f"{int(seconds / 86400)}d ago"
+ if dt.year == now.year:
+ return dt.strftime("%d %b")
+ return dt.strftime("%d %b %Y")
+ except Exception:
+ return ""
+
+
+def _deduplicate_recall(static_facts: list, dynamic_facts: list, search_results: list) -> tuple[list, list, list]:
+ seen = set()
+ out_static, out_dynamic, out_search = [], [], []
+ for fact in static_facts or []:
+ if fact and fact not in seen:
+ seen.add(fact)
+ out_static.append(fact)
+ for fact in dynamic_facts or []:
+ if fact and fact not in seen:
+ seen.add(fact)
+ out_dynamic.append(fact)
+ for item in search_results or []:
+ memory = item.get("memory", "")
+ if memory and memory not in seen:
+ seen.add(memory)
+ out_search.append(item)
+ return out_static, out_dynamic, out_search
+
+
+def _format_prefetch_context(static_facts: list, dynamic_facts: list, search_results: list, max_results: int) -> str:
+ statics, dynamics, search = _deduplicate_recall(static_facts, dynamic_facts, search_results)
+ statics = statics[:max_results]
+ dynamics = dynamics[:max_results]
+ search = search[:max_results]
+ if not statics and not dynamics and not search:
+ return ""
+
+ sections = []
+ if statics:
+ sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in statics))
+ if dynamics:
+ sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in dynamics))
+ if search:
+ lines = []
+ for item in search:
+ memory = item.get("memory", "")
+ if not memory:
+ continue
+ similarity = item.get("similarity")
+ updated = item.get("updated_at") or item.get("updatedAt") or ""
+ prefix_bits = []
+ rel = _format_relative_time(updated)
+ if rel:
+ prefix_bits.append(f"[{rel}]")
+ if similarity is not None:
+ try:
+ prefix_bits.append(f"[{round(float(similarity) * 100)}%]")
+ except Exception:
+ pass
+ prefix = " ".join(prefix_bits)
+ lines.append(f"- {prefix} {memory}".strip())
+ if lines:
+ sections.append("## Relevant Memories\n" + "\n".join(lines))
+ if not sections:
+ return ""
+
+ intro = (
+ "The following is background context from long-term memory. Use it silently when relevant. "
+ "Do not force memories into the conversation."
+ )
+ body = "\n\n".join(sections)
+ return f"\n{intro}\n\n{body}\n"
+
+
+def _clean_text_for_capture(text: str) -> str:
+ text = _CONTEXT_STRIP_RE.sub("", text or "")
+ text = _CONTAINERS_STRIP_RE.sub("", text)
+ return text.strip()
+
+
+def _is_trivial_message(text: str) -> bool:
+ return bool(_TRIVIAL_RE.match((text or "").strip()))
+
+
+class _SupermemoryClient:
+ def __init__(self, api_key: str, timeout: float, container_tag: str):
+ from supermemory import Supermemory
+
+ self._api_key = api_key
+ self._container_tag = container_tag
+ self._timeout = timeout
+ 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:
+ kwargs = {
+ "content": content.strip(),
+ "container_tags": [self._container_tag],
+ }
+ if metadata:
+ kwargs["metadata"] = metadata
+ if entity_context:
+ kwargs["entity_context"] = _clamp_entity_context(entity_context)
+ result = self._client.documents.add(**kwargs)
+ return {"id": getattr(result, "id", "")}
+
+ def search_memories(self, query: str, *, limit: int = 5) -> list[dict]:
+ response = self._client.search.memories(q=query, container_tag=self._container_tag, limit=limit)
+ results = []
+ for item in (getattr(response, "results", None) or []):
+ results.append({
+ "id": getattr(item, "id", ""),
+ "memory": getattr(item, "memory", "") or "",
+ "similarity": getattr(item, "similarity", None),
+ "updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None),
+ "metadata": getattr(item, "metadata", None),
+ })
+ return results
+
+ def get_profile(self, query: Optional[str] = None) -> dict:
+ kwargs = {"container_tag": self._container_tag}
+ if query:
+ kwargs["q"] = query
+ response = self._client.profile(**kwargs)
+ profile_data = getattr(response, "profile", None)
+ search_data = getattr(response, "search_results", None) or getattr(response, "searchResults", None)
+ static = getattr(profile_data, "static", []) or [] if profile_data else []
+ dynamic = getattr(profile_data, "dynamic", []) or [] if profile_data else []
+ raw_results = getattr(search_data, "results", None) or search_data or []
+ search_results = []
+ if isinstance(raw_results, list):
+ for item in raw_results:
+ if isinstance(item, dict):
+ search_results.append(item)
+ else:
+ search_results.append({
+ "memory": getattr(item, "memory", ""),
+ "updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None),
+ "similarity": getattr(item, "similarity", None),
+ })
+ return {"static": static, "dynamic": dynamic, "search_results": search_results}
+
+ def forget_memory(self, memory_id: str) -> None:
+ self._client.memories.forget(container_tag=self._container_tag, id=memory_id)
+
+ def forget_by_query(self, query: str) -> dict:
+ results = self.search_memories(query, limit=5)
+ if not results:
+ return {"success": False, "message": "No matching memory found to forget."}
+ target = results[0]
+ memory_id = target.get("id", "")
+ if not memory_id:
+ return {"success": False, "message": "Best matching memory has no id."}
+ self.forget_memory(memory_id)
+ 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({
+ "conversationId": session_id,
+ "messages": messages,
+ "containerTags": [self._container_tag],
+ }).encode("utf-8")
+ req = urllib.request.Request(
+ _CONVERSATIONS_URL,
+ data=payload,
+ headers={
+ "Authorization": f"Bearer {self._api_key}",
+ "Content-Type": "application/json",
+ },
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=self._timeout + 3):
+ return
+
+
+STORE_SCHEMA = {
+ "name": "supermemory_store",
+ "description": "Store an explicit memory for future recall.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "content": {"type": "string", "description": "The memory content to store."},
+ "metadata": {"type": "object", "description": "Optional metadata attached to the memory."},
+ },
+ "required": ["content"],
+ },
+}
+
+SEARCH_SCHEMA = {
+ "name": "supermemory_search",
+ "description": "Search long-term memory by semantic similarity.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "What to search for."},
+ "limit": {"type": "integer", "description": "Maximum results to return, 1 to 20."},
+ },
+ "required": ["query"],
+ },
+}
+
+FORGET_SCHEMA = {
+ "name": "supermemory_forget",
+ "description": "Forget a memory by exact id or by best-match query.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string", "description": "Exact memory id to delete."},
+ "query": {"type": "string", "description": "Query used to find the memory to forget."},
+ },
+ },
+}
+
+PROFILE_SCHEMA = {
+ "name": "supermemory_profile",
+ "description": "Retrieve persistent profile facts and recent memory context.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Optional query to focus the profile response."},
+ },
+ },
+}
+
+
+class SupermemoryMemoryProvider(MemoryProvider):
+ def __init__(self):
+ self._config = _default_config()
+ self._api_key = ""
+ self._client: Optional[_SupermemoryClient] = None
+ self._container_tag = _DEFAULT_CONTAINER_TAG
+ self._session_id = ""
+ self._turn_count = 0
+ self._prefetch_result = ""
+ self._prefetch_lock = threading.Lock()
+ self._prefetch_thread: Optional[threading.Thread] = None
+ self._sync_thread: Optional[threading.Thread] = None
+ self._auto_recall = True
+ self._auto_capture = True
+ self._max_recall_results = _DEFAULT_MAX_RECALL_RESULTS
+ self._profile_frequency = _DEFAULT_PROFILE_FREQUENCY
+ self._capture_mode = _DEFAULT_CAPTURE_MODE
+ self._entity_context = _DEFAULT_ENTITY_CONTEXT
+ self._api_timeout = _DEFAULT_API_TIMEOUT
+ self._hermes_home = os.path.expanduser("~/.hermes")
+ self._write_enabled = True
+ self._active = False
+
+ @property
+ def name(self) -> str:
+ return "supermemory"
+
+ def is_available(self) -> bool:
+ api_key = os.environ.get("SUPERMEMORY_API_KEY", "")
+ if not api_key:
+ return False
+ try:
+ __import__("supermemory")
+ return True
+ except Exception:
+ return False
+
+ def get_config_schema(self):
+ return [
+ {"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):
+ sanitized = dict(values or {})
+ if "container_tag" in sanitized:
+ sanitized["container_tag"] = _sanitize_tag(str(sanitized["container_tag"]))
+ if "entity_context" in sanitized:
+ sanitized["entity_context"] = _clamp_entity_context(str(sanitized["entity_context"]))
+ _save_supermemory_config(sanitized, hermes_home)
+
+ def initialize(self, session_id: str, **kwargs) -> None:
+ self._hermes_home = kwargs.get("hermes_home") or os.path.expanduser("~/.hermes")
+ self._session_id = session_id
+ self._turn_count = 0
+ self._config = _load_supermemory_config(self._hermes_home)
+ self._api_key = os.environ.get("SUPERMEMORY_API_KEY", "")
+ self._container_tag = self._config["container_tag"]
+ self._auto_recall = self._config["auto_recall"]
+ self._auto_capture = self._config["auto_capture"]
+ self._max_recall_results = self._config["max_recall_results"]
+ self._profile_frequency = self._config["profile_frequency"]
+ self._capture_mode = self._config["capture_mode"]
+ self._entity_context = self._config["entity_context"]
+ self._api_timeout = self._config["api_timeout"]
+ agent_context = kwargs.get("agent_context", "")
+ self._write_enabled = agent_context not in ("cron", "flush", "subagent")
+ self._active = bool(self._api_key)
+ self._client = None
+ if self._active:
+ try:
+ self._client = _SupermemoryClient(
+ api_key=self._api_key,
+ timeout=self._api_timeout,
+ container_tag=self._container_tag,
+ )
+ except Exception:
+ logger.warning("Supermemory initialization failed", exc_info=True)
+ self._active = False
+ self._client = None
+
+ def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
+ self._turn_count = max(turn_number, 0)
+
+ def system_prompt_block(self) -> str:
+ if not self._active:
+ return ""
+ return (
+ "# Supermemory\n"
+ f"Active. Container: {self._container_tag}.\n"
+ "Use supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile for explicit memory operations."
+ )
+
+ 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():
+ return ""
+ try:
+ profile = self._client.get_profile(query=query[:200])
+ include_profile = self._turn_count <= 1 or (self._turn_count % self._profile_frequency == 0)
+ context = _format_prefetch_context(
+ static_facts=profile["static"] if include_profile else [],
+ dynamic_facts=profile["dynamic"] if include_profile else [],
+ search_results=profile["search_results"],
+ max_results=self._max_recall_results,
+ )
+ return context
+ except Exception:
+ logger.debug("Supermemory prefetch failed", exc_info=True)
+ return ""
+
+ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
+ if not self._active or not self._auto_capture or not self._write_enabled or not self._client:
+ return
+
+ clean_user = _clean_text_for_capture(user_content)
+ clean_assistant = _clean_text_for_capture(assistant_content)
+ if not clean_user or 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 = threading.Thread(target=_run, daemon=True, name="supermemory-sync")
+ self._sync_thread.start()
+
+ 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:
+ return
+ cleaned = []
+ for message in messages or []:
+ role = message.get("role")
+ if role not in ("user", "assistant"):
+ continue
+ content = _clean_text_for_capture(str(message.get("content", "")))
+ if content:
+ cleaned.append({"role": role, "content": content})
+ if not cleaned:
+ return
+ if len(cleaned) == 1 and len(cleaned[0].get("content", "")) < 20:
+ return
+ try:
+ self._client.ingest_conversation(self._session_id, 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)
+
+ 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
+ if action != "add" or not (content or "").strip():
+ return
+
+ def _run():
+ try:
+ self._client.add_memory(
+ content.strip(),
+ metadata={"source": "hermes_memory", "target": target, "type": "explicit_memory"},
+ entity_context=self._entity_context,
+ )
+ except Exception:
+ logger.debug("Supermemory on_memory_write failed", exc_info=True)
+
+ threading.Thread(target=_run, daemon=True, name="supermemory-memory-write").start()
+
+ def get_tool_schemas(self) -> List[Dict[str, Any]]:
+ return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA]
+
+ def _tool_store(self, args: dict) -> str:
+ content = str(args.get("content") or "").strip()
+ if not content:
+ return json.dumps({"error": "content is required"})
+ metadata = args.get("metadata") or {}
+ if not isinstance(metadata, dict):
+ metadata = {}
+ metadata.setdefault("type", _detect_category(content))
+ metadata["source"] = "hermes_tool"
+ try:
+ result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context)
+ preview = content[:80] + ("..." if len(content) > 80 else "")
+ return json.dumps({"saved": True, "id": result.get("id", ""), "preview": preview})
+ except Exception as exc:
+ return json.dumps({"error": f"Failed to store memory: {exc}"})
+
+ def _tool_search(self, args: dict) -> str:
+ query = str(args.get("query") or "").strip()
+ if not query:
+ return json.dumps({"error": "query is required"})
+ try:
+ limit = max(1, min(20, int(args.get("limit", 5) or 5)))
+ except Exception:
+ limit = 5
+ try:
+ results = self._client.search_memories(query, limit=limit)
+ formatted = []
+ for item in results:
+ entry = {"id": item.get("id", ""), "content": item.get("memory", "")}
+ if item.get("similarity") is not None:
+ try:
+ entry["similarity"] = round(float(item["similarity"]) * 100)
+ except Exception:
+ pass
+ formatted.append(entry)
+ return json.dumps({"results": formatted, "count": len(formatted)})
+ except Exception as exc:
+ return json.dumps({"error": f"Search failed: {exc}"})
+
+ def _tool_forget(self, args: dict) -> str:
+ memory_id = str(args.get("id") or "").strip()
+ query = str(args.get("query") or "").strip()
+ if not memory_id and not query:
+ return json.dumps({"error": "Provide either id or query"})
+ try:
+ if memory_id:
+ self._client.forget_memory(memory_id)
+ return json.dumps({"forgotten": True, "id": memory_id})
+ return json.dumps(self._client.forget_by_query(query))
+ except Exception as exc:
+ return json.dumps({"error": f"Forget failed: {exc}"})
+
+ def _tool_profile(self, args: dict) -> str:
+ query = str(args.get("query") or "").strip() or None
+ try:
+ profile = self._client.get_profile(query=query)
+ sections = []
+ if profile["static"]:
+ sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in profile["static"]))
+ if profile["dynamic"]:
+ sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in profile["dynamic"]))
+ return json.dumps({
+ "profile": "\n\n".join(sections),
+ "static_count": len(profile["static"]),
+ "dynamic_count": len(profile["dynamic"]),
+ })
+ except Exception as exc:
+ return json.dumps({"error": f"Profile failed: {exc}"})
+
+ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
+ if not self._active or not self._client:
+ return json.dumps({"error": "Supermemory is not configured"})
+ if tool_name == "supermemory_store":
+ return self._tool_store(args)
+ if tool_name == "supermemory_search":
+ return self._tool_search(args)
+ if tool_name == "supermemory_forget":
+ return self._tool_forget(args)
+ if tool_name == "supermemory_profile":
+ return self._tool_profile(args)
+ return json.dumps({"error": f"Unknown tool: {tool_name}"})
+
+
+def register(ctx):
+ ctx.register_memory_provider(SupermemoryMemoryProvider())
diff --git a/plugins/memory/supermemory/plugin.yaml b/plugins/memory/supermemory/plugin.yaml
new file mode 100644
index 0000000000..372edb2b7c
--- /dev/null
+++ b/plugins/memory/supermemory/plugin.yaml
@@ -0,0 +1,7 @@
+name: supermemory
+version: 1.0.0
+description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest."
+pip_dependencies:
+ - supermemory
+hooks:
+ - on_session_end
diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py
new file mode 100644
index 0000000000..f61a904194
--- /dev/null
+++ b/tests/plugins/memory/test_supermemory_provider.py
@@ -0,0 +1,212 @@
+import json
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from plugins.memory.supermemory import (
+ SupermemoryMemoryProvider,
+ _clean_text_for_capture,
+ _format_prefetch_context,
+ _load_supermemory_config,
+ _save_supermemory_config,
+)
+
+
+class FakeClient:
+ def __init__(self, api_key: str, timeout: float, container_tag: str):
+ self.api_key = api_key
+ self.timeout = timeout
+ self.container_tag = container_tag
+ self.add_calls = []
+ self.search_results = []
+ self.profile_response = {"static": [], "dynamic": [], "search_results": []}
+ self.ingest_calls = []
+ self.forgotten_ids = []
+ self.forget_by_query_response = {"success": True, "message": "Forgot"}
+
+ def add_memory(self, content, metadata=None, *, entity_context=""):
+ self.add_calls.append({
+ "content": content,
+ "metadata": metadata,
+ "entity_context": entity_context,
+ })
+ return {"id": "mem_123"}
+
+ def search_memories(self, query, *, limit=5):
+ return self.search_results
+
+ def get_profile(self, query=None):
+ return self.profile_response
+
+ def forget_memory(self, memory_id):
+ self.forgotten_ids.append(memory_id)
+
+ def forget_by_query(self, query):
+ return self.forget_by_query_response
+
+ def ingest_conversation(self, session_id, messages):
+ self.ingest_calls.append({"session_id": session_id, "messages": messages})
+
+
+@pytest.fixture
+def provider(monkeypatch, tmp_path):
+ monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
+ monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
+ p = SupermemoryMemoryProvider()
+ p.initialize("session-1", hermes_home=str(tmp_path), platform="cli")
+ return p
+
+
+def test_is_available_false_without_api_key(monkeypatch):
+ monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
+ p = SupermemoryMemoryProvider()
+ assert p.is_available() is False
+
+
+def test_is_available_false_when_import_missing(monkeypatch):
+ monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
+
+ import builtins
+ real_import = builtins.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name == "supermemory":
+ raise ImportError("missing")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", fake_import)
+ p = SupermemoryMemoryProvider()
+ assert p.is_available() is False
+
+
+def test_load_and_save_config_round_trip(tmp_path):
+ _save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path))
+ cfg = _load_supermemory_config(str(tmp_path))
+ assert cfg["container_tag"] == "demo_tag"
+ assert cfg["auto_capture"] is False
+ assert cfg["auto_recall"] is True
+
+
+def test_clean_text_for_capture_strips_injected_context():
+ text = "hello\nignore me\nworld"
+ assert _clean_text_for_capture(text) == "hello\nworld"
+
+
+def test_format_prefetch_context_deduplicates_overlap():
+ result = _format_prefetch_context(
+ static_facts=["Jordan prefers short answers"],
+ dynamic_facts=["Jordan prefers short answers", "Uses Hermes"],
+ search_results=[{"memory": "Uses Hermes", "similarity": 0.9}],
+ max_results=10,
+ )
+ assert result.count("Jordan prefers short answers") == 1
+ assert result.count("Uses Hermes") == 1
+ assert "" in result
+
+
+def test_prefetch_includes_profile_on_first_turn(provider):
+ provider._client.profile_response = {
+ "static": ["Jordan prefers short answers"],
+ "dynamic": ["Current project is Supermemory provider"],
+ "search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
+ }
+ provider.on_turn_start(1, "start")
+ result = provider.prefetch("what am I working on?")
+ assert "User Profile (Persistent)" in result
+ assert "Recent Context" in result
+ assert "Relevant Memories" in result
+
+
+def test_prefetch_skips_profile_between_frequency(provider):
+ provider._client.profile_response = {
+ "static": ["Jordan prefers short answers"],
+ "dynamic": ["Current project is Supermemory provider"],
+ "search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
+ }
+ provider.on_turn_start(2, "next")
+ result = provider.prefetch("what am I working on?")
+ assert "Relevant Memories" in result
+ assert "User Profile (Persistent)" not in result
+
+
+def test_sync_turn_skips_trivial_message(provider):
+ provider.sync_turn("ok", "sure", session_id="session-1")
+ assert provider._client.add_calls == []
+
+
+def test_sync_turn_persists_cleaned_exchange(provider):
+ provider.sync_turn(
+ "Please remember this\nignore",
+ "Got it, storing the context",
+ session_id="session-1",
+ )
+ provider._sync_thread.join(timeout=1)
+ assert len(provider._client.add_calls) == 1
+ content = provider._client.add_calls[0]["content"]
+ assert "ignore" not in content
+ assert "[role: user]" in content
+ assert "[role: assistant]" in content
+
+
+def test_on_session_end_ingests_clean_messages(provider):
+ messages = [
+ {"role": "system", "content": "skip"},
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi there"},
+ ]
+ provider.on_session_end(messages)
+ assert len(provider._client.ingest_calls) == 1
+ payload = provider._client.ingest_calls[0]
+ assert payload["session_id"] == "session-1"
+ assert payload["messages"] == [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "hi there"},
+ ]
+
+
+def test_store_tool_returns_saved_payload(provider):
+ result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"}))
+ assert result["saved"] is True
+ assert result["id"] == "mem_123"
+
+
+def test_search_tool_formats_results(provider):
+ provider._client.search_results = [
+ {"id": "m1", "memory": "Jordan likes concise docs", "similarity": 0.92}
+ ]
+ result = json.loads(provider.handle_tool_call("supermemory_search", {"query": "concise docs"}))
+ assert result["count"] == 1
+ assert result["results"][0]["similarity"] == 92
+
+
+def test_forget_tool_by_id(provider):
+ result = json.loads(provider.handle_tool_call("supermemory_forget", {"id": "m1"}))
+ assert result == {"forgotten": True, "id": "m1"}
+ assert provider._client.forgotten_ids == ["m1"]
+
+
+def test_forget_tool_by_query(provider):
+ provider._client.forget_by_query_response = {"success": True, "message": "Forgot one", "id": "m7"}
+ result = json.loads(provider.handle_tool_call("supermemory_forget", {"query": "that thing"}))
+ assert result["success"] is True
+ assert result["id"] == "m7"
+
+
+def test_profile_tool_formats_sections(provider):
+ provider._client.profile_response = {
+ "static": ["Jordan prefers concise docs"],
+ "dynamic": ["Working on Supermemory provider"],
+ "search_results": [],
+ }
+ result = json.loads(provider.handle_tool_call("supermemory_profile", {}))
+ assert result["static_count"] == 1
+ assert result["dynamic_count"] == 1
+ assert "User Profile (Persistent)" in result["profile"]
+
+
+def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch):
+ monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
+ p = SupermemoryMemoryProvider()
+ result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"}))
+ assert "error" in result