* refactor(supermemory): session-level conversation ingest + kebab tool aliases Salvaged from #32487 (by @MaheshtheDev), rebased onto current main. - sync_turn now buffers cleaned turns; the full session is ingested once at session end / switch / shutdown via the conversations endpoint - ingest_conversation() accepts and forwards functional document metadata (type, session_id, message_count, partial) - register kebab-case tool aliases (supermemory-save/search/forget/profile) alongside the snake_case names - README + docs (EN/zh-Hans) updated for the simplified session model Source/vendor-attribution removed per project policy (no telemetry): dropped x-sm-source header, sm_source metadata, and sm_capture_mode tags. Preserved the post-branch atomic_json_write(mode=0o600) hardening that the PR's stale base had reverted. Updated provider tests for the new behavior and added maheshthedev@gmail.com to release.py AUTHOR_MAP. Co-authored-by: alt-glitch <balyan.sid@gmail.com> * feat(supermemory): restore x-sm-source for Spaces routing Reinstates x-sm-source: hermes (SDK default_headers + conversations POST) and sm_source: hermes document metadata. Per @Dhravya (Supermemory), this is a functional routing key, not telemetry: it groups Hermes writes into a dedicated "Hermes" Space in the Supermemory app so users can filter and bulk-manage memories per source agent. sm_capture_mode remains dropped (appears analytics-only; Spaces are routed by sm_source) pending confirmation. Adds README note + a unit test covering _merge_metadata sm_source stamping and legacy source->type migration. --------- Co-authored-by: Mahesh Sanikommu <maheshthedev@gmail.com>
This commit is contained in:
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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\n<supermemory-context>ignore</supermemory-context>",
|
||||
"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):
|
||||
|
||||
@ -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.
|
||||
|
||||
<details>
|
||||
<summary>Multi-container example</summary>
|
||||
|
||||
@ -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 跨命名容器读写。自动操作(同步、预取)保持在主容器上。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user