feat(memory): add OSS-to-Platform notices (#5494)

This commit is contained in:
youneshima
2026-06-13 18:34:20 +05:30
committed by GitHub
parent f322cf82b9
commit 3553fc79dd
29 changed files with 9694 additions and 33 deletions
+195 -2
View File
@@ -5,6 +5,7 @@ import hashlib
import json
import logging
import os
import time
import uuid
import warnings
from copy import deepcopy
@@ -26,6 +27,29 @@ from mem0.memory.base import MemoryBase
from mem0.memory.setup import mem0_dir, setup_config
from mem0.memory.storage import SQLiteManager
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
from mem0.memory.notices import (
PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS,
detect_scale_threshold_from_add_result,
detect_scale_threshold_from_top_k,
detect_decay_usage_from_delete,
detect_decay_usage_from_delete_all,
detect_temporal_usage_from_metadata,
detect_temporal_usage_from_search,
display_decay_usage_notice,
display_decay_usage_notice_async,
display_first_run_notice,
display_first_run_notice_async,
display_performance_slow_query_notice,
display_performance_slow_query_notice_async,
display_scale_threshold_notice,
display_scale_threshold_notice_async,
display_temporal_usage_notice,
display_temporal_usage_notice_async,
get_decay_feature_error_message,
get_decay_feature_error_message_async,
get_temporal_feature_error_message,
get_temporal_feature_error_message_async,
)
from mem0.memory.utils import (
extract_json,
parse_messages,
@@ -349,6 +373,36 @@ def _entity_collection_name(provider: str, collection_name: str) -> str:
setup_config()
logger = logging.getLogger(__name__)
_PROJECT_UPDATE_UNSUPPORTED_ERROR = "Project updates are not supported by the OSS Memory SDK."
class _OSSProject:
def update(
self,
custom_instructions: Optional[str] = None,
custom_categories: Optional[list] = None,
retrieval_criteria: Optional[list] = None,
multilingual: Optional[bool] = None,
decay: Optional[bool] = None,
):
if decay is True:
raise ValueError(get_decay_feature_error_message("sync", "project.update", "decay"))
raise ValueError(_PROJECT_UPDATE_UNSUPPORTED_ERROR)
class _AsyncOSSProject:
async def update(
self,
custom_instructions: Optional[str] = None,
custom_categories: Optional[list] = None,
retrieval_criteria: Optional[list] = None,
multilingual: Optional[bool] = None,
decay: Optional[bool] = None,
):
if decay is True:
raise ValueError(await get_decay_feature_error_message_async("async", "project.update", "decay"))
raise ValueError(_PROJECT_UPDATE_UNSUPPORTED_ERROR)
class Memory(MemoryBase):
def __init__(self, config: MemoryConfig = MemoryConfig()):
@@ -417,6 +471,10 @@ class Memory(MemoryBase):
capture_event("mem0.init", self, {"sync_type": "sync"})
@property
def project(self):
return _OSSProject()
@property
def entity_store(self):
"""Lazily initialize entity store on first use."""
@@ -600,6 +658,7 @@ class Memory(MemoryBase):
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
timestamp: Optional[Any] = None,
infer: bool = True,
memory_type: Optional[str] = None,
prompt: Optional[str] = None,
@@ -617,6 +676,7 @@ class Memory(MemoryBase):
agent_id (str, optional): ID of the agent creating the memory. Defaults to None.
run_id (str, optional): ID of the run creating the memory. Defaults to None.
metadata (dict, optional): Metadata to store with the memory. Defaults to None.
timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS.
infer (bool, optional): If True (default), an LLM is used to extract key facts from
'messages' and decide whether to add, update, or delete related memories.
If False, 'messages' are added as raw memories directly.
@@ -639,7 +699,10 @@ class Memory(MemoryBase):
LLMError: If LLM operations fail.
DatabaseError: If database operations fail.
"""
if timestamp is not None:
raise ValueError(get_temporal_feature_error_message("sync", "add", "timestamp"))
temporal_usage_notice = detect_temporal_usage_from_metadata(metadata)
processed_metadata, effective_filters = _build_filters_and_metadata(
user_id=user_id,
agent_id=agent_id,
@@ -671,6 +734,13 @@ class Memory(MemoryBase):
if agent_id is not None and memory_type == MemoryType.PROCEDURAL.value:
results = self._create_procedural_memory(messages, metadata=processed_metadata, prompt=prompt)
scale_threshold_notice = detect_scale_threshold_from_add_result(self, results)
if temporal_usage_notice:
display_temporal_usage_notice(self, "sync", "add", *temporal_usage_notice)
elif scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "add", *scale_threshold_notice)
else:
display_first_run_notice(self, "sync", "add")
return results
if self.config.llm.config.get("enable_vision"):
@@ -679,6 +749,13 @@ class Memory(MemoryBase):
messages = parse_vision_messages(messages)
vector_store_result = self._add_to_vector_store(messages, processed_metadata, effective_filters, infer, prompt=prompt)
scale_threshold_notice = detect_scale_threshold_from_add_result(self, vector_store_result)
if temporal_usage_notice:
display_temporal_usage_notice(self, "sync", "add", *temporal_usage_notice)
elif scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "add", *scale_threshold_notice)
else:
display_first_run_notice(self, "sync", "add")
return {"results": vector_store_result}
def _add_to_vector_store(self, messages, metadata, filters, infer, prompt=None):
@@ -1005,6 +1082,7 @@ class Memory(MemoryBase):
capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "sync"})
memory = self.vector_store.get(vector_id=memory_id)
if not memory:
display_first_run_notice(self, "sync", "get")
return None
promoted_payload_keys = [
@@ -1033,6 +1111,7 @@ class Memory(MemoryBase):
if additional_metadata:
result_item["metadata"] = additional_metadata
display_first_run_notice(self, "sync", "get")
return result_item
def get_all(
@@ -1088,6 +1167,7 @@ class Memory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
keys, encoded_ids = process_telemetry_filters(effective_filters)
capture_event(
@@ -1096,6 +1176,10 @@ class Memory(MemoryBase):
all_memories_result = self._get_all_from_vector_store(effective_filters, limit)
if scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "get_all", *scale_threshold_notice)
else:
display_first_run_notice(self, "sync", "get_all")
return {"results": all_memories_result}
def _get_all_from_vector_store(self, filters, limit):
@@ -1154,6 +1238,7 @@ class Memory(MemoryBase):
threshold: float = 0.1,
rerank: bool = False,
explain: bool = False,
reference_date: Optional[Any] = None,
**kwargs,
):
"""
@@ -1185,6 +1270,7 @@ class Memory(MemoryBase):
threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
rerank (bool, optional): Whether to rerank results. Defaults to False.
explain (bool, optional): Whether to include score_details for each result. Defaults to False.
reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS.
Returns:
dict: A dictionary containing the search results under a "results" key.
@@ -1194,12 +1280,16 @@ class Memory(MemoryBase):
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if threshold/top_k values are invalid.
"""
if reference_date is not None:
raise ValueError(get_temporal_feature_error_message("sync", "search", "reference_date"))
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "search")
# Validate search parameters (before applying defaults)
_validate_search_params(threshold=threshold, top_k=top_k)
query = _validate_and_trim_search_query(query)
temporal_usage_notice = detect_temporal_usage_from_search(query, filters)
# Validate and trim entity IDs in filters
effective_filters = filters.copy() if filters else {}
@@ -1222,6 +1312,7 @@ class Memory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
# Apply enhanced metadata filtering if advanced operators are detected
if self._has_advanced_operators(effective_filters):
@@ -1250,7 +1341,9 @@ class Memory(MemoryBase):
},
)
search_start = time.perf_counter()
original_memories = self._search_vector_store(query, effective_filters, limit, threshold, explain=explain)
search_elapsed_seconds = time.perf_counter() - search_start
# Apply reranking if enabled and reranker is available
if rerank and self.reranker and original_memories:
@@ -1260,6 +1353,21 @@ class Memory(MemoryBase):
except Exception as e:
logger.warning(f"Reranking failed, using original results: {e}")
if temporal_usage_notice:
display_temporal_usage_notice(self, "sync", "search", *temporal_usage_notice)
elif scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "search", *scale_threshold_notice)
elif search_elapsed_seconds > PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS:
display_performance_slow_query_notice(
self,
"sync",
"search",
search_elapsed_seconds,
top_k,
len(original_memories),
)
else:
display_first_run_notice(self, "sync", "search")
return {"results": original_memories}
def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]:
@@ -1569,6 +1677,7 @@ class Memory(MemoryBase):
existing_embeddings = {data: self.embedding_model.embed(data, "update")}
self._update_memory(memory_id, data, existing_embeddings, metadata)
display_first_run_notice(self, "sync", "update")
return {"message": "Memory updated successfully!"}
def delete(self, memory_id):
@@ -1585,6 +1694,11 @@ class Memory(MemoryBase):
raise ValueError(f"Memory with id {memory_id} not found")
self._delete_memory(memory_id, existing_memory)
decay_usage_notice = detect_decay_usage_from_delete()
if decay_usage_notice:
display_decay_usage_notice(self, "sync", "delete", *decay_usage_notice)
else:
display_first_run_notice(self, "sync", "delete")
return {"message": "Memory deleted successfully!"}
def delete_all(self, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None):
@@ -1618,6 +1732,11 @@ class Memory(MemoryBase):
logger.info(f"Deleted {len(memories)} memories")
decay_usage_notice = detect_decay_usage_from_delete_all(len(memories))
if decay_usage_notice:
display_decay_usage_notice(self, "sync", "delete_all", *decay_usage_notice)
else:
display_first_run_notice(self, "sync", "delete_all")
return {"message": "Memories deleted successfully!"}
def history(self, memory_id):
@@ -1631,7 +1750,9 @@ class Memory(MemoryBase):
list: List of changes for the memory.
"""
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "sync"})
return self.db.get_history(memory_id)
history = self.db.get_history(memory_id)
display_first_run_notice(self, "sync", "history")
return history
def _create_memory(self, data, existing_embeddings, metadata=None):
logger.debug(f"Creating memory with {data=}")
@@ -1831,6 +1952,7 @@ class Memory(MemoryBase):
self._entity_store = None
capture_event("mem0.reset", self, {"sync_type": "sync"})
display_first_run_notice(self, "sync", "reset")
def close(self):
"""Release resources held by this Memory instance (SQLite connections, etc.)."""
@@ -1889,6 +2011,10 @@ class AsyncMemory(MemoryBase):
capture_event("mem0.init", self, {"sync_type": "async"})
@property
def project(self):
return _AsyncOSSProject()
@property
def entity_store(self):
"""Lazily initialize entity store on first use."""
@@ -2058,6 +2184,7 @@ class AsyncMemory(MemoryBase):
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
timestamp: Optional[Any] = None,
infer: bool = True,
memory_type: Optional[str] = None,
prompt: Optional[str] = None,
@@ -2072,6 +2199,7 @@ class AsyncMemory(MemoryBase):
agent_id (str, optional): ID of the agent creating the memory. Defaults to None.
run_id (str, optional): ID of the run creating the memory. Defaults to None.
metadata (dict, optional): Metadata to store with the memory. Defaults to None.
timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS.
infer (bool, optional): Whether to infer the memories. Defaults to True.
memory_type (str, optional): Type of memory to create. Defaults to None.
Pass "procedural_memory" to create procedural memories.
@@ -2080,6 +2208,10 @@ class AsyncMemory(MemoryBase):
Returns:
dict: A dictionary containing the result of the memory addition operation.
"""
if timestamp is not None:
raise ValueError(await get_temporal_feature_error_message_async("async", "add", "timestamp"))
temporal_usage_notice = detect_temporal_usage_from_metadata(metadata)
processed_metadata, effective_filters = _build_filters_and_metadata(
user_id=user_id, agent_id=agent_id, run_id=run_id, input_metadata=metadata
)
@@ -2107,6 +2239,13 @@ class AsyncMemory(MemoryBase):
results = await self._create_procedural_memory(
messages, metadata=processed_metadata, prompt=prompt, llm=llm
)
scale_threshold_notice = await asyncio.to_thread(detect_scale_threshold_from_add_result, self, results)
if temporal_usage_notice:
await display_temporal_usage_notice_async(self, "async", "add", *temporal_usage_notice)
elif scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "add", *scale_threshold_notice)
else:
await display_first_run_notice_async(self, "async", "add")
return results
if self.config.llm.config.get("enable_vision"):
@@ -2115,6 +2254,13 @@ class AsyncMemory(MemoryBase):
messages = parse_vision_messages(messages)
vector_store_result = await self._add_to_vector_store(messages, processed_metadata, effective_filters, infer, prompt=prompt)
scale_threshold_notice = await asyncio.to_thread(detect_scale_threshold_from_add_result, self, vector_store_result)
if temporal_usage_notice:
await display_temporal_usage_notice_async(self, "async", "add", *temporal_usage_notice)
elif scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "add", *scale_threshold_notice)
else:
await display_first_run_notice_async(self, "async", "add")
return {"results": vector_store_result}
async def _add_to_vector_store(
@@ -2448,6 +2594,7 @@ class AsyncMemory(MemoryBase):
capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "async"})
memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
if not memory:
await display_first_run_notice_async(self, "async", "get")
return None
promoted_payload_keys = [
@@ -2476,6 +2623,7 @@ class AsyncMemory(MemoryBase):
if additional_metadata:
result_item["metadata"] = additional_metadata
await display_first_run_notice_async(self, "async", "get")
return result_item
async def get_all(
@@ -2531,6 +2679,7 @@ class AsyncMemory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
keys, encoded_ids = process_telemetry_filters(effective_filters)
capture_event(
@@ -2539,6 +2688,10 @@ class AsyncMemory(MemoryBase):
all_memories_result = await self._get_all_from_vector_store(effective_filters, limit)
if scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "get_all", *scale_threshold_notice)
else:
await display_first_run_notice_async(self, "async", "get_all")
return {"results": all_memories_result}
async def _get_all_from_vector_store(self, filters, limit):
@@ -2597,6 +2750,7 @@ class AsyncMemory(MemoryBase):
threshold: float = 0.1,
rerank: bool = False,
explain: bool = False,
reference_date: Optional[Any] = None,
**kwargs,
):
"""
@@ -2628,6 +2782,7 @@ class AsyncMemory(MemoryBase):
threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
rerank (bool, optional): Whether to rerank results. Defaults to False.
explain (bool, optional): Whether to include score_details for each result. Defaults to False.
reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS.
Returns:
dict: A dictionary containing the search results under a "results" key.
@@ -2637,12 +2792,18 @@ class AsyncMemory(MemoryBase):
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if threshold/top_k values are invalid.
"""
if reference_date is not None:
raise ValueError(
await get_temporal_feature_error_message_async("async", "search", "reference_date")
)
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "search")
# Validate search parameters (before applying defaults)
_validate_search_params(threshold=threshold, top_k=top_k)
query = _validate_and_trim_search_query(query)
temporal_usage_notice = detect_temporal_usage_from_search(query, filters)
# Validate and trim entity IDs in filters
effective_filters = filters.copy() if filters else {}
@@ -2667,6 +2828,7 @@ class AsyncMemory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
# Apply enhanced metadata filtering if advanced operators are detected
if self._has_advanced_operators(effective_filters):
@@ -2695,7 +2857,9 @@ class AsyncMemory(MemoryBase):
},
)
search_start = time.perf_counter()
original_memories = await self._search_vector_store(query, effective_filters, limit, threshold, explain=explain)
search_elapsed_seconds = time.perf_counter() - search_start
# Apply reranking if enabled and reranker is available
if rerank and self.reranker and original_memories:
@@ -2708,6 +2872,21 @@ class AsyncMemory(MemoryBase):
except Exception as e:
logger.warning(f"Reranking failed, using original results: {e}")
if temporal_usage_notice:
await display_temporal_usage_notice_async(self, "async", "search", *temporal_usage_notice)
elif scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "search", *scale_threshold_notice)
elif search_elapsed_seconds > PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS:
await display_performance_slow_query_notice_async(
self,
"async",
"search",
search_elapsed_seconds,
top_k,
len(original_memories),
)
else:
await display_first_run_notice_async(self, "async", "search")
return {"results": original_memories}
def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]:
@@ -3008,6 +3187,7 @@ class AsyncMemory(MemoryBase):
existing_embeddings = {data: embeddings}
await self._update_memory(memory_id, data, existing_embeddings, metadata)
await display_first_run_notice_async(self, "async", "update")
return {"message": "Memory updated successfully!"}
async def delete(self, memory_id):
@@ -3024,6 +3204,11 @@ class AsyncMemory(MemoryBase):
raise ValueError(f"Memory with id {memory_id} not found")
await self._delete_memory(memory_id, existing_memory)
decay_usage_notice = detect_decay_usage_from_delete()
if decay_usage_notice:
await display_decay_usage_notice_async(self, "async", "delete", *decay_usage_notice)
else:
await display_first_run_notice_async(self, "async", "delete")
return {"message": "Memory deleted successfully!"}
async def delete_all(self, user_id=None, agent_id=None, run_id=None):
@@ -3060,6 +3245,11 @@ class AsyncMemory(MemoryBase):
logger.info(f"Deleted {len(memories[0])} memories")
decay_usage_notice = detect_decay_usage_from_delete_all(len(memories[0]))
if decay_usage_notice:
await display_decay_usage_notice_async(self, "async", "delete_all", *decay_usage_notice)
else:
await display_first_run_notice_async(self, "async", "delete_all")
return {"message": "Memories deleted successfully!"}
async def history(self, memory_id):
@@ -3073,7 +3263,9 @@ class AsyncMemory(MemoryBase):
list: List of changes for the memory.
"""
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "async"})
return await asyncio.to_thread(self.db.get_history, memory_id)
history = await asyncio.to_thread(self.db.get_history, memory_id)
await display_first_run_notice_async(self, "async", "history")
return history
async def _create_memory(self, data, existing_embeddings, metadata=None):
logger.debug(f"Creating memory with {data=}")
@@ -3291,6 +3483,7 @@ class AsyncMemory(MemoryBase):
)
capture_event("mem0.reset", self, {"sync_type": "async"})
await display_first_run_notice_async(self, "async", "reset")
def close(self):
"""Release resources held by this AsyncMemory instance."""
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,6 +1,7 @@
import json
import logging
import os
import tempfile
import uuid
from hashlib import sha256
@@ -34,10 +35,21 @@ def _load_config():
def _write_config(config):
"""Best-effort write of ~/.mem0/config.json. Never raises."""
path = _config_path()
temp_path = None
try:
with open(path, "w") as f:
os.makedirs(os.path.dirname(path), exist_ok=True)
with tempfile.NamedTemporaryFile("w", dir=os.path.dirname(path), delete=False) as f:
temp_path = f.name
json.dump(config, f, indent=4)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, path)
except Exception as e:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
_logger.debug("Failed to write mem0 config %s: %s", path, e)
+15 -9
View File
@@ -14,6 +14,7 @@ from mem0.memory.setup import get_or_create_user_id
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
PROJECT_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
HOST = "https://us.i.posthog.com"
FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS = 0.5
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
@@ -49,7 +50,9 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S
# Events that bypass sampling and always fire. Keep this set in sync with the
# event names passed to capture_event() in mem0/memory/main.py.
# $identify is included so PostHog person-merging is never lost to sampling.
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"})
_LIFECYCLE_EVENTS = frozenset(
{"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "mem0.notice_displayed", "$identify"}
)
def _sampling_before_send(msg):
@@ -77,15 +80,15 @@ class AnonymousTelemetry:
self.user_id = None
return
try:
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST, before_send=before_send)
except TypeError:
# posthog <4.5.0 does not accept before_send; fall back without sampling.
_logger.debug("posthog.Posthog does not accept before_send; upgrade to >=4.5.0 for sampling")
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.posthog = Posthog(
project_api_key=PROJECT_API_KEY,
host=HOST,
before_send=before_send,
feature_flags_request_timeout_seconds=FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS,
)
self.user_id = get_or_create_user_id(vector_store)
def capture_event(self, event_name, properties=None, user_email=None):
def capture_event(self, event_name, properties=None, user_email=None, flags=None):
if self.posthog is None:
return
@@ -109,7 +112,10 @@ class AnonymousTelemetry:
**properties,
}
try:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
capture_kwargs = {"distinct_id": distinct_id, "properties": properties}
if flags is not None:
capture_kwargs["flags"] = flags
self.posthog.capture(event_name, **capture_kwargs)
except Exception as e:
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e)