From bc5526f13d2fc7fadf630652381cbd9e5efa99e5 Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Tue, 15 Sep 2026 00:19:00 +0530 Subject: [PATCH] feat(integrations): declare which surface each client is, and its version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing on the wire said which Mem0 surface made a call. Both SDKs sent only an auth header, so the platform saw python-httpx and axios and attributed every plugin, wrapper and direct API user to one undifferentiated bucket. Version was unknowable, which is what gates every deprecation decision. Three headers, and the rules on them are the point: - X-Mem0-Source and X-Application are SET-ONCE. Whichever layer is outermost sets them; nothing below overwrites. A plugin wrapping the SDK keeps its own identity instead of being renamed by the transport underneath it. - X-Mem0-Client is APPEND-ONLY. A plugin calling the Python SDK produces `mem0-plugin/0.3.1, mem0-python/2.0.19`, so neither layer can erase the other. Deliberately not User-Agent: proxies rewrite it, and we have already met a WAF that 403s on it. The plugin core also hoists `source` out of metadata to the top level, which is where the backend actually reads it. It sat in metadata, which get_event_source never consults, so all six plugins arrived indistinguishable from a raw SDK call no matter what they set. The harness tag stays in metadata as hook provenance. pi-agent had PI_AGENT as a PostHog property only and never sent it on the wire. vercel-ai-sdk sent nothing at all from its raw fetch calls. Values must exist in the platform's EventSource enum or they bucket to OTHERS, so integrations/AGENTS.md now states the contract and the "adding an integration" checklist requires landing the platform value in the same week. Pairs with mem0ai/platform#3602, which recognizes these values. TypeScript changes are not typechecked locally — deps are not installed for those packages. CI covers them. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb --- cli/node/src/backend/platform.ts | 3 +- cli/python/src/mem0_cli/backend/platform.py | 3 +- integrations/AGENTS.md | 24 +++++++ .../agent-plugin-core/python/memory_core.py | 38 ++++++++++- .../antigravity-plugin/core/memory_core.py | 38 ++++++++++- .../claude-code-plugin/core/memory_core.py | 38 ++++++++++- integrations/codex-plugin/core/memory_core.py | 38 ++++++++++- .../cursor-plugin/core/memory_core.py | 38 ++++++++++- integrations/kimi-plugin/core/memory_core.py | 38 ++++++++++- .../mem0-agent-plugin/core/memory_core.py | 38 ++++++++++- integrations/pi-agent-plugin/src/commands.ts | 9 ++- integrations/vercel-ai-sdk/src/mem0-utils.ts | 14 +++- mem0-ts/src/client/mem0.ts | 33 ++++++++++ mem0/client/main.py | 66 ++++++++++++++----- 14 files changed, 373 insertions(+), 45 deletions(-) diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 705a3d470..65a4b6228 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -31,7 +31,8 @@ export class PlatformBackend implements Backend { this.headers = { Authorization: `Token ${config.apiKey}`, "Content-Type": "application/json", - "X-Mem0-Source": "cli", + "X-Mem0-Source": "CLI", + "X-Mem0-Client": `mem0-cli-node/${CLI_VERSION}`, "X-Mem0-Client-Language": "node", "X-Mem0-Client-Version": CLI_VERSION, }; diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 1d153da9c..a9ce60df1 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -27,7 +27,8 @@ class PlatformBackend(Backend): headers={ "Authorization": f"Token {config.api_key}", "Content-Type": "application/json", - "X-Mem0-Source": "cli", + "X-Mem0-Source": "CLI", + "X-Mem0-Client": f"mem0-cli-python/{__version__}", "X-Mem0-Client-Language": "python", "X-Mem0-Client-Version": __version__, }, diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md index c129dd48b..75b60009e 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -49,6 +49,29 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc - - **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret). - **`mem0-strands/`** is a native Strands `MemoryStore` (Python, published to PyPI as `mem0-strands`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `mem0-strands/python/`. +## Surface attribution + +Every integration tells the Mem0 platform which surface it is. Three headers, +and the rules on them are what keep one layer from erasing another: + +| Header | Carries | Rule | +|--------|---------|------| +| `X-Mem0-Source` | one canonical source value | **set-once** — write only if absent | +| `X-Application` | the host app it runs inside | **set-once** — write only if absent | +| `X-Mem0-Client` | `name/version`, outermost first | **append-only** — add yourself, never replace | + +Set-once means `setdefault`, never assignment. An integration that wraps the +SDK is the outermost layer and sets the source; the SDK underneath must defer to +it. Assignment is exactly how every agent plugin came to be indistinguishable +from every other one at the platform. + +Append-only means a plugin calling the Python SDK produces +`mem0-plugin/0.3.1, mem0-python/2.0.19`, so neither layer can erase the other. + +The backend recognizes a fixed list of source values and buckets everything else +into `OTHERS`. A new value has to land in the platform's `EventSource` enum, so +do not invent one without that change going in too. + ## Adding an integration 1. For a native coding-agent host, add `integrations/-plugin/` with `plugin-build.json`, its manifest, and a thin adapter, then generate its shared runtime. Portable clients use the single `mem0-agent-plugin/` package. Independent TypeScript integrations stay self-contained and import shared lifecycle behavior from `agent-plugin-core/typescript/`. @@ -59,3 +82,4 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc - 5. If it is a Claude Code or editor marketplace plugin, register the generated native bundle path in the applicable marketplace files. Preserve the existing public plugin name. 6. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`. 7. Add rows to the table above and to the CI/CD tables in [`../.github/AGENTS.md`](../.github/AGENTS.md). +8. Send the three headers in [Surface attribution](#surface-attribution), and land the matching `EventSource` value on the platform in the same week. Until it exists, your traffic reports as `OTHERS`. diff --git a/integrations/agent-plugin-core/python/memory_core.py b/integrations/agent-plugin-core/python/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/agent-plugin-core/python/memory_core.py +++ b/integrations/agent-plugin-core/python/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/antigravity-plugin/core/memory_core.py b/integrations/antigravity-plugin/core/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/antigravity-plugin/core/memory_core.py +++ b/integrations/antigravity-plugin/core/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/claude-code-plugin/core/memory_core.py b/integrations/claude-code-plugin/core/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/claude-code-plugin/core/memory_core.py +++ b/integrations/claude-code-plugin/core/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/codex-plugin/core/memory_core.py b/integrations/codex-plugin/core/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/codex-plugin/core/memory_core.py +++ b/integrations/codex-plugin/core/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/cursor-plugin/core/memory_core.py b/integrations/cursor-plugin/core/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/cursor-plugin/core/memory_core.py +++ b/integrations/cursor-plugin/core/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/kimi-plugin/core/memory_core.py b/integrations/kimi-plugin/core/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/kimi-plugin/core/memory_core.py +++ b/integrations/kimi-plugin/core/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/mem0-agent-plugin/core/memory_core.py b/integrations/mem0-agent-plugin/core/memory_core.py index 77d7a2b80..31a08ec37 100644 --- a/integrations/mem0-agent-plugin/core/memory_core.py +++ b/integrations/mem0-agent-plugin/core/memory_core.py @@ -1800,6 +1800,34 @@ def extraction_message_batches( return batches +# Platform surface attribution. Read from the generated per-host module so a new +# entrypoint is correct without remembering to configure anything. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE +except ImportError: + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + + +def platform_headers(key: str) -> dict[str, str]: + """Auth plus the three surface-identity headers. + + X-Mem0-Source and X-Application are set-once by contract: this is the + outermost layer, so it sets them, and nothing below may overwrite them. + X-Mem0-Client is append-only — anything downstream adds itself to the tail. + """ + headers = { + "Authorization": f"Token {key}", + "Content-Type": "application/json", + "X-Mem0-Source": _PLATFORM_SOURCE, + "X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}", + } + if _PLATFORM_APPLICATION: + headers["X-Application"] = _PLATFORM_APPLICATION + return headers + + def _request_json( url: str, key: str, payload: dict[str, Any], timeout: float ) -> tuple[dict[str, Any] | list[Any], int, int]: @@ -1807,7 +1835,7 @@ def _request_json( request = urllib.request.Request( url, data=raw, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1834,7 +1862,7 @@ def _get_json( ) -> tuple[dict[str, Any] | list[Any], int]: request = urllib.request.Request( url, - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="GET", ) with urllib.request.urlopen(request, timeout=timeout) as response: @@ -1980,6 +2008,10 @@ def flush_session( "user_id": write_user, "app_id": repo.app_id, "run_id": session_id, + # Top level, not metadata: the backend reads `source` from the body, + # query string or X-Mem0-Source header, never from metadata. The + # harness tag stays in metadata as hook provenance. + "source": _PLATFORM_SOURCE, "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, @@ -2523,7 +2555,7 @@ def _collect_memory_ids( def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: request = urllib.request.Request( f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", - headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + headers=platform_headers(key), method="DELETE", ) try: diff --git a/integrations/pi-agent-plugin/src/commands.ts b/integrations/pi-agent-plugin/src/commands.ts index 9379bed9a..e90f8833b 100644 --- a/integrations/pi-agent-plugin/src/commands.ts +++ b/integrations/pi-agent-plugin/src/commands.ts @@ -22,6 +22,10 @@ export function registerCommands( const pluralize = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`; +// Surface attribution on the wire. This was previously only a PostHog property, +// so the platform saw these calls as generic SDK traffic. +const PLATFORM_SOURCE = "PI_AGENT"; + const searchMemories = async (query: string, scope: Scope) => { const filters = resolveSearchFilters(scope, getScopeCtx()); const result = await mem0.search(query, { @@ -29,7 +33,8 @@ export function registerCommands( threshold: config.searchThreshold, topK: SEARCH_TOP_K, rerank: true, - }); + source: PLATFORM_SOURCE, + } as never); return result.results ?? []; }; @@ -45,7 +50,7 @@ export function registerCommands( const addParams = resolveAddParams(config.defaultScope, getScopeCtx()); const result = await mem0.add( [{ role: "user", content: text }], - { ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false }, + { ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false, source: PLATFORM_SOURCE } as never, ); captureCommandEvent("mem0-remember", {}, telemetryCtx); diff --git a/integrations/vercel-ai-sdk/src/mem0-utils.ts b/integrations/vercel-ai-sdk/src/mem0-utils.ts index 55d485cbc..8fa20440c 100644 --- a/integrations/vercel-ai-sdk/src/mem0-utils.ts +++ b/integrations/vercel-ai-sdk/src/mem0-utils.ts @@ -1,3 +1,5 @@ +const PROVIDER_VERSION = "3.0.2"; + import { LanguageModelV3Prompt } from '@ai-sdk/provider'; import { Mem0ConfigSettings } from './mem0-types'; import { loadApiKey } from '@ai-sdk/provider-utils'; @@ -277,7 +279,11 @@ const searchInternalMemories = async (query: string, config?: Mem0ConfigSettings method: 'POST', headers: { Authorization: `Token ${apiKey}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + // Surface attribution. Set-once by contract: this wrapper is the + // outermost layer on these raw fetch calls. + 'X-Mem0-Source': 'VERCEL_AI_SDK', + 'X-Mem0-Client': `mem0-vercel-ai-provider/${PROVIDER_VERSION}` }, body: JSON.stringify(body), }; @@ -331,7 +337,11 @@ const updateMemories = async (messages: Array, config?: Mem0ConfigSetti method: 'POST', headers: { Authorization: `Token ${apiKey}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + // Surface attribution. Set-once by contract: this wrapper is the + // outermost layer on these raw fetch calls. + 'X-Mem0-Source': 'VERCEL_AI_SDK', + 'X-Mem0-Client': `mem0-vercel-ai-provider/${PROVIDER_VERSION}` }, body: JSON.stringify(body), }; diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index ff259f2fe..6327acffa 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -95,6 +95,38 @@ interface ClientIdentity { const IDENTITY_CACHE_MAX_DEFAULT = 50; const identityByCredentials = new Map>(); +const SDK_VERSION = "3.1.8"; + +/** + * Surface-identity headers. + * + * X-Mem0-Source and X-Application are SET-ONCE by contract: whichever layer is + * outermost sets them and nothing below overwrites, so a plugin wrapping this + * SDK keeps its own identity. X-Mem0-Client is APPEND-ONLY - every layer adds + * itself, so the platform sees the whole stack and not just the last speaker. + */ +function surfaceHeaders(): Record { + const env: Record = + typeof process !== "undefined" && process.env ? process.env : {}; + const existing = (env.MEM0_CLIENT_STACK ?? "").trim(); + const entries = existing + ? existing + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + : []; + entries.push(`mem0-js/${SDK_VERSION}`); + + const headers: Record = { + "X-Mem0-Client": entries.slice(0, 4).join(", ").slice(0, 200), + }; + const source = (env.MEM0_SOURCE ?? "").trim(); + if (source) headers["X-Mem0-Source"] = source; + const application = (env.MEM0_APPLICATION ?? "").trim(); + if (application) headers["X-Application"] = application; + return headers; +} + export default class MemoryClient { apiKey: string; host: string; @@ -129,6 +161,7 @@ export default class MemoryClient { this.headers = { Authorization: `Token ${this.apiKey}`, "Content-Type": "application/json", + ...surfaceHeaders(), }; this.client = axios.create({ diff --git a/mem0/client/main.py b/mem0/client/main.py index a52986dcd..d118bc340 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -79,6 +79,50 @@ def _maybe_alias_anon_to_email(user_email): logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e) +def _sdk_version() -> str: + """Resolved here rather than imported from the package root, which would cycle.""" + try: + import importlib.metadata + + return importlib.metadata.version("mem0ai") + except Exception: + return "unknown" + + +def _client_headers(api_key: str, user_id: str) -> Dict[str, str]: + """Auth plus surface-identity headers. + + X-Mem0-Source and X-Application are SET-ONCE by contract: whichever layer is + outermost sets them, and nothing below overwrites. A plugin or harness that + wraps this SDK therefore keeps its own identity — it declares via MEM0_SOURCE + / MEM0_APPLICATION and the SDK defers. + + X-Mem0-Client is APPEND-ONLY: every layer adds itself, so the platform sees + the whole stack rather than only whoever spoke last. + """ + headers = { + "Authorization": f"Token {api_key}", + "Mem0-User-ID": user_id, + "X-Mem0-Client": _client_stack(), + } + source = os.getenv("MEM0_SOURCE", "").strip() + if source: + headers["X-Mem0-Source"] = source + application = os.getenv("MEM0_APPLICATION", "").strip() + if application: + headers["X-Application"] = application + return headers + + +def _client_stack() -> str: + """This SDK appended to any stack an outer layer already declared.""" + existing = os.getenv("MEM0_CLIENT_STACK", "").strip() + mine = f"mem0-python/{_sdk_version()}" + entries = [part.strip() for part in existing.split(",") if part.strip()] if existing else [] + entries.append(mine) + return ", ".join(entries[:4])[:200] + + class MemoryClient: """Client for interacting with the Mem0 API. @@ -129,19 +173,11 @@ class MemoryClient: self.client = client # Ensure the client has the correct base_url and headers self.client.base_url = httpx.URL(self.host) - self.client.headers.update( - { - "Authorization": f"Token {self.api_key}", - "Mem0-User-ID": self.user_id, - } - ) + self.client.headers.update(_client_headers(self.api_key, self.user_id)) else: self.client = httpx.Client( base_url=self.host, - headers={ - "Authorization": f"Token {self.api_key}", - "Mem0-User-ID": self.user_id, - }, + headers=_client_headers(self.api_key, self.user_id), timeout=300, ) self.user_email = self._validate_api_key() @@ -1027,10 +1063,7 @@ class AsyncMemoryClient: else: self.async_client = httpx.AsyncClient( base_url=self.host, - headers={ - "Authorization": f"Token {self.api_key}", - "Mem0-User-ID": self.user_id, - }, + headers=_client_headers(self.api_key, self.user_id), timeout=300, ) @@ -1053,10 +1086,7 @@ class AsyncMemoryClient: params = self._prepare_params() response = requests.get( f"{self.host}/v1/ping/", - headers={ - "Authorization": f"Token {self.api_key}", - "Mem0-User-ID": self.user_id, - }, + headers=_client_headers(self.api_key, self.user_id), params=params, ) response.raise_for_status()