diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py
index 6ece3cf514..0b339660b5 100644
--- a/plugins/memory/supermemory/__init__.py
+++ b/plugins/memory/supermemory/__init__.py
@@ -1,8 +1,8 @@
-"""Supermemory memory plugin (MemoryProvider): profile recall, semantic search, explicit memory tools,
-cleaned turn capture, and session-end conversation ingest."""
+"""Supermemory memory plugin (MemoryProvider): profile recall, semantic search, memory tools, turn capture, session ingest."""
from __future__ import annotations
+import importlib
import json
import logging
import os
@@ -11,7 +11,7 @@ import threading
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from agent.secret_scope import get_secret, is_multiplex_active
@@ -40,6 +40,16 @@ _ALIAS_TO_TOOL = {kebab: snake for snake, kebab in _KEBAB_ALIASES.items()}
_BOOL_WORDS = {**dict.fromkeys(("true", "1", "yes", "y", "on"), True), **dict.fromkeys(("false", "0", "no", "n", "off"), False)}
+def _quietly(fn: Callable[[], Any], fail_msg: str = "", *args: Any, level: int = logging.DEBUG, default: Any = None) -> Any:
+ """Run ``fn()``; on any exception log ``fail_msg`` (if given) with traceback and return ``default``."""
+ try:
+ return fn()
+ except Exception:
+ if fail_msg:
+ logger.log(level, fail_msg, *args, exc_info=True)
+ return default
+
+
def _sanitize_tag(raw: str) -> str:
return re.sub(r"_+", "_", re.sub(r"[^a-zA-Z0-9_]", "_", raw or "")).strip("_") or _DEFAULT_CONTAINER_TAG
@@ -56,21 +66,12 @@ def _clamp_entity_context(text: str) -> str:
def _as_bool(value: Any, default: bool) -> bool:
"""bool passthrough; common true/false words parsed; anything else (incl. ints) -> default."""
- if isinstance(value, bool):
- return value
- return _BOOL_WORDS.get(value.strip().lower(), default) if isinstance(value, str) else default
-
-
-def _valid_search_mode(mode: str) -> str:
- return mode if mode in _VALID_SEARCH_MODES else "hybrid"
+ return value if isinstance(value, bool) else _BOOL_WORDS.get(value.strip().lower(), default) if isinstance(value, str) else default
def _clamp_number(value: Any, default, lo, hi, cast):
"""Cast ``value`` and clamp it to [lo, hi]; fall back to ``default`` on any conversion error."""
- try:
- return max(lo, min(hi, cast(value)))
- except Exception:
- return default
+ return _quietly(lambda: max(lo, min(hi, cast(value))), default=default)
# config key -> (default, normalizer applied to the raw/merged value). Order = supermemory.json layout.
@@ -83,11 +84,10 @@ _CONFIG_SPEC: Dict[str, tuple] = {
"max_recall_results": (10, lambda v: _clamp_number(v, 10, 1, 20, int)),
"profile_frequency": (50, lambda v: _clamp_number(v, 50, 1, 500, int)),
"capture_mode": ("all", lambda v: "everything" if v == "everything" else "all"),
- "search_mode": ("hybrid", lambda v: _valid_search_mode(str(v).strip().lower())),
+ "search_mode": ("hybrid", lambda v: v if (v := str(v).strip().lower()) in _VALID_SEARCH_MODES else "hybrid"),
"entity_context": (_DEFAULT_ENTITY_CONTEXT, lambda v: _clamp_entity_context(str(v))),
"api_timeout": (5.0, lambda v: _clamp_number(v, 5.0, 0.5, 15.0, float)),
"base_url": ("", lambda v: str(v or "").strip()),
- # Multi-container support
"enable_custom_container_tags": (False, lambda v: _as_bool(v, False)),
"custom_containers": ([], lambda v: [_sanitize_tag(str(t)) for t in v if t] if isinstance(v, list) else []),
"custom_container_instructions": ("", lambda v: str(v).strip()),
@@ -95,12 +95,7 @@ _CONFIG_SPEC: Dict[str, tuple] = {
def _read_json_dict(path: Path) -> dict:
- """JSON object stored at ``path`` or {} if missing/invalid."""
- try:
- raw = json.loads(path.read_text(encoding="utf-8")) if path.exists() else None
- except Exception:
- logger.debug("Failed to parse %s", path, exc_info=True)
- return {}
+ raw = _quietly(lambda: json.loads(path.read_text(encoding="utf-8")), "Failed to parse %s", path) if path.exists() else None
return raw if isinstance(raw, dict) else {}
@@ -115,11 +110,9 @@ def _load_supermemory_config(hermes_home: Optional[str] = None) -> dict:
def _save_supermemory_config(values: dict, hermes_home: str) -> None:
- config_path = Path(hermes_home) / "supermemory.json"
- existing = _read_json_dict(config_path)
- existing.update(values)
from utils import atomic_json_write
- atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)
+ config_path = Path(hermes_home) / "supermemory.json"
+ atomic_json_write(config_path, {**_read_json_dict(config_path), **values}, mode=0o600, sort_keys=True)
def _detect_category(text: str) -> str:
@@ -129,24 +122,20 @@ def _detect_category(text: str) -> str:
def _format_relative_time(iso_timestamp: str) -> str:
- try:
- dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
- now = datetime.now(timezone.utc)
+ """'just now' / '5m ago' / '3h ago' / '2d ago' / '%d %b[ %Y]'; '' when unparseable."""
+ def _fmt():
+ dt, now = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")), datetime.now(timezone.utc)
seconds = (now - dt).total_seconds()
for limit, unit, label in ((1800, 0, "just now"), (3600, 60, "m ago"), (86400, 3600, "h ago"), (604800, 86400, "d ago")):
if seconds < limit:
return f"{int(seconds / unit)}{label}" if unit else label
return dt.strftime("%d %b" if dt.year == now.year else "%d %b %Y")
- except Exception:
- return ""
+ return _quietly(_fmt, default="")
def _similarity_pct(value: Any) -> Optional[int]:
"""0..1 similarity -> whole percent; None when absent or unparseable."""
- try:
- return None if value is None else round(float(value) * 100)
- except Exception:
- return None
+ return _quietly(lambda: None if value is None else round(float(value) * 100))
def _profile_sections(static_facts: list, dynamic_facts: list) -> list[str]:
@@ -160,21 +149,15 @@ def _format_prefetch_context(static_facts: list, dynamic_facts: list, search_res
def _unique(items, key=lambda x: x): # set.add() returns None, so `not seen.add(k)` records k and keeps the item
return [i for i in items or [] if (k := key(i)) and k not in seen and not seen.add(k)][:max_results]
-
sections = _profile_sections(_unique(static_facts), _unique(dynamic_facts))
lines = []
for item in _unique(search_results, key=lambda i: i.get("memory", "")):
rel = _format_relative_time(item.get("updated_at") or item.get("updatedAt") or "")
pct = _similarity_pct(item.get("similarity"))
- prefix_bits = ([f"[{rel}]"] if rel else []) + ([f"[{pct}%]"] if pct is not None else [])
- lines.append(f"- {' '.join(prefix_bits)} {item['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.")
- return f"\n{intro}\n\n" + "\n\n".join(sections) + "\n"
+ lines.append(f"- {' '.join(([f'[{rel}]'] if rel else []) + ([f'[{pct}%]'] if pct is not None else []))} {item['memory']}".strip())
+ sections += ["## Relevant Memories\n" + "\n".join(lines)] if lines else []
+ intro = "The following is background context from long-term memory. Use it silently when relevant. Do not force memories into the conversation."
+ return f"\n{intro}\n\n" + "\n\n".join(sections) + "\n" if sections else ""
def _clean_text_for_capture(text: str) -> str:
@@ -183,10 +166,9 @@ def _clean_text_for_capture(text: str) -> str:
def _memory_fields(item: Any, *keys: str) -> dict:
"""Pick SDK result attrs into a plain dict; ``updated_at`` also accepts camelCase ``updatedAt``."""
- values = {"id": getattr(item, "id", ""), "memory": getattr(item, "memory", ""), "similarity": getattr(item, "similarity", None),
- "updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None),
- "metadata": getattr(item, "metadata", None)}
- return {k: values[k] for k in keys}
+ defaults = {"id": "", "memory": "", "similarity": None, "metadata": None}
+ return {k: getattr(item, "updated_at", None) or getattr(item, "updatedAt", None) if k == "updated_at" else getattr(item, k, defaults[k])
+ for k in keys}
class _SupermemoryClient:
@@ -194,15 +176,11 @@ class _SupermemoryClient:
search_mode: str = "hybrid", base_url: str = ""):
# Lazy-install the SDK on demand (honors security.allow_lazy_installs and sealed Docker
# venvs). On failure fall through so the raw import produces the canonical ImportError.
- try:
- from tools.lazy_deps import ensure as _lazy_ensure
- _lazy_ensure("memory.supermemory", prompt=False)
- except Exception:
- pass
+ _quietly(lambda: importlib.import_module("tools.lazy_deps").ensure("memory.supermemory", prompt=False))
from supermemory import Supermemory
-
self._api_key, self._container_tag, self._timeout = api_key, container_tag, timeout
- self._search_mode, self._base_url = _valid_search_mode(search_mode), _resolve_base_url(base_url)
+ self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else "hybrid"
+ self._base_url = _resolve_base_url(base_url)
self._client = Supermemory(api_key=api_key, base_url=self._base_url, timeout=timeout, max_retries=0,
default_headers={"x-sm-source": "hermes"})
@@ -210,8 +188,7 @@ class _SupermemoryClient:
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory app so the user
# can filter / bulk-manage them per source agent (a routing key for the user, not telemetry).
merged = {"sm_source": "hermes", **(metadata or {})}
- legacy_source = merged.pop("source", None)
- if legacy_source and "type" not in merged:
+ if (legacy_source := merged.pop("source", None)) and "type" not in merged:
merged["type"] = str(legacy_source)
return merged
@@ -238,8 +215,7 @@ class _SupermemoryClient:
search_data = getattr(response, "search_results", None) or getattr(response, "searchResults", None)
raw_results = getattr(search_data, "results", None) or search_data or []
return {
- "static": (getattr(profile_data, "static", []) or []) if profile_data else [],
- "dynamic": (getattr(profile_data, "dynamic", []) or []) if profile_data else [],
+ **{k: (getattr(profile_data, k, []) or []) if profile_data else [] for k in ("static", "dynamic")},
"search_results": [item if isinstance(item, dict) else _memory_fields(item, "memory", "updated_at", "similarity")
for item in raw_results] if isinstance(raw_results, list) else [],
}
@@ -258,10 +234,9 @@ class _SupermemoryClient:
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],
**({"metadata": self._merge_metadata(metadata)} if metadata else {})}
- req = urllib.request.Request(
- f"{self._base_url}/v4/conversations", data=json.dumps(payload).encode("utf-8"), method="POST",
- headers={"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json", "x-sm-source": "hermes"},
- )
+ req = urllib.request.Request(f"{self._base_url}/v4/conversations", data=json.dumps(payload).encode("utf-8"), method="POST",
+ headers={"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json",
+ "x-sm-source": "hermes"})
with urllib.request.urlopen(req, timeout=self._timeout + 3):
return
@@ -282,20 +257,17 @@ def _probe_supermemory_connection(api_key: str, hermes_home: str, *, identity: s
status = {"ok": False, "error": "", "profile_facts": 0, "container_tag": _resolve_container_tag(config["container_tag"], identity),
"auto_recall": bool(config["auto_recall"]), "auto_capture": bool(config["auto_capture"])}
if not (api_key or "").strip():
- status["error"] = "SUPERMEMORY_API_KEY not set"
- return status
+ return {**status, "error": "SUPERMEMORY_API_KEY not set"}
try:
__import__("supermemory")
except ImportError:
- status["error"] = "supermemory package not installed"
- return status
+ return {**status, "error": "supermemory package not installed"}
try:
profile = _build_client(api_key.strip(), config, status["container_tag"]).get_profile()
- status["profile_facts"] = sum(1 for f in (profile.get("static") or []) + (profile.get("dynamic") or []) if f and str(f).strip())
- status["ok"] = True
except Exception as exc:
- status["error"] = str(exc).strip()[:160] or "connection failed"
- return status
+ return {**status, "error": str(exc).strip()[:160] or "connection failed"}
+ facts = sum(1 for f in (profile.get("static") or []) + (profile.get("dynamic") or []) if f and str(f).strip())
+ return {**status, "ok": True, "profile_facts": facts}
def _format_connection_summary(status: dict) -> str:
@@ -307,24 +279,19 @@ def _format_connection_summary(status: dict) -> str:
return f"✗ {status.get('error') or 'connection failed'} · container: {container} · {flags}"
-def _str_prop(description: str) -> dict:
- return {"type": "string", "description": description}
-
-
-# (name, description, properties, required) -> tool schema; kebab aliases are added in get_tool_schemas().
+# (name, description, ((prop, type, description), ...), required) -> tool schema; kebab aliases are added in get_tool_schemas().
_BASE_SCHEMAS = [
- {"name": name, "description": description, "parameters": {"type": "object", "properties": props, **({"required": req} if req else {})}}
+ {"name": name, "description": description,
+ "parameters": {"type": "object", "properties": {p: {"type": t, "description": d} for p, t, d in props}, **({"required": req} if req else {})}}
for name, description, props, req in (
("supermemory_store", "Store an explicit memory for future recall.",
- {"content": _str_prop("The memory content to store."),
- "metadata": {"type": "object", "description": "Optional metadata attached to the memory."}}, ["content"]),
+ (("content", "string", "The memory content to store."), ("metadata", "object", "Optional metadata attached to the memory.")), ["content"]),
("supermemory_search", "Search long-term memory by semantic similarity.",
- {"query": _str_prop("What to search for."),
- "limit": {"type": "integer", "description": "Maximum results to return, 1 to 20."}}, ["query"]),
+ (("query", "string", "What to search for."), ("limit", "integer", "Maximum results to return, 1 to 20.")), ["query"]),
("supermemory_forget", "Forget a memory by exact id or by best-match query.",
- {"id": _str_prop("Exact memory id to delete."), "query": _str_prop("Query used to find the memory to forget.")}, None),
+ (("id", "string", "Exact memory id to delete."), ("query", "string", "Query used to find the memory to forget.")), None),
("supermemory_profile", "Retrieve persistent profile facts and recent memory context.",
- {"query": _str_prop("Optional query to focus the profile response.")}, None),
+ (("query", "string", "Optional query to focus the profile response."),), None),
)
]
@@ -341,22 +308,17 @@ class SupermemoryMemoryProvider(MemoryProvider):
def __init__(self):
self._api_key = self._session_id = self._hermes_home = ""
self._client: Optional[_SupermemoryClient] = None
- self._container_tag = _DEFAULT_CONTAINER_TAG
- self._turn_count = 0
+ self._container_tag, self._turn_count, self._write_enabled, self._active = _DEFAULT_CONTAINER_TAG, 0, True, False
self._prefetch_thread = self._sync_thread = self._write_thread = None # only _write_thread is ever started
- self._write_enabled = True
- self._active = False
self._session_turns: List[Dict[str, str]] = []
self._apply_config(_load_supermemory_config())
- self._base_url = _DEFAULT_BASE_URL # env var is only consulted in initialize()
- self._allowed_containers = []
+ self._base_url, self._allowed_containers = _DEFAULT_BASE_URL, [] # env var is only consulted in initialize()
def _apply_config(self, config: dict) -> None:
for key in ("auto_recall", "auto_capture", "max_recall_results", "profile_frequency", "capture_mode",
"search_mode", "entity_context", "api_timeout", "custom_containers", "custom_container_instructions"):
setattr(self, f"_{key}", config[key])
- self._base_url = _resolve_base_url(config["base_url"])
- self._enable_custom_containers = config["enable_custom_container_tags"]
+ self._base_url, self._enable_custom_containers = _resolve_base_url(config["base_url"]), config["enable_custom_container_tags"]
self._allowed_containers: List[str] = [self._container_tag] + list(self._custom_containers)
@property
@@ -364,43 +326,37 @@ class SupermemoryMemoryProvider(MemoryProvider):
return "supermemory"
def is_available(self) -> bool:
- # Key presence only, no SDK import check: the SDK is lazy-installed when the client is first constructed
- # in initialize(), so gating on importability here is a chicken-and-egg trap on sealed venvs. Mirrors honcho/mem0.
+ # Key presence only, no SDK import check: the SDK is lazy-installed in initialize(), so gating on
+ # importability here is a chicken-and-egg trap on sealed venvs. Mirrors honcho/mem0.
return bool(get_secret("SUPERMEMORY_API_KEY", ""))
def get_config_schema(self):
- # Only the API key is prompted during `hermes memory setup`; other options live in
- # $HERMES_HOME/supermemory.json or SUPERMEMORY_CONTAINER_TAG.
+ # Only the API key is prompted during `hermes memory setup`; other options live in supermemory.json / env.
return [{"key": "api_key", "description": "Supermemory API key", "secret": True, "required": True, "env_var": "SUPERMEMORY_API_KEY", "url": _API_KEY_URL}]
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"]))
+ for key, fix in (("container_tag", _sanitize_tag), ("entity_context", _clamp_entity_context)):
+ if key in sanitized:
+ sanitized[key] = fix(str(sanitized[key]))
_save_supermemory_config(sanitized, hermes_home)
def get_status_config(self, provider_config: dict) -> dict:
from hermes_constants import get_hermes_home
- status = _probe_supermemory_connection(get_secret("SUPERMEMORY_API_KEY", "") or "", str(get_hermes_home()))
- return {"summary": _format_connection_summary(status)}
+ return {"summary": _format_connection_summary(_probe_supermemory_connection(get_secret("SUPERMEMORY_API_KEY", "") or "", str(get_hermes_home())))}
def post_setup(self, hermes_home: str, config: dict) -> None:
from hermes_cli.config import save_config
from hermes_cli.memory_setup import _prompt, _write_env_vars
-
print(f"\n Configuring supermemory:\n\n Get your API key at {_API_KEY_URL}\n")
existing = os.environ.get("SUPERMEMORY_API_KEY", "")
masked = f"...{existing[-4:]}" if len(existing) > 4 else "set"
val = _prompt(f"Supermemory API key (current: {masked}, blank to keep)" if existing else "Supermemory API key", secret=True)
- if not isinstance(config.get("memory"), dict):
- config["memory"] = {}
- config["memory"]["provider"] = self.name
+ memory = config["memory"] = config["memory"] if isinstance(config.get("memory"), dict) else {}
+ memory["provider"] = self.name
save_config(config)
if val:
_write_env_vars({"SUPERMEMORY_API_KEY": val}, hermes_home=hermes_home)
-
api_key = val or existing
# Make the freshly-entered key visible to the probe below. Single-profile only: under a multiplexed
# gateway, writing to the process-global environ would leak the key to sibling profiles and their subprocesses.
@@ -415,40 +371,27 @@ class SupermemoryMemoryProvider(MemoryProvider):
def initialize(self, session_id: str, **kwargs) -> None:
from hermes_constants import get_hermes_home
self._hermes_home = kwargs.get("hermes_home") or str(get_hermes_home())
- self._session_id = session_id
- self._turn_count = 0
+ self._session_id, self._turn_count, self._session_turns = session_id, 0, []
config = _load_supermemory_config(self._hermes_home)
self._api_key = get_secret("SUPERMEMORY_API_KEY", "") or ""
self._container_tag = _resolve_container_tag(config["container_tag"], kwargs.get("agent_identity", "default"))
self._apply_config(config)
- self._session_turns = []
self._write_enabled = kwargs.get("agent_context", "") not in {"cron", "flush", "subagent"}
- self._active = bool(self._api_key)
- self._client = None
- if self._active:
- try:
- self._client = _build_client(self._api_key, config, self._container_tag)
- except Exception:
- logger.warning("Supermemory initialization failed", exc_info=True)
- self._active = False
+ self._client = _quietly(lambda: _build_client(self._api_key, config, self._container_tag),
+ "Supermemory initialization failed", level=logging.WARNING) if self._api_key else None
+ self._active = self._client is not 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 ""
- lines = [
- "# Supermemory",
- f"Active. Container: {self._container_tag}.",
- "Use supermemory-search, supermemory-save, supermemory-forget, and supermemory-profile (aliases: supermemory_search, supermemory_store, supermemory_forget, supermemory_profile).",
- ]
+ lines = ["# Supermemory", f"Active. Container: {self._container_tag}.",
+ "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:
- lines.append(f"\nMulti-container mode enabled. Available containers: {', '.join(self._allowed_containers)}.")
- lines.append("Pass an optional container_tag to supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile to target a specific container.")
- if self._custom_container_instructions:
- lines.append(f"\n{self._custom_container_instructions}")
- return "\n".join(lines)
+ lines += [f"\nMulti-container mode enabled. Available containers: {', '.join(self._allowed_containers)}.",
+ "Pass an optional container_tag to supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile to target a specific container."]
+ lines += [f"\n{self._custom_container_instructions}"] if self._custom_container_instructions else []
+ return "\n".join(lines) if self._active else ""
def _can_write(self) -> bool:
return bool(self._active and self._write_enabled and self._client)
@@ -456,45 +399,37 @@ class SupermemoryMemoryProvider(MemoryProvider):
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:
+ def _recall():
profile = self._client.get_profile(query=query[:200])
include_profile = self._turn_count <= 1 or (self._turn_count % self._profile_frequency == 0)
- return _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)
- except Exception:
- logger.debug("Supermemory prefetch failed", exc_info=True)
- return ""
+ return _format_prefetch_context(profile["static"] if include_profile else [], profile["dynamic"] if include_profile else [],
+ profile["search_results"], self._max_recall_results)
+ return _quietly(_recall, "Supermemory prefetch failed", default="")
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
if not self._can_write() or not self._auto_capture:
return
- clean_user, clean_assistant = _clean_text_for_capture(user_content), _clean_text_for_capture(assistant_content)
- if clean_user or clean_assistant: # buffered for the single full-session document written at end/switch/shutdown
- self._session_turns.append({"user": clean_user, "assistant": clean_assistant})
+ turn = {"user": _clean_text_for_capture(user_content), "assistant": _clean_text_for_capture(assistant_content)}
+ if any(turn.values()): # buffered for the single full-session document written at end/switch/shutdown
+ self._session_turns.append(turn)
- def _ingest_session(self, session_id: str, messages: list[dict], metadata: dict, fail_msg: str, level: int = logging.DEBUG) -> None:
- try:
- self._client.ingest_conversation(session_id, messages, metadata={"type": "full_session", "session_id": session_id, **metadata})
- except Exception:
- logger.log(level, fail_msg, exc_info=True)
+ def _ingest(self, session_id: str, messages: list[dict], metadata: dict, fail_msg: str, level: int = logging.DEBUG) -> None:
+ metadata = {"type": "full_session", "session_id": session_id, **metadata}
+ _quietly(lambda: self._client.ingest_conversation(session_id, messages, metadata=metadata), fail_msg, level=level)
- def _ingest_buffered_turns(self, session_id: str, *, partial: bool, fail_msg: str) -> None:
- # message_count reports 2 per buffered turn regardless of empty sides.
- turns = self._session_turns
+ def _flush_turns(self, session_id: str, *, partial: bool, fail_msg: str) -> None:
+ turns = self._session_turns # message_count reports 2 per buffered turn regardless of empty sides
messages = [{"role": role, "content": t[role]} for t in turns for role in ("user", "assistant") if t.get(role)]
- self._ingest_session(session_id, messages, {"message_count": len(turns) * 2, "partial": partial}, fail_msg)
+ self._ingest(session_id, messages, {"message_count": len(turns) * 2, "partial": partial}, fail_msg)
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if not self._can_write() or not self._session_id:
return
- cleaned = [{"role": m.get("role"), "content": _clean_text_for_capture(str(m.get("content", "")))}
- for m in messages or [] if m.get("role") in {"user", "assistant"}]
- cleaned = [m for m in cleaned if m["content"]]
+ cleaned = [{"role": m.get("role"), "content": content} for m in messages or []
+ if m.get("role") in {"user", "assistant"} and (content := _clean_text_for_capture(str(m.get("content", ""))))]
if not cleaned or (len(cleaned) == 1 and len(cleaned[0]["content"]) < 20):
return
- self._ingest_session(self._session_id, cleaned, {"message_count": len(cleaned)}, "Supermemory session ingest failed",
- level=logging.WARNING)
+ self._ingest(self._session_id, cleaned, {"message_count": len(cleaned)}, "Supermemory session ingest failed", level=logging.WARNING)
self._session_turns = [] # so shutdown() doesn't duplicate on normal exit
def on_session_switch(self, new_session_id: str, *, parent_session_id: str = "", reset: bool = False, **kwargs) -> None:
@@ -502,7 +437,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
old_session_id = self._session_id
if self._can_write():
if self._session_turns and old_session_id:
- self._ingest_buffered_turns(old_session_id, partial=not reset, fail_msg="Supermemory session-switch ingest failed")
+ self._flush_turns(old_session_id, partial=not reset, fail_msg="Supermemory session-switch ingest failed")
self._turn_count = 0
self._session_id = str(new_session_id or "").strip() or old_session_id
self._session_turns = []
@@ -510,46 +445,36 @@ class SupermemoryMemoryProvider(MemoryProvider):
def on_memory_write(self, action: str, target: str, content: str) -> None:
if not self._can_write() or action != "add" or not (content or "").strip():
return
-
- def _run():
- try:
- self._client.add_memory(content.strip(), metadata={"target": target, "type": "explicit_memory"},
- entity_context=self._entity_context)
- except Exception:
- logger.debug("Supermemory on_memory_write failed", exc_info=True)
-
if self._write_thread and self._write_thread.is_alive():
self._write_thread.join(timeout=2.0)
- self._write_thread = threading.Thread(target=_run, daemon=False, name="supermemory-memory-write")
+ self._write_thread = threading.Thread(
+ target=_quietly, daemon=False, name="supermemory-memory-write",
+ args=(lambda: self._client.add_memory(content.strip(), metadata={"target": target, "type": "explicit_memory"},
+ entity_context=self._entity_context), "Supermemory on_memory_write failed"))
self._write_thread.start()
def shutdown(self) -> None:
# Emergency fallback (crashes only). Buffer is cleared on normal on_session_end().
if self._can_write() 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))
- self._ingest_buffered_turns(self._session_id, partial=True, fail_msg="Supermemory shutdown ingest failed")
+ self._flush_turns(self._session_id, partial=True, fail_msg="Supermemory shutdown ingest failed")
if self._write_thread and self._write_thread.is_alive():
self._write_thread.join(timeout=5.0)
self._prefetch_thread = self._sync_thread = self._write_thread = None
def get_tool_schemas(self) -> List[Dict[str, Any]]:
schemas = [json.loads(json.dumps(base)) for base in _BASE_SCHEMAS] # deep copies
- if self._enable_custom_containers:
- for schema in schemas: # multi-container mode: every tool takes an optional container_tag
- schema["parameters"]["properties"]["container_tag"] = {
- "type": "string",
- "description": f"Optional container tag. Allowed: {', '.join(self._allowed_containers)}. Defaults to primary ({self._container_tag}).",
- }
+ for schema in schemas if self._enable_custom_containers else (): # multi-container mode: every tool takes container_tag
+ schema["parameters"]["properties"]["container_tag"] = {
+ "type": "string", "description": f"Optional container tag. Allowed: {', '.join(self._allowed_containers)}. Defaults to primary ({self._container_tag})."}
# Kebab-case aliases are appended after all snake_case schemas (deep-copied, name swapped).
return schemas + [{**json.loads(json.dumps(s)), "name": _KEBAB_ALIASES[s["name"]]} for s in schemas]
def _tool_container_tag(self, args: dict) -> Optional[str]:
"""Validated container_tag from args; None = primary. Raises _TagError when not whitelisted."""
- tag = str(args.get("container_tag") or "").strip() if self._enable_custom_containers else ""
- if not tag:
- return None
- tag = _sanitize_tag(tag)
- if tag not in self._allowed_containers:
+ raw = str(args.get("container_tag") or "").strip() if self._enable_custom_containers else ""
+ tag = _sanitize_tag(raw) if raw else None
+ if tag and tag not in self._allowed_containers:
raise _TagError(f"Container tag '{tag}' is not allowed. Allowed: {', '.join(self._allowed_containers)}")
return tag
@@ -557,9 +482,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
content = str(args.get("content") or "").strip()
if not content:
return tool_error("content is required")
- metadata = args.get("metadata") or {}
- if not isinstance(metadata, dict):
- metadata = {}
+ metadata = args.get("metadata") if isinstance(args.get("metadata"), dict) else {}
metadata.setdefault("type", _detect_category(content))
metadata.pop("source", None)
tag = self._tool_container_tag(args)
@@ -572,22 +495,19 @@ class SupermemoryMemoryProvider(MemoryProvider):
return tool_error("query is required")
limit = _clamp_number(args.get("limit", 5) or 5, 5, 1, 20, int)
tag = self._tool_container_tag(args)
- formatted = []
- for item in self._client.search_memories(query, limit=limit, container_tag=tag):
- pct = _similarity_pct(item.get("similarity"))
- formatted.append({"id": item.get("id", ""), "content": item.get("memory", ""), **({"similarity": pct} if pct is not None else {})})
- return _tagged({"results": formatted, "count": len(formatted)}, tag)
+ results = [{"id": i.get("id", ""), "content": i.get("memory", ""), **({"similarity": pct} if (pct := _similarity_pct(i.get("similarity"))) is not None else {})}
+ for i in self._client.search_memories(query, limit=limit, container_tag=tag)]
+ return _tagged({"results": results, "count": len(results)}, tag)
def _tool_forget(self, args: dict) -> dict | str:
- memory_id = str(args.get("id") or "").strip()
- query = str(args.get("query") or "").strip()
+ memory_id, query = str(args.get("id") or "").strip(), str(args.get("query") or "").strip()
if not memory_id and not query:
return tool_error("Provide either id or query")
tag = self._tool_container_tag(args) # not echoed in the response
- if memory_id:
- self._client.forget_memory(memory_id, container_tag=tag)
- return {"forgotten": True, "id": memory_id}
- return self._client.forget_by_query(query, container_tag=tag)
+ if not memory_id:
+ return self._client.forget_by_query(query, container_tag=tag)
+ self._client.forget_memory(memory_id, container_tag=tag)
+ return {"forgotten": True, "id": memory_id}
def _tool_profile(self, args: dict) -> dict:
tag = self._tool_container_tag(args)
@@ -605,10 +525,8 @@ class SupermemoryMemoryProvider(MemoryProvider):
handler, fail_prefix = self._TOOL_HANDLERS[tool_name]
try:
resp = handler(self, args)
- except _TagError as exc:
- return tool_error(str(exc))
except Exception as exc:
- return tool_error(f"{fail_prefix}: {exc}")
+ return tool_error(str(exc) if isinstance(exc, _TagError) else f"{fail_prefix}: {exc}")
return resp if isinstance(resp, str) else json.dumps(resp)
# snake_case tool name -> (handler, error prefix); kebab aliases are folded in via _ALIAS_TO_TOOL first.