From 397f3414ee7c3bf5eadec20ef4a3dca0f1c1c567 Mon Sep 17 00:00:00 2001 From: Chaithanya Kumar Date: Wed, 6 May 2026 15:33:32 +0530 Subject: [PATCH] feat(sdk): expose decay on project.update (Python + TypeScript) (#5062) Co-authored-by: Claude Opus 4.7 (1M context) --- mem0-ts/src/client/mem0.types.ts | 7 +++ mem0/client/project.py | 18 +++++- tests/test_project.py | 97 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/test_project.py diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index 1dacce376..afd5b663d 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -50,6 +50,13 @@ export interface PromptUpdatePayload { memoryDepth?: string | null; usecaseSetting?: string | number; multilingual?: boolean; + /** + * Toggle Memory Decay for this project. When `true`, search-time ranking + * boosts recently-used memories and gently dampens stale ones; when `false`, + * ranking is restored to the pre-decay behaviour. Off by default. + * See https://docs.mem0.ai/platform/features/memory-decay + */ + decay?: boolean; [key: string]: any; } diff --git a/mem0/client/project.py b/mem0/client/project.py index fe466b7ef..4255bec18 100644 --- a/mem0/client/project.py +++ b/mem0/client/project.py @@ -398,6 +398,7 @@ class Project(BaseProject): custom_categories: Optional[List[str]] = None, retrieval_criteria: Optional[List[Dict[str, Any]]] = None, multilingual: Optional[bool] = None, + decay: Optional[bool] = None, ) -> Dict[str, Any]: """ Update project settings. @@ -407,6 +408,9 @@ class Project(BaseProject): custom_categories: New categories for the project retrieval_criteria: New retrieval criteria for the project multilingual: Whether to use the input language for memory storage and retrieval + decay: Toggle Memory Decay for this project. When True, search-time + ranking boosts recently-used memories and gently dampens stale ones; when + False, ranking is restored to the pre-decay behaviour. Off by default. Returns: Dictionary containing the API response. @@ -423,11 +427,12 @@ class Project(BaseProject): and custom_categories is None and retrieval_criteria is None and multilingual is None + and decay is None ): raise ValueError( "At least one parameter must be provided for update: " "custom_instructions, custom_categories, retrieval_criteria, " - "multilingual" + "multilingual, decay" ) payload = self._prepare_params( @@ -436,6 +441,7 @@ class Project(BaseProject): "custom_categories": custom_categories, "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, + "decay": decay, } ) response = self._client.patch( @@ -451,6 +457,7 @@ class Project(BaseProject): "custom_categories": custom_categories, "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, + "decay": decay, "sync_type": "sync", }, ) @@ -715,6 +722,7 @@ class AsyncProject(BaseProject): custom_categories: Optional[List[str]] = None, retrieval_criteria: Optional[List[Dict[str, Any]]] = None, multilingual: Optional[bool] = None, + decay: Optional[bool] = None, ) -> Dict[str, Any]: """ Update project settings. @@ -724,6 +732,9 @@ class AsyncProject(BaseProject): custom_categories: New categories for the project retrieval_criteria: New retrieval criteria for the project multilingual: Whether to use the input language for memory storage and retrieval + decay: Toggle Memory Decay for this project. When True, search-time + ranking boosts recently-used memories and gently dampens stale ones; when + False, ranking is restored to the pre-decay behaviour. Off by default. Returns: Dictionary containing the API response. @@ -740,11 +751,12 @@ class AsyncProject(BaseProject): and custom_categories is None and retrieval_criteria is None and multilingual is None + and decay is None ): raise ValueError( "At least one parameter must be provided for update: " "custom_instructions, custom_categories, retrieval_criteria, " - "multilingual" + "multilingual, decay" ) payload = self._prepare_params( @@ -753,6 +765,7 @@ class AsyncProject(BaseProject): "custom_categories": custom_categories, "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, + "decay": decay, } ) response = await self._client.patch( @@ -768,6 +781,7 @@ class AsyncProject(BaseProject): "custom_categories": custom_categories, "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, + "decay": decay, "sync_type": "async", }, ) diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 000000000..9f2d9b7a2 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,97 @@ +"""Tests for ``mem0.client.project.Project.update`` — focused on the +parameter-passthrough surface. + +Verifies the kwarg → JSON payload mapping for every supported field +(``custom_instructions``, ``custom_categories``, ``retrieval_criteria``, +``multilingual``, ``decay``), the ValueError when no field is +provided, and the URL/method shape. The HTTP layer is mocked. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def project(): + """Build a ``Project`` with a mocked httpx client. + + Bypasses ``MemoryClient`` so the test stays focused on + ``Project.update`` payload construction. + """ + http = MagicMock() + http.patch.return_value = MagicMock( + json=lambda: {"message": "Updated"}, + raise_for_status=lambda: None, + ) + with patch("mem0.client.project.capture_client_event"): + from mem0.client.project import Project + + proj = Project(client=http, org_id="org1", project_id="proj1") + yield proj, http + + +def _patch_payload(http): + """Return the JSON body sent on the last PATCH, stripped of the SDK's + standard auth params (``org_id``, ``project_id``) that ``_prepare_params`` + injects on every request.""" + assert http.patch.called, "expected a PATCH call" + _, kwargs = http.patch.call_args + body = dict(kwargs.get("json", {})) + body.pop("org_id", None) + body.pop("project_id", None) + return body + + +class TestProjectUpdateDecay: + def test_decay_true_sent_in_payload(self, project): + proj, http = project + proj.update(decay=True) + assert _patch_payload(http) == {"decay": True} + + def test_decay_false_sent_in_payload(self, project): + """Explicit ``False`` must round-trip — not be filtered as falsy.""" + proj, http = project + proj.update(decay=False) + assert _patch_payload(http) == {"decay": False} + + def test_decay_combined_with_multilingual(self, project): + proj, http = project + proj.update(multilingual=True, decay=True) + assert _patch_payload(http) == { + "multilingual": True, + "decay": True, + } + + def test_decay_omitted_when_none(self, project): + """When the caller doesn't pass ``decay``, it must not appear in + the payload — backwards compatible with pre-decay callers.""" + proj, http = project + proj.update(multilingual=False) + payload = _patch_payload(http) + assert payload == {"multilingual": False} + assert "decay" not in payload + + def test_no_args_raises_with_decay_in_message(self, project): + proj, _ = project + with pytest.raises(ValueError, match=r"decay"): + proj.update() + + def test_url_targets_project_endpoint(self, project): + proj, http = project + proj.update(decay=True) + args, _ = http.patch.call_args + assert args[0] == "/api/v1/orgs/organizations/org1/projects/proj1/" + + +class TestProjectUpdateBackwardsCompat: + def test_multilingual_only_still_works(self, project): + """Pre-decay callers (multilingual only) keep working unchanged.""" + proj, http = project + proj.update(multilingual=True) + assert _patch_payload(http) == {"multilingual": True} + + def test_custom_instructions_only_still_works(self, project): + proj, http = project + proj.update(custom_instructions="be concise") + assert _patch_payload(http) == {"custom_instructions": "be concise"}