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