diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md index c1f41c415..7e7786d83 100644 --- a/plugins/memory/supermemory/README.md +++ b/plugins/memory/supermemory/README.md @@ -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 @@ -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: @@ -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 diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index a21ae53cc..0d03f4eaa 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -269,7 +269,22 @@ class _SupermemoryClient: 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, @@ -280,7 +295,7 @@ class _SupermemoryClient: "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: @@ -349,18 +364,22 @@ class _SupermemoryClient: 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", ) @@ -447,6 +466,7 @@ class SupermemoryMemoryProvider(MemoryProvider): 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: @@ -501,13 +521,13 @@ class SupermemoryMemoryProvider(MemoryProvider): 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) @@ -534,7 +554,7 @@ class SupermemoryMemoryProvider(MemoryProvider): 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) @@ -567,31 +587,11 @@ class SupermemoryMemoryProvider(MemoryProvider): 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: @@ -609,12 +609,68 @@ class SupermemoryMemoryProvider(MemoryProvider): 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 @@ -625,7 +681,7 @@ class SupermemoryMemoryProvider(MemoryProvider): 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: @@ -638,6 +694,31 @@ class SupermemoryMemoryProvider(MemoryProvider): 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(): @@ -665,8 +746,25 @@ class SupermemoryMemoryProvider(MemoryProvider): 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 = { @@ -678,7 +776,7 @@ class SupermemoryMemoryProvider(MemoryProvider): 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() @@ -692,7 +790,7 @@ class SupermemoryMemoryProvider(MemoryProvider): 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 "") @@ -777,6 +875,13 @@ class SupermemoryMemoryProvider(MemoryProvider): 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": diff --git a/plugins/memory/supermemory/plugin.yaml b/plugins/memory/supermemory/plugin.yaml index 23321bdb5..8100b3213 100644 --- a/plugins/memory/supermemory/plugin.yaml +++ b/plugins/memory/supermemory/plugin.yaml @@ -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 diff --git a/scripts/release.py b/scripts/release.py index d7f6dcfea..3bf9b2dbf 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -70,6 +70,7 @@ AUTHOR_MAP = { "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", diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index d5f1c5bb1..2d0d0c9e2 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -50,8 +50,8 @@ class FakeClient: def forget_by_query(self, query, *, container_tag=None): return self.forget_by_query_response - def ingest_conversation(self, session_id, messages): - self.ingest_calls.append({"session_id": session_id, "messages": messages}) + def ingest_conversation(self, session_id, messages, metadata=None): + self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata}) @pytest.fixture @@ -136,23 +136,28 @@ def test_prefetch_skips_profile_between_frequency(provider): assert "User Profile (Persistent)" not in result -def test_sync_turn_skips_trivial_message(provider): +def test_sync_turn_buffers_short_messages(provider): + # Trivial filtering is no longer applied at sync time — every non-empty turn + # is buffered and only the full session is written at session boundaries. provider.sync_turn("ok", "sure", session_id="session-1") + assert provider._session_turns == [{"user": "ok", "assistant": "sure"}] assert provider._client.add_calls == [] -def test_sync_turn_persists_cleaned_exchange(provider): +def test_sync_turn_buffers_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 + assert len(provider._session_turns) == 1 + turn = provider._session_turns[0] + assert "ignore" not in turn["user"] + assert turn["user"].startswith("Please remember this") + assert turn["assistant"] == "Got it, storing the context" + # Buffering only — no per-turn writes to the client + assert provider._client.add_calls == [] + assert provider._client.ingest_calls == [] def test_on_session_end_ingests_clean_messages(provider): @@ -169,6 +174,28 @@ def test_on_session_end_ingests_clean_messages(provider): {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] + assert payload["metadata"]["type"] == "full_session" + assert payload["metadata"]["session_id"] == "session-1" + assert payload["metadata"]["message_count"] == 2 + # Buffer is cleared after a normal session-end ingest. + assert provider._session_turns == [] + + +def test_merge_metadata_stamps_sm_source(): + # sm_source routes Hermes writes into the "Hermes" Space in the Supermemory + # app (functional routing, not telemetry) — must always be present. + from plugins.memory.supermemory import _SupermemoryClient + + client = _SupermemoryClient.__new__(_SupermemoryClient) + merged = client._merge_metadata({"type": "explicit_memory"}) + assert merged["sm_source"] == "hermes" + assert merged["type"] == "explicit_memory" + + # Legacy "source" is migrated into "type" when type is absent. + merged2 = client._merge_metadata({"source": "conversation_turn"}) + assert merged2["sm_source"] == "hermes" + assert merged2["type"] == "conversation_turn" + assert "source" not in merged2 def test_on_memory_write_tracks_thread(provider): @@ -179,7 +206,7 @@ def test_on_memory_write_tracks_thread(provider): assert provider._client.add_calls[0]["metadata"]["type"] == "explicit_memory" -def test_shutdown_joins_and_clears_threads(provider, monkeypatch): +def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch): started = threading.Event() release = threading.Event() @@ -196,15 +223,16 @@ def test_shutdown_joins_and_clears_threads(provider, monkeypatch): monkeypatch.setattr(provider._client, "add_memory", slow_add_memory) + # sync_turn now only buffers — no thread is spawned. provider.sync_turn( "Please remember this request in long-term memory", "Absolutely, I will keep that in long-term memory.", session_id="session-1", ) - assert started.wait(timeout=1) - assert provider._sync_thread is not None + assert provider._sync_thread is None + assert len(provider._session_turns) == 1 - started.clear() + # on_memory_write still runs on a background thread. provider.on_memory_write("add", "memory", "Jordan likes concise docs") assert started.wait(timeout=1) assert provider._write_thread is not None @@ -212,10 +240,18 @@ def test_shutdown_joins_and_clears_threads(provider, monkeypatch): release.set() provider.shutdown() + # All tracked threads joined and cleared. assert provider._sync_thread is None assert provider._write_thread is None assert provider._prefetch_thread is None - assert len(provider._client.add_calls) == 2 + # Explicit memory write went through. + assert len(provider._client.add_calls) == 1 + # Buffered turn was flushed as a partial full-session ingest. + assert len(provider._client.ingest_calls) == 1 + payload = provider._client.ingest_calls[0] + assert payload["session_id"] == "session-1" + assert payload["metadata"]["partial"] is True + assert payload["metadata"]["type"] == "full_session" def test_store_tool_returns_saved_payload(provider): diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 00f2555d6..43b70334d 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -66,7 +66,7 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c hermes memory setup # select "honcho" — runs the Honcho-specific post-setup ``` -On a fresh install, configure Honcho directly with `hermes memory setup honcho`. The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. +The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. **Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes). @@ -498,11 +498,11 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env **Key features:** - Automatic context fencing — strips recalled memories from captured turns to prevent recursive memory pollution -- Session-end conversation ingest for richer graph-level knowledge building +- Full-session ingest — the entire conversation is sent once at session boundaries +- Session-end conversation ingest (to `/v4/conversations`) for richer profile + graph building in Supermemory - Profile facts injected on first turn and at configurable intervals -- 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. +- **Multi-container mode** — enable `enable_custom_container_tags` with a `custom_containers` list to let the agent read/write across named containers. Automatic operations stay on the primary container.
Multi-container example diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md index 79c8489a1..8658733db 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md @@ -498,9 +498,9 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env **主要特性:** - 自动上下文隔离——从捕获的轮次中剥离已召回的记忆,防止递归记忆污染 -- 会话结束时的对话导入,用于构建更丰富的图谱级知识 +- 在会话边界时将整个会话**一次性导入** +- 会话结束时同时导入到对话端点(`/v4/conversations`),用于 Supermemory 的 profile 和图谱构建 - 在第一轮及可配置间隔注入 profile 事实 -- 无意义消息过滤(跳过"ok"、"thanks"等) - **Profile 范围容器**——在 `container_tag` 中使用 `{identity}`(例如 `hermes-{identity}` → `hermes-coder`),按 Hermes profile 隔离记忆 - **多容器模式**——启用 `enable_custom_container_tags` 并配置 `custom_containers` 列表,让 Agent 跨命名容器读写。自动操作(同步、预取)保持在主容器上。