From c449b2fe5303141fee9997c53c9ac1d1cd7ae9f6 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Fri, 7 Aug 2026 19:39:58 -0700 Subject: [PATCH 1/3] fix(python-sdks): migrate agent-framework, cartesia, and pipecat to v4 APIs Use client.add and search.memories hybrid mode, improve profile memory deduplication for string/pydantic items, and add dedupe unit tests. Co-authored-by: Cursor --- .../agent-framework-python/pyproject.toml | 2 +- .../src/supermemory_agent_framework/tools.py | 19 +- .../src/supermemory_agent_framework/utils.py | 9 +- .../tests/test_utils.py | 14 ++ packages/cartesia-sdk-python/pyproject.toml | 2 +- .../src/supermemory_cartesia/agent.py | 22 ++- .../src/supermemory_cartesia/utils.py | 55 ++++-- .../tests/test_dedupe_utils.py | 120 ++++++++++++ .../tests/test_empty_profile.py | 4 + packages/pipecat-sdk-python/pyproject.toml | 2 +- .../src/supermemory_pipecat/service.py | 9 +- .../src/supermemory_pipecat/utils.py | 55 ++++-- .../tests/test_dedupe_utils.py | 180 ++++++++++++++++++ .../tests/test_empty_profile.py | 6 +- 14 files changed, 441 insertions(+), 58 deletions(-) create mode 100644 packages/cartesia-sdk-python/tests/test_dedupe_utils.py create mode 100644 packages/pipecat-sdk-python/tests/test_dedupe_utils.py diff --git a/packages/agent-framework-python/pyproject.toml b/packages/agent-framework-python/pyproject.toml index 659830832..deedba27b 100644 --- a/packages/agent-framework-python/pyproject.toml +++ b/packages/agent-framework-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "supermemory-agent-framework" -version = "1.0.0" +version = "1.0.1" description = "Memory tools and middleware for Microsoft Agent Framework with supermemory" readme = "README.md" license = "MIT" diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/tools.py b/packages/agent-framework-python/src/supermemory_agent_framework/tools.py index c59ca6e1f..780637a38 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/tools.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/tools.py @@ -72,19 +72,20 @@ async def search_memories( ] = True, limit: Annotated[int, "Maximum number of results to return"] = 10, ) -> str: - """Search (recall) memories/details/information about the user or other facts or entities. Run when explicitly asked or when context about user's past choices would be helpful.""" + """Search stored memories for facts, preferences, history, and context. Use proactively before answering whenever memory could help — not only when explicitly asked.""" try: - response = await self._client.search.execute( + response = await self._client.search.memories( q=information_to_get, container_tags=[self._connection.container_tag], limit=limit, - chunk_threshold=0.6, - include_full_docs=include_full_docs, + threshold=0.6, + search_mode="hybrid", ) + results = response.results or [] result: MemorySearchResult = { "success": True, - "results": response.results, - "count": len(response.results) if response.results else 0, + "results": results, + "count": len(results), } return json.dumps(result, default=str) except Exception as error: @@ -152,9 +153,9 @@ def get_tools(self) -> list[FunctionTool]: tool( name="search_memories", description=( - "Search (recall) memories/details/information about the user or other " - "facts or entities. Run when explicitly asked or when context about " - "user's past choices would be helpful." + "Search (recall) stored memories for facts, preferences, history, and context " + "about the user or any topic. Use proactively before answering whenever memory " + "could help — do not wait for the user to explicitly ask you to search or recall." ), )(self.search_memories), tool( diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/utils.py b/packages/agent-framework-python/src/supermemory_agent_framework/utils.py index 8b8c9be03..dac80c3c7 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/utils.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/utils.py @@ -92,14 +92,19 @@ def deduplicate_memories( def extract_memory_text(item: Any) -> Optional[str]: if item is None: return None + if isinstance(item, str): + trimmed = item.strip() + return trimmed if trimmed else None if isinstance(item, dict): memory = item.get("memory") if isinstance(memory, str): trimmed = memory.strip() return trimmed if trimmed else None return None - if isinstance(item, str): - trimmed = item.strip() + # Stainless SDK returns pydantic models (attribute access, snake_case). + memory = getattr(item, "memory", None) + if isinstance(memory, str): + trimmed = memory.strip() return trimmed if trimmed else None return None diff --git a/packages/agent-framework-python/tests/test_utils.py b/packages/agent-framework-python/tests/test_utils.py index 6b9362bbc..5d0d9b8c0 100644 --- a/packages/agent-framework-python/tests/test_utils.py +++ b/packages/agent-framework-python/tests/test_utils.py @@ -56,6 +56,20 @@ def test_none_items_filtered(self) -> None: ) assert result.static == ["valid"] + def test_pydantic_like_search_results(self) -> None: + """SDK search results are pydantic models, not dicts (#1266).""" + from types import SimpleNamespace + + result = deduplicate_memories( + static=["User likes Python"], + search_results=[ + SimpleNamespace(memory="User prefers async", updated_at="2026-01-01T00:00:00Z"), + SimpleNamespace(memory="User likes Python", updated_at=None), + ], + ) + assert result.static == ["User likes Python"] + assert result.search_results == ["User prefers async"] + class TestConvertProfileToMarkdown: def test_empty_profile(self) -> None: diff --git a/packages/cartesia-sdk-python/pyproject.toml b/packages/cartesia-sdk-python/pyproject.toml index 81dac7fd1..7cb93edc4 100644 --- a/packages/cartesia-sdk-python/pyproject.toml +++ b/packages/cartesia-sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "supermemory-cartesia" -version = "0.1.1" +version = "0.1.2" description = "Supermemory integration for Cartesia Line - memory-enhanced voice agents" readme = "README.md" license = "MIT" diff --git a/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py b/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py index 9019193e8..d369af8ea 100644 --- a/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py +++ b/packages/cartesia-sdk-python/src/supermemory_cartesia/agent.py @@ -151,31 +151,35 @@ async def _retrieve_memories(self, query: str) -> Dict[str, Any]: raise MemoryRetrievalError("Supermemory client not initialized") try: - # Use primary container tag for profile retrieval - kwargs: Dict[str, Any] = {"container_tag": self.container_tags[0]} + logger.info(f"[Supermemory] Retrieving memories for query: {query[:50]}...") + # One profile call: static + dynamic, and (when mode/query allow) + # search_results via `q` — keeps a single round trip for latency. + kwargs: Dict[str, Any] = {"container_tag": self.container_tags[0]} if self.config.mode != "profile" and query: kwargs["q"] = query kwargs["threshold"] = self.config.search_threshold kwargs["extra_body"] = {"limit": self.config.search_limit} - logger.info(f"[Supermemory] Retrieving memories for query: {query[:50]}...") - response = await asyncio.wait_for( self._supermemory_client.profile(**kwargs), - timeout=10.0 + timeout=10.0, ) # A user with no stored memories yet gets a null profile back, which # is a normal case, not an error. Guard against it so we return an # empty profile instead of raising AttributeError on response.profile. profile = getattr(response, "profile", None) - profile_static = profile.static if profile is not None and profile.static else [] - profile_dynamic = profile.dynamic if profile is not None and profile.dynamic else [] + profile_static = ( + profile.static if profile is not None and profile.static else [] + ) + profile_dynamic = ( + profile.dynamic if profile is not None and profile.dynamic else [] + ) - search_results = [] + search_results: List[Any] = [] if response.search_results and response.search_results.results: - search_results = response.search_results.results + search_results = list(response.search_results.results) logger.info( f"[Supermemory] Retrieved memories - static: {len(profile_static)}, " diff --git a/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py b/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py index eb3664262..5cda92985 100644 --- a/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py +++ b/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py @@ -49,17 +49,37 @@ def format_relative_time(iso_timestamp: str) -> str: return "" +def _field(item: Any, *names: str, default: Any = None) -> Any: + """Read a field from a dict or pydantic/SDK model. + + Accepts camelCase and snake_case names so helpers work with both raw JSON + dicts and Stainless-generated response models. + """ + if item is None: + return default + if isinstance(item, dict): + for name in names: + if name in item and item[name] is not None: + return item[name] + return default + for name in names: + value = getattr(item, name, None) + if value is not None: + return value + return default + + def deduplicate_memories( static: List[str], dynamic: List[str], - search_results: List[Dict[str, Any]], -) -> Dict[str, Union[List[str], List[Dict[str, Any]]]]: + search_results: List[Any], +) -> Dict[str, Union[List[str], List[Any]]]: """Deduplicate memories. Priority: static > dynamic > search. Args: static: List of static memory strings. dynamic: List of dynamic memory strings. - search_results: List of search result dicts with 'memory' and 'updatedAt'. + search_results: Search result dicts or pydantic models with a memory field. """ seen = set() @@ -71,10 +91,14 @@ def unique_strings(memories: List[str]) -> List[str]: out.append(m) return out - def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def unique_search(results: List[Any]) -> List[Any]: out = [] for r in results: - memory = r.get("memory", "") + # v4 search.memories/hybrid uses `memory` or `chunk`. + memory = _field(r, "memory", "chunk", "content", default="") + if not isinstance(memory, str): + memory = "" + memory = memory.strip() if memory and memory not in seen: seen.add(memory) out.append(r) @@ -88,7 +112,7 @@ def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def format_memories_to_text( - memories: Dict[str, Union[List[str], List[Dict[str, Any]]]], + memories: Dict[str, Union[List[str], List[Any]]], system_prompt: str = "Based on previous conversations, I recall:\n\n", include_static: bool = True, include_dynamic: bool = True, @@ -116,16 +140,17 @@ def format_memories_to_text( sections.append("## Relevant Memories") lines = [] for item in search_results: - if isinstance(item, dict): - memory = item.get("memory", "") - updated_at = item.get("updatedAt", "") - time_str = format_relative_time(updated_at) if updated_at else "" - if time_str: - lines.append(f"- [{time_str}] {memory}") - else: - lines.append(f"- {memory}") - else: + if isinstance(item, str): lines.append(f"- {item}") + continue + + memory = _field(item, "memory", "chunk", "content", default="") + updated_at = _field(item, "updatedAt", "updated_at", default="") + time_str = format_relative_time(updated_at) if updated_at else "" + if time_str: + lines.append(f"- [{time_str}] {memory}") + else: + lines.append(f"- {memory}") sections.append("\n".join(lines)) if not sections: diff --git a/packages/cartesia-sdk-python/tests/test_dedupe_utils.py b/packages/cartesia-sdk-python/tests/test_dedupe_utils.py new file mode 100644 index 000000000..81931fe1f --- /dev/null +++ b/packages/cartesia-sdk-python/tests/test_dedupe_utils.py @@ -0,0 +1,120 @@ +"""Regression tests for pydantic/dict memory helpers (#1266).""" + +from __future__ import annotations + +import sys +import types +import unittest +from types import SimpleNamespace + + +def _install_test_stubs() -> None: + if "loguru" not in sys.modules: + loguru_module = types.ModuleType("loguru") + + class _Logger: + def info(self, *_args, **_kwargs): + return None + + def warning(self, *_args, **_kwargs): + return None + + def error(self, *_args, **_kwargs): + return None + + loguru_module.logger = _Logger() + sys.modules["loguru"] = loguru_module + + if "pydantic" not in sys.modules: + pydantic_module = types.ModuleType("pydantic") + + class BaseModel: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def Field(*, default=None, **_kwargs): + return default + + pydantic_module.BaseModel = BaseModel + pydantic_module.Field = Field + sys.modules["pydantic"] = pydantic_module + + +_install_test_stubs() + +from supermemory_cartesia.utils import deduplicate_memories, format_memories_to_text + + +class TestDeduplicateMemories(unittest.TestCase): + def test_accepts_dict_search_results(self) -> None: + result = deduplicate_memories( + static=["User likes Python"], + dynamic=[], + search_results=[{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}], + ) + self.assertEqual(result["static"], ["User likes Python"]) + self.assertEqual(len(result["search_results"]), 1) + + def test_accepts_pydantic_like_search_results(self) -> None: + # Mirrors supermemory.types.search_memories_response.Result + model = SimpleNamespace( + id="mem_1", + similarity=0.9, + memory="User prefers async", + updated_at="2026-01-01T00:00:00Z", + ) + result = deduplicate_memories( + static=[], + dynamic=[], + search_results=[model], + ) + self.assertEqual(len(result["search_results"]), 1) + self.assertIs(result["search_results"][0], model) + + def test_dedupes_model_against_static_string(self) -> None: + model = SimpleNamespace(memory="User likes Python", updated_at=None) + result = deduplicate_memories( + static=["User likes Python"], + dynamic=[], + search_results=[model], + ) + self.assertEqual(result["search_results"], []) + + +class TestFormatMemoriesToText(unittest.TestCase): + def test_formats_pydantic_like_search_results(self) -> None: + text = format_memories_to_text( + { + "static": [], + "dynamic": [], + "search_results": [ + SimpleNamespace( + memory="User prefers async", + updated_at="2020-01-01T00:00:00Z", + ) + ], + } + ) + self.assertIn("User prefers async", text) + self.assertIn("Relevant Memories", text) + + def test_formats_search_execute_content_field(self) -> None: + text = format_memories_to_text( + { + "static": [], + "dynamic": [], + "search_results": [ + SimpleNamespace( + content="User owns a telescope", + updated_at="2020-01-01T00:00:00Z", + memory=None, + ) + ], + } + ) + self.assertIn("User owns a telescope", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cartesia-sdk-python/tests/test_empty_profile.py b/packages/cartesia-sdk-python/tests/test_empty_profile.py index 382e5e5f6..a430007ed 100644 --- a/packages/cartesia-sdk-python/tests/test_empty_profile.py +++ b/packages/cartesia-sdk-python/tests/test_empty_profile.py @@ -71,6 +71,10 @@ async def test_retrieve_memories_handles_null_profile(self) -> None: "search_results": [], }, ) + agent._supermemory_client.profile.assert_awaited_once() + kwargs = agent._supermemory_client.profile.await_args.kwargs + self.assertEqual(kwargs["container_tag"], "user-123") + self.assertEqual(kwargs["q"], "Hello world") if __name__ == "__main__": diff --git a/packages/pipecat-sdk-python/pyproject.toml b/packages/pipecat-sdk-python/pyproject.toml index 1c25b6a20..92825ccb8 100644 --- a/packages/pipecat-sdk-python/pyproject.toml +++ b/packages/pipecat-sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "supermemory-pipecat" -version = "0.1.1" +version = "0.1.2" description = "Supermemory integration for Pipecat - memory-enhanced conversational AI pipelines" readme = "README.md" license = "MIT" diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py index eb9d5fb66..046960762 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py @@ -137,8 +137,9 @@ async def _retrieve_memories(self, query: str) -> Dict[str, Any]: ) try: + # One profile call: static + dynamic, and (when mode/query allow) + # search_results via `q`. This is the intended profile API shape. kwargs: Dict[str, Any] = {"container_tag": self.container_tag} - if self.params.mode != "profile" and query: kwargs["q"] = query kwargs["threshold"] = self.params.search_threshold @@ -149,9 +150,9 @@ async def _retrieve_memories(self, query: str) -> Dict[str, Any]: profile = getattr(response, "profile", None) search_results_response = getattr(response, "search_results", None) - search_results = [] + search_results: List[Any] = [] if search_results_response and search_results_response.results: - search_results = search_results_response.results + search_results = list(search_results_response.results) return { "profile": { @@ -179,7 +180,7 @@ async def _store_messages(self, messages: List[Dict[str, Any]]) -> None: if self.session_id: add_params["custom_id"] = self.session_id - await self._supermemory_client.memories.add(**add_params) + await self._supermemory_client.add(**add_params) except Exception as e: logger.error(f"Error storing messages: {e}") diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py index a27da2561..3b74509f7 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py @@ -49,17 +49,37 @@ def format_relative_time(iso_timestamp: str) -> str: return "" +def _field(item: Any, *names: str, default: Any = None) -> Any: + """Read a field from a dict or pydantic/SDK model. + + Accepts camelCase and snake_case names so helpers work with both raw JSON + dicts and Stainless-generated response models. + """ + if item is None: + return default + if isinstance(item, dict): + for name in names: + if name in item and item[name] is not None: + return item[name] + return default + for name in names: + value = getattr(item, name, None) + if value is not None: + return value + return default + + def deduplicate_memories( static: List[str], dynamic: List[str], - search_results: List[Dict[str, Any]], -) -> Dict[str, Union[List[str], List[Dict[str, Any]]]]: + search_results: List[Any], +) -> Dict[str, Union[List[str], List[Any]]]: """Deduplicate memories. Priority: static > dynamic > search. Args: static: List of static memory strings. dynamic: List of dynamic memory strings. - search_results: List of search result dicts with 'memory' and 'updatedAt'. + search_results: Search result dicts or pydantic models with a memory field. """ seen = set() @@ -71,10 +91,14 @@ def unique_strings(memories: List[str]) -> List[str]: out.append(m) return out - def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def unique_search(results: List[Any]) -> List[Any]: out = [] for r in results: - memory = r.get("memory", "") + # v4 search.memories/hybrid uses `memory` or `chunk`. + memory = _field(r, "memory", "chunk", "content", default="") + if not isinstance(memory, str): + memory = "" + memory = memory.strip() if memory and memory not in seen: seen.add(memory) out.append(r) @@ -88,7 +112,7 @@ def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def format_memories_to_text( - memories: Dict[str, Union[List[str], List[Dict[str, Any]]]], + memories: Dict[str, Union[List[str], List[Any]]], system_prompt: str = "Based on previous conversations, I recall:\n\n", include_static: bool = True, include_dynamic: bool = True, @@ -116,16 +140,17 @@ def format_memories_to_text( sections.append("## Relevant Memories") lines = [] for item in search_results: - if isinstance(item, dict): - memory = item.get("memory", "") - updated_at = item.get("updatedAt", "") - time_str = format_relative_time(updated_at) if updated_at else "" - if time_str: - lines.append(f"- [{time_str}] {memory}") - else: - lines.append(f"- {memory}") - else: + if isinstance(item, str): lines.append(f"- {item}") + continue + + memory = _field(item, "memory", "chunk", "content", default="") + updated_at = _field(item, "updatedAt", "updated_at", default="") + time_str = format_relative_time(updated_at) if updated_at else "" + if time_str: + lines.append(f"- [{time_str}] {memory}") + else: + lines.append(f"- {memory}") sections.append("\n".join(lines)) if not sections: diff --git a/packages/pipecat-sdk-python/tests/test_dedupe_utils.py b/packages/pipecat-sdk-python/tests/test_dedupe_utils.py new file mode 100644 index 000000000..94abeea2f --- /dev/null +++ b/packages/pipecat-sdk-python/tests/test_dedupe_utils.py @@ -0,0 +1,180 @@ +"""Regression tests for pydantic/dict memory helpers (#1266).""" + +from __future__ import annotations + +import sys +import types +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + + +def _install_test_stubs() -> None: + if "loguru" not in sys.modules: + loguru_module = types.ModuleType("loguru") + + class _Logger: + def warning(self, *_args, **_kwargs): + return None + + def error(self, *_args, **_kwargs): + return None + + def info(self, *_args, **_kwargs): + return None + + loguru_module.logger = _Logger() + sys.modules["loguru"] = loguru_module + + if "pydantic" not in sys.modules: + pydantic_module = types.ModuleType("pydantic") + + class BaseModel: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def Field(*, default=None, **_kwargs): + return default + + pydantic_module.BaseModel = BaseModel + pydantic_module.Field = Field + sys.modules["pydantic"] = pydantic_module + + if "pipecat" not in sys.modules: + pipecat_module = types.ModuleType("pipecat") + sys.modules["pipecat"] = pipecat_module + + frames_module = types.ModuleType("pipecat.frames.frames") + + class Frame: + pass + + class InputAudioRawFrame: + pass + + class LLMContextFrame: + pass + + class LLMMessagesFrame: + pass + + frames_module.Frame = Frame + frames_module.InputAudioRawFrame = InputAudioRawFrame + frames_module.LLMContextFrame = LLMContextFrame + frames_module.LLMMessagesFrame = LLMMessagesFrame + + llm_context_module = types.ModuleType( + "pipecat.processors.aggregators.llm_context" + ) + + class LLMContext: + pass + + llm_context_module.LLMContext = LLMContext + + openai_context_module = types.ModuleType( + "pipecat.processors.aggregators.openai_llm_context" + ) + + class OpenAILLMContextFrame: + pass + + openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame + + frame_processor_module = types.ModuleType("pipecat.processors.frame_processor") + + class FrameDirection: + pass + + class FrameProcessor: + def __init__(self, *args, **kwargs): + return None + + frame_processor_module.FrameDirection = FrameDirection + frame_processor_module.FrameProcessor = FrameProcessor + + sys.modules["pipecat.frames.frames"] = frames_module + sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module + sys.modules[ + "pipecat.processors.aggregators.openai_llm_context" + ] = openai_context_module + sys.modules["pipecat.processors.frame_processor"] = frame_processor_module + + +_install_test_stubs() + +from supermemory_pipecat.service import SupermemoryPipecatService +from supermemory_pipecat.utils import deduplicate_memories, format_memories_to_text + + +class TestDeduplicateMemories(unittest.TestCase): + def test_accepts_dict_search_results(self) -> None: + result = deduplicate_memories( + static=["User likes Python"], + dynamic=[], + search_results=[{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}], + ) + self.assertEqual(result["static"], ["User likes Python"]) + self.assertEqual(len(result["search_results"]), 1) + + def test_accepts_pydantic_like_search_results(self) -> None: + model = SimpleNamespace( + id="mem_1", + similarity=0.9, + memory="User prefers async", + updated_at="2026-01-01T00:00:00Z", + ) + result = deduplicate_memories( + static=[], + dynamic=[], + search_results=[model], + ) + self.assertEqual(len(result["search_results"]), 1) + self.assertIs(result["search_results"][0], model) + + def test_dedupes_model_against_static_string(self) -> None: + model = SimpleNamespace(memory="User likes Python", updated_at=None) + result = deduplicate_memories( + static=["User likes Python"], + dynamic=[], + search_results=[model], + ) + self.assertEqual(result["search_results"], []) + + +class TestFormatMemoriesToText(unittest.TestCase): + def test_formats_pydantic_like_search_results(self) -> None: + text = format_memories_to_text( + { + "static": [], + "dynamic": [], + "search_results": [ + SimpleNamespace( + memory="User prefers async", + updated_at="2020-01-01T00:00:00Z", + ) + ], + } + ) + self.assertIn("User prefers async", text) + self.assertIn("Relevant Memories", text) + + +class TestStoreMessagesUsesClientAdd(unittest.IsolatedAsyncioTestCase): + async def test_store_messages_calls_client_add(self) -> None: + service = SupermemoryPipecatService(api_key="mock_key", user_id="user-123") + service._supermemory_client = SimpleNamespace(add=AsyncMock()) + + await service._store_messages( + [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}] + ) + + service._supermemory_client.add.assert_awaited_once() + kwargs = service._supermemory_client.add.await_args.kwargs + self.assertIn("hello", kwargs["content"]) + self.assertEqual(kwargs["container_tags"], ["user-123"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/pipecat-sdk-python/tests/test_empty_profile.py b/packages/pipecat-sdk-python/tests/test_empty_profile.py index ec3ccd261..aea9011cc 100644 --- a/packages/pipecat-sdk-python/tests/test_empty_profile.py +++ b/packages/pipecat-sdk-python/tests/test_empty_profile.py @@ -120,4 +120,8 @@ async def test_retrieve_memories_handles_null_profile(self) -> None: "profile": {"static": [], "dynamic": []}, "search_results": [], }, - ) \ No newline at end of file + ) + service._supermemory_client.profile.assert_awaited_once() + kwargs = service._supermemory_client.profile.await_args.kwargs + self.assertEqual(kwargs["container_tag"], "new_user_123") + self.assertEqual(kwargs["q"], "Hello world") \ No newline at end of file From 4b41cba3845b71d2071704cba958dbf379830e31 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:19:42 +0000 Subject: [PATCH 2/3] chore: update bun.lock for v4 SDK dependencies Co-Authored-By: Claude Opus 4.5 --- bun.lock | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index d55c8237e..dc8a43246 100644 --- a/bun.lock +++ b/bun.lock @@ -258,12 +258,13 @@ }, "packages/ai-sdk": { "name": "@supermemory/ai-sdk", - "version": "1.0.8", + "version": "1.0.9", "dependencies": { "@ai-sdk/openai": "^2.0.22", "@ai-sdk/provider": "^2.0.0", + "@supermemory/tools": "workspace:*", "ai": "^5.0.113", - "supermemory": "^3.0.0-alpha.26", + "supermemory": "^4.25.4", }, "devDependencies": { "@total-typescript/tsconfig": "^1.0.4", @@ -343,7 +344,7 @@ "ai": "^5.0.29", "lru-cache": "^11.2.6", "openai": "^4.104.0", - "supermemory": "^3.0.0-alpha.26", + "supermemory": "^4.25.4", "zod": "^4.1.5", }, "devDependencies": { @@ -5526,6 +5527,8 @@ "@stoplight/yaml/@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], + "@supermemory/ai-sdk/supermemory": ["supermemory@4.25.4", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-97ME3rlmu7OmsXJTb9OgXOD+3VUv4Wej0ZX9xezG+LKkMwrzi4xeeAZaOJFcr0oI/QQjcHG2WOzm+und1e7MFA=="], + "@supermemory/ai-sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@supermemory/memory-graph/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -5536,9 +5539,11 @@ "@supermemory/tools/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.65.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw=="], + "@supermemory/tools/supermemory": ["supermemory@4.25.4", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-97ME3rlmu7OmsXJTb9OgXOD+3VUv4Wej0ZX9xezG+LKkMwrzi4xeeAZaOJFcr0oI/QQjcHG2WOzm+und1e7MFA=="], + "@supermemory/tools/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@supermemory/tools/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@supermemory/tools/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], From 6180c047f76cdb77962028e56278313cd0da8ee7 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:59:51 +0530 Subject: [PATCH 3/3] fix(openai-sdk-python): harden middleware requests --- packages/openai-sdk-python/README.md | 8 +- .../src/supermemory_openai/middleware.py | 99 +++++++++++++------ 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/packages/openai-sdk-python/README.md b/packages/openai-sdk-python/README.md index 9b455b3b8..cc13c9c55 100644 --- a/packages/openai-sdk-python/README.md +++ b/packages/openai-sdk-python/README.md @@ -49,7 +49,9 @@ async def main(): custom_id="chat-123", # Required: groups messages into documents mode="full", # "profile", "query", or "full" verbose=True, # Enable logging - add_memory="always" # Automatically save conversations (default) + add_memory="always", # Automatically save conversations (default) + api_key="your-supermemory-api-key", # Or use SUPERMEMORY_API_KEY + # base_url="https://api.supermemory.ai", # Optional custom endpoint ) ) @@ -357,6 +359,8 @@ class OpenAIMiddlewareOptions: verbose: bool = False # Enable detailed logging mode: Literal["profile", "query", "full"] = "profile" # Memory injection mode add_memory: Literal["always", "never"] = "always" # Auto-save behavior + api_key: Optional[str] = None # Falls back to SUPERMEMORY_API_KEY + base_url: Optional[str] = None # Falls back to SUPERMEMORY_BASE_URL ``` ### SupermemoryTools @@ -436,7 +440,7 @@ All exceptions include the original error for debugging and have descriptive err Set these environment variables: -- `SUPERMEMORY_API_KEY` - Your Supermemory API key (required) +- `SUPERMEMORY_API_KEY` - Your Supermemory API key (unless passed in middleware options) - `OPENAI_API_KEY` - Your OpenAI API key (required for examples) Optional for testing: diff --git a/packages/openai-sdk-python/src/supermemory_openai/middleware.py b/packages/openai-sdk-python/src/supermemory_openai/middleware.py index 9cbad1b08..5191a6c8b 100644 --- a/packages/openai-sdk-python/src/supermemory_openai/middleware.py +++ b/packages/openai-sdk-python/src/supermemory_openai/middleware.py @@ -28,6 +28,9 @@ get_last_user_message, ) +DEFAULT_SUPERMEMORY_BASE_URL = "https://api.supermemory.ai" +PROFILE_REQUEST_TIMEOUT_SECONDS = 30.0 + @dataclass class OpenAIMiddlewareOptions: @@ -38,6 +41,8 @@ class OpenAIMiddlewareOptions: verbose: bool = False mode: Literal["profile", "query", "full"] = "profile" add_memory: Literal["always", "never"] = "always" + api_key: Optional[str] = None + base_url: Optional[str] = None class SupermemoryProfileSearch: @@ -52,6 +57,7 @@ async def supermemory_profile_search( container_tag: str, query_text: str, api_key: str, + base_url: str, ) -> SupermemoryProfileSearch: """Search for memories using the SuperMemory profile API.""" payload = { @@ -59,20 +65,23 @@ async def supermemory_profile_search( } if query_text: payload["q"] = query_text + profile_url = f"{base_url.rstrip('/')}/v4/profile" try: import aiohttp - async with aiohttp.ClientSession() as session: + timeout = aiohttp.ClientTimeout(total=PROFILE_REQUEST_TIMEOUT_SECONDS) + async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post( - "https://api.supermemory.ai/v4/profile", + profile_url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", }, json=payload, + allow_redirects=False, ) as response: - if not response.ok: + if not 200 <= response.status < 300: error_text = await response.text() raise SupermemoryAPIError( "Supermemory profile search failed", @@ -88,15 +97,17 @@ async def supermemory_profile_search( import requests response = requests.post( - "https://api.supermemory.ai/v4/profile", + profile_url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", }, json=payload, + timeout=PROFILE_REQUEST_TIMEOUT_SECONDS, + allow_redirects=False, ) - if not response.ok: + if not 200 <= response.status_code < 300: raise SupermemoryAPIError( "Supermemory profile search failed", status_code=response.status_code, @@ -112,6 +123,7 @@ async def add_system_prompt( logger: Logger, mode: Literal["profile", "query", "full"], api_key: str, + base_url: str, ) -> list[ChatCompletionMessageParam]: """Add memory-enhanced system prompts to chat completion messages.""" system_prompt_exists = any(msg.get("role") == "system" for msg in messages) @@ -119,7 +131,10 @@ async def add_system_prompt( query_text = get_last_user_message(messages) if mode != "profile" else "" memories_response = await supermemory_profile_search( - container_tag, query_text, api_key + container_tag, + query_text, + api_key, + base_url, ) profile = memories_response.profile or {} @@ -199,9 +214,11 @@ async def add_system_prompt( if system_prompt_exists: logger.debug("Added memories to existing system prompt") return [ - {**msg, "content": f"{msg.get('content', '')} \n {memories}"} - if msg.get("role") == "system" - else msg + ( + {**msg, "content": f"{msg.get('content', '')} \n {memories}"} + if msg.get("role") == "system" + else msg + ) for msg in messages ] @@ -222,15 +239,17 @@ async def add_memory_tool( ) -> None: """Add a new memory to the SuperMemory system.""" try: - # Handle both sync and async supermemory clients - if custom_id is None: - result = client.add(content=content, container_tag=container_tag) + kwargs = {"content": content, "container_tag": container_tag} + if custom_id is not None: + kwargs["custom_id"] = custom_id + + # The wrapper currently constructs the synchronous Supermemory client for + # both OpenAI variants. Never execute that network call on an async event + # loop; mocks or future async clients can still return an awaitable. + if inspect.iscoroutinefunction(client.add): + result = client.add(**kwargs) else: - result = client.add( - content=content, - container_tag=container_tag, - custom_id=custom_id, - ) + result = await asyncio.to_thread(client.add, **kwargs) if inspect.isawaitable(result): response = await result else: @@ -273,6 +292,8 @@ def __init__( self._container_tag: str = options.container_tag self._options: OpenAIMiddlewareOptions = options self._logger: Logger = create_logger(self._options.verbose) + self._api_key = self._resolve_api_key(options.api_key) + self._base_url = self._resolve_base_url(options.base_url) # Track background tasks to ensure they complete self._background_tasks: set[asyncio.Task] = set() @@ -283,10 +304,10 @@ def __init__( ImportError("supermemory package not installed"), ) - api_key = self._get_api_key() try: self._supermemory_client: supermemory.Supermemory = supermemory.Supermemory( - api_key=api_key + api_key=self._api_key, + base_url=self._base_url, ) except Exception as e: raise SupermemoryConfigurationError( @@ -296,16 +317,28 @@ def __init__( # Wrap the chat completions create method self._wrap_chat_completions() - def _get_api_key(self) -> str: - """Get Supermemory API key from environment.""" - import os - - api_key = os.getenv("SUPERMEMORY_API_KEY") + @staticmethod + def _resolve_api_key(configured_api_key: Optional[str]) -> str: + """Resolve the API key once when the middleware is constructed.""" + api_key = (configured_api_key or "").strip() or ( + os.getenv("SUPERMEMORY_API_KEY") or "" + ).strip() if not api_key: raise SupermemoryConfigurationError( - "SUPERMEMORY_API_KEY environment variable is required but not set" + "A Supermemory API key is required. Pass api_key to " + "OpenAIMiddlewareOptions or set SUPERMEMORY_API_KEY." ) - return api_key + return api_key.strip() + + @staticmethod + def _resolve_base_url(configured_base_url: Optional[str]) -> str: + """Resolve and normalize the API base URL once.""" + base_url = ( + (configured_base_url or "").strip() + or (os.getenv("SUPERMEMORY_BASE_URL") or "").strip() + or DEFAULT_SUPERMEMORY_BASE_URL + ) + return base_url.rstrip("/") def _wrap_chat_completions(self) -> None: """Wrap the chat completions create method with memory injection.""" @@ -317,6 +350,7 @@ async def create_with_memory( **kwargs: Any, ) -> Any: return await self._create_with_memory_async(original_create, **kwargs) + else: def create_with_memory( @@ -413,7 +447,8 @@ def handle_task_exception(task_obj): self._container_tag, self._logger, self._options.mode, - self._get_api_key(), + self._api_key, + self._base_url, ) kwargs["messages"] = enhanced_messages @@ -500,7 +535,8 @@ def _create_with_memory_sync( self._container_tag, self._logger, self._options.mode, - self._get_api_key(), + self._api_key, + self._base_url, ) ) except RuntimeError as e: @@ -516,7 +552,8 @@ def _create_with_memory_sync( self._container_tag, self._logger, self._options.mode, - self._get_api_key(), + self._api_key, + self._base_url, ), ) enhanced_messages = future.result() @@ -558,7 +595,9 @@ async def wait_for_background_tasks(self, timeout: Optional[float] = 10.0) -> No f"Background tasks did not complete within {timeout}s timeout" ) # Cancel remaining tasks - tasks_to_cancel = [task for task in self._background_tasks if not task.done()] + tasks_to_cancel = [ + task for task in self._background_tasks if not task.done() + ] for task in tasks_to_cancel: task.cancel()