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()