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..221c8206f 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -49,6 +49,48 @@ 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 check-then-set, never assignment. An integration that wraps the +SDK is the outermost layer and sets the source; the SDK underneath defers to it. +Assignment is exactly how every agent plugin came to be indistinguishable from +every other one at the platform. + +How to declare it from an integration, in order of preference: + +1. Send the headers yourself, if you make the HTTP call directly. +2. Pass `source` in the call options, if you go through an SDK. +3. Set `MEM0_SOURCE` / `MEM0_APPLICATION` / `MEM0_CLIENT_STACK` in the + environment before constructing the client. The SDKs read these and defer to + anything already present. + +Append-only applies where a stack can actually form: an SDK handed a client that +already carries `X-Mem0-Client` appends itself rather than replacing. An SDK +constructed with no outer context simply reports itself, which is correct — it +is the outermost layer in that process. + +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. + +`X-Application` is allowlisted the same way, and this one has a rule of its own: +**omit the header when you do not know the host.** A value outside the allowlist +is discarded server-side, so guessing produces an event that claims an +attribution we do not actually have. The portable bundle is the case that +matters. It runs in whatever editor a user drops it into, so its build leaves +`PLATFORM_APPLICATION` empty and `memory_core` sends no header at all, while the +native bundles each name the host they were generated for. If you add a build +target, decide which of those two it is. + ## 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 +101,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/build/build.py b/integrations/agent-plugin-core/build/build.py index e675efb02..3059b9b1f 100644 --- a/integrations/agent-plugin-core/build/build.py +++ b/integrations/agent-plugin-core/build/build.py @@ -81,15 +81,23 @@ def replace_output(staged: Path, output: Path) -> Path: return output -def _render_harness_id(host: str) -> str: +def _render_harness_id(host: str, *, portable: bool = False) -> str: """Emit core/_harness_id.py for one host. Carries both vocabularies from a single definition: the PostHog `source` tag and the platform's X-Mem0-Source / X-Application pair. Keeping them together is what stops the two from drifting into separate vocabularies for the same thing. + + The portable bundle runs in whatever editor a user drops it into, so it does + not know its host and must not guess one. HARNESS_ID stays "coding-agent", + which is true and useful for grouping in PostHog, but PLATFORM_APPLICATION is + left empty: X-Application names a real host app, is checked against an + allowlist server-side, and a value that is always discarded is worse than no + value -- it reads like an attribution we have and do not. """ tag = host.upper().replace("-", "_") + "_PLUGIN" + application = "" if portable else host return ( '"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n' "\n" @@ -98,8 +106,10 @@ def _render_harness_id(host: str) -> str: "\n" "# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n" "# plugin family is one source; which editor it runs in is the application.\n" + "# An empty application means the host is unknown, and memory_core omits\n" + "# the header entirely rather than sending a placeholder.\n" 'PLATFORM_SOURCE = "MEM0_PLUGIN"\n' - f'PLATFORM_APPLICATION = "{host}"\n' + f'PLATFORM_APPLICATION = "{application}"\n' ) @@ -122,7 +132,7 @@ def _bundle_python( # to call telemetry.init(). mcp_server.py and the detached telemetry.py sender # never did, which is how MCP searches reported harness=generic and every # batch they drained was labelled MEM0_PLUGIN regardless of the real host. - (core / "_harness_id.py").write_text(_render_harness_id(host), encoding="utf-8") + (core / "_harness_id.py").write_text(_render_harness_id(host, portable=portable), encoding="utf-8") values = { "PLUGIN_ROOT": plugin_root, diff --git a/integrations/agent-plugin-core/python/memory_core.py b/integrations/agent-plugin-core/python/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/agent-plugin-core/tests/test_build.py b/integrations/agent-plugin-core/tests/test_build.py index 54b5e5c6a..23295cc38 100644 --- a/integrations/agent-plugin-core/tests/test_build.py +++ b/integrations/agent-plugin-core/tests/test_build.py @@ -70,6 +70,38 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non assert not any(path.is_symlink() for path in root.rglob("*")) +def _harness_identity(root: Path) -> dict[str, str]: + """Read the generated core/_harness_id.py without importing it.""" + values: dict[str, str] = {} + for line in (root / "core" / "_harness_id.py").read_text(encoding="utf-8").splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + name, _, raw = line.partition("=") + values[name.strip()] = raw.strip().strip('"') + return values + + +def test_the_portable_bundle_declares_no_host_application(tmp_path: Path) -> None: + """It runs in whatever editor a user drops it into, so it cannot know the host. + + X-Application is allowlisted server-side. A guessed value is silently dropped + there, which is the worst outcome: the wire says we know the host and the + stored event says we do not. + """ + identity = _harness_identity(build("mem0-agent-plugin", "portable", tmp_path / "portable")) + + assert identity["PLATFORM_APPLICATION"] == "" + # The PostHog-side label is still useful for grouping and stays populated. + assert identity["HARNESS_ID"] == "coding-agent" + assert identity["PLATFORM_SOURCE"] == "MEM0_PLUGIN" + + +@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"]) +def test_a_native_bundle_names_the_host_it_was_built_for(host: str, tmp_path: Path) -> None: + identity = _harness_identity(build(host, "native", tmp_path / host)) + + assert identity["PLATFORM_APPLICATION"] == host + + @pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"]) def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None: root = build(host, "native", tmp_path / host) diff --git a/integrations/agent-plugin-core/tests/test_uninitialised_identity.py b/integrations/agent-plugin-core/tests/test_uninitialised_identity.py index 7d48dbcc3..74a31fa5c 100644 --- a/integrations/agent-plugin-core/tests/test_uninitialised_identity.py +++ b/integrations/agent-plugin-core/tests/test_uninitialised_identity.py @@ -179,6 +179,33 @@ def test_source_tag_defaults_agree_between_the_two_modules(): assert left == right == "KIMI_PLUGIN" +def test_the_plugin_declares_its_surface_in_the_body_and_the_headers(): + """Body and headers both, because only the body works on every backend.""" + core = _core_dir("claude-code-plugin") + if not core.exists(): + pytest.skip("claude-code-plugin is not built in this tree") + + with tempfile.TemporaryDirectory() as tmp: + out = _run( + core, + Path(tmp), + "import json, memory_core\n" + "h = memory_core.platform_headers('k')\n" + "print(json.dumps({'source': h.get('X-Mem0-Source')," + " 'app': h.get('X-Application')," + " 'client': h.get('X-Mem0-Client')," + " 'auth': h.get('Authorization')," + " 'ctype': h.get('Content-Type')}))", + ) + headers = json.loads(out) + assert headers["source"] == "MEM0_PLUGIN" + assert headers["app"] == "claude-code" + assert headers["client"].startswith("mem0-plugin/") + # The transport headers the three call sites relied on must survive. + assert headers["auth"] == "Token k" + assert headers["ctype"] == "application/json" + + def _session_start(core: Path, data_dir: Path) -> list[str]: """Drive the real hook_runner session-start path and return lifecycle events.""" recorded = "\n".join( diff --git a/integrations/antigravity-plugin/core/_harness_id.py b/integrations/antigravity-plugin/core/_harness_id.py index 2c8a515f4..18b7f68ec 100644 --- a/integrations/antigravity-plugin/core/_harness_id.py +++ b/integrations/antigravity-plugin/core/_harness_id.py @@ -5,5 +5,7 @@ SOURCE_TAG = "ANTIGRAVITY_PLUGIN" # Platform-side vocabulary (mem0_event.source + X-Application). The whole # plugin family is one source; which editor it runs in is the application. +# An empty application means the host is unknown, and memory_core omits +# the header entirely rather than sending a placeholder. PLATFORM_SOURCE = "MEM0_PLUGIN" PLATFORM_APPLICATION = "antigravity" diff --git a/integrations/antigravity-plugin/core/memory_core.py b/integrations/antigravity-plugin/core/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/_harness_id.py b/integrations/claude-code-plugin/core/_harness_id.py index 6c3e1ce15..9c4949408 100644 --- a/integrations/claude-code-plugin/core/_harness_id.py +++ b/integrations/claude-code-plugin/core/_harness_id.py @@ -5,5 +5,7 @@ SOURCE_TAG = "CLAUDE_CODE_PLUGIN" # Platform-side vocabulary (mem0_event.source + X-Application). The whole # plugin family is one source; which editor it runs in is the application. +# An empty application means the host is unknown, and memory_core omits +# the header entirely rather than sending a placeholder. PLATFORM_SOURCE = "MEM0_PLUGIN" PLATFORM_APPLICATION = "claude-code" diff --git a/integrations/claude-code-plugin/core/memory_core.py b/integrations/claude-code-plugin/core/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/_harness_id.py b/integrations/codex-plugin/core/_harness_id.py index 152313a21..47938ee08 100644 --- a/integrations/codex-plugin/core/_harness_id.py +++ b/integrations/codex-plugin/core/_harness_id.py @@ -5,5 +5,7 @@ SOURCE_TAG = "CODEX_PLUGIN" # Platform-side vocabulary (mem0_event.source + X-Application). The whole # plugin family is one source; which editor it runs in is the application. +# An empty application means the host is unknown, and memory_core omits +# the header entirely rather than sending a placeholder. PLATFORM_SOURCE = "MEM0_PLUGIN" PLATFORM_APPLICATION = "codex" diff --git a/integrations/codex-plugin/core/memory_core.py b/integrations/codex-plugin/core/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/_harness_id.py b/integrations/cursor-plugin/core/_harness_id.py index 40241a7f4..0e20bf1ff 100644 --- a/integrations/cursor-plugin/core/_harness_id.py +++ b/integrations/cursor-plugin/core/_harness_id.py @@ -5,5 +5,7 @@ SOURCE_TAG = "CURSOR_PLUGIN" # Platform-side vocabulary (mem0_event.source + X-Application). The whole # plugin family is one source; which editor it runs in is the application. +# An empty application means the host is unknown, and memory_core omits +# the header entirely rather than sending a placeholder. PLATFORM_SOURCE = "MEM0_PLUGIN" PLATFORM_APPLICATION = "cursor" diff --git a/integrations/cursor-plugin/core/memory_core.py b/integrations/cursor-plugin/core/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/deepseek-plugin/src/index.ts b/integrations/deepseek-plugin/src/index.ts index 2d24b498a..3ac86f4b0 100644 --- a/integrations/deepseek-plugin/src/index.ts +++ b/integrations/deepseek-plugin/src/index.ts @@ -26,8 +26,9 @@ export const name = "mem0"; export const inject = ["tools", "systemPrompt"]; // Tags writes so Mem0's backend attributes them to this integration in -// telemetry. The backend's KNOWN_EVENT_SOURCES allowlist recognizes this value; -// anything outside it buckets into "OTHERS". +// telemetry. Values outside the backend's KNOWN_EVENT_SOURCES allowlist bucket +// into "OTHERS"; this one is added by mem0ai/platform#3602 and reads as OTHERS +// until that ships. const SOURCE = "DEEPSEEK_HARNESS"; const DEFAULT_SEARCH_LIMIT = 10; diff --git a/integrations/kimi-plugin/core/_harness_id.py b/integrations/kimi-plugin/core/_harness_id.py index aa17750b1..83ab061ef 100644 --- a/integrations/kimi-plugin/core/_harness_id.py +++ b/integrations/kimi-plugin/core/_harness_id.py @@ -5,5 +5,7 @@ SOURCE_TAG = "KIMI_PLUGIN" # Platform-side vocabulary (mem0_event.source + X-Application). The whole # plugin family is one source; which editor it runs in is the application. +# An empty application means the host is unknown, and memory_core omits +# the header entirely rather than sending a placeholder. PLATFORM_SOURCE = "MEM0_PLUGIN" PLATFORM_APPLICATION = "kimi" diff --git a/integrations/kimi-plugin/core/memory_core.py b/integrations/kimi-plugin/core/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/_harness_id.py b/integrations/mem0-agent-plugin/core/_harness_id.py index b0836d6de..3efd000f8 100644 --- a/integrations/mem0-agent-plugin/core/_harness_id.py +++ b/integrations/mem0-agent-plugin/core/_harness_id.py @@ -5,5 +5,7 @@ SOURCE_TAG = "CODING_AGENT_PLUGIN" # Platform-side vocabulary (mem0_event.source + X-Application). The whole # plugin family is one source; which editor it runs in is the application. +# An empty application means the host is unknown, and memory_core omits +# the header entirely rather than sending a placeholder. PLATFORM_SOURCE = "MEM0_PLUGIN" -PLATFORM_APPLICATION = "coding-agent" +PLATFORM_APPLICATION = "" diff --git a/integrations/mem0-agent-plugin/core/memory_core.py b/integrations/mem0-agent-plugin/core/memory_core.py index 77d7a2b80..d350e76d2 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,13 @@ 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 or + # the query string, never from metadata, which is where this used to + # sit. The X-Mem0-Source header is also read, but only from the + # platform release that ships alongside this change, so the body value + # is what makes attribution work on both. 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 +2558,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/attribution.test.ts b/integrations/pi-agent-plugin/src/attribution.test.ts new file mode 100644 index 000000000..f90d28da3 --- /dev/null +++ b/integrations/pi-agent-plugin/src/attribution.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { applySurfaceHeaders, PLATFORM_APPLICATION, PLATFORM_SOURCE } from "./attribution.ts"; + +function client(headers: Record = {}) { + return { headers: { Authorization: "Token k", ...headers } } as never; +} + +describe("applySurfaceHeaders", () => { + it("stamps the shared client so every path is attributed, not just commands", () => { + const mem0 = client(); + applySurfaceHeaders(mem0); + const headers = (mem0 as unknown as { headers: Record }).headers; + + expect(headers["X-Mem0-Source"]).toBe(PLATFORM_SOURCE); + expect(headers["X-Application"]).toBe(PLATFORM_APPLICATION); + expect(headers["X-Mem0-Client"]).toMatch(/^mem0-pi-agent\//); + expect(headers.Authorization).toBe("Token k"); + }); + + it("defers to a surface an outer wrapper already declared", () => { + const mem0 = client({ "X-Mem0-Source": "OPENCLAW", "X-Application": "vscode" }); + applySurfaceHeaders(mem0); + const headers = (mem0 as unknown as { headers: Record }).headers; + + expect(headers["X-Mem0-Source"]).toBe("OPENCLAW"); + expect(headers["X-Application"]).toBe("vscode"); + }); + + it("appends to the client stack rather than replacing it", () => { + const mem0 = client({ "X-Mem0-Client": "openclaw/2.1.0" }); + applySurfaceHeaders(mem0); + const headers = (mem0 as unknown as { headers: Record }).headers; + + expect(headers["X-Mem0-Client"]).toMatch(/^openclaw\/2\.1\.0, mem0-pi-agent\//); + }); + + it("treats a blank header as absent", () => { + const mem0 = client({ "X-Mem0-Source": " " }); + applySurfaceHeaders(mem0); + const headers = (mem0 as unknown as { headers: Record }).headers; + + expect(headers["X-Mem0-Source"]).toBe(PLATFORM_SOURCE); + }); + + it("bounds the stack so a long chain cannot grow the header without limit", () => { + const mem0 = client({ "X-Mem0-Client": "a/1, b/1, c/1, d/1, e/1" }); + applySurfaceHeaders(mem0); + const headers = (mem0 as unknown as { headers: Record }).headers; + + expect(headers["X-Mem0-Client"].split(",").length).toBeLessThanOrEqual(4); + expect(headers["X-Mem0-Client"].length).toBeLessThanOrEqual(200); + }); +}); + +describe("client stack bounding", () => { + it("keeps our own entry when the caller already filled the stack", () => { + // The defect: pushing then trimming to four dropped exactly the entry this + // function exists to add, so we vanished from our own stack. + const mem0 = client({ "X-Mem0-Client": "a/1, b/2, c/3, d/4" }); + applySurfaceHeaders(mem0); + const stack = (mem0 as unknown as { headers: Record }).headers["X-Mem0-Client"]; + + expect(stack).toMatch(/mem0-pi-agent\//); + expect(stack.split(",").length).toBeLessThanOrEqual(4); + }); + + it("drops whole entries at the character cap, never a fragment", () => { + const long = `${"n".repeat(90)}/1.0, ${"m".repeat(90)}/1.0, ${"o".repeat(90)}/1.0`; + const mem0 = client({ "X-Mem0-Client": long }); + applySurfaceHeaders(mem0); + const stack = (mem0 as unknown as { headers: Record }).headers["X-Mem0-Client"]; + + expect(stack.length).toBeLessThanOrEqual(200); + expect(stack.endsWith("/0.0.0") || /mem0-pi-agent\/[\w.\-]+$/.test(stack)).toBe(true); + // Every surviving entry is whole: name/version, no severed tail. + for (const entry of stack.split(",")) { + expect(entry.trim()).toMatch(/^[^/]+\/[^/]+$/); + } + }); +}); diff --git a/integrations/pi-agent-plugin/src/attribution.ts b/integrations/pi-agent-plugin/src/attribution.ts new file mode 100644 index 000000000..2cbc9a73e --- /dev/null +++ b/integrations/pi-agent-plugin/src/attribution.ts @@ -0,0 +1,65 @@ +import type MemoryClient from "mem0ai"; +import * as fs from "node:fs"; + +/** Surface identity for this plugin, as the platform's EventSource knows it. */ +export const PLATFORM_SOURCE = "PI_AGENT"; + +/** Host app the plugin runs inside. Allowlisted server-side. */ +export const PLATFORM_APPLICATION = "pi"; + +const PLUGIN_VERSION = (() => { + try { + return JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"), + ).version as string; + } catch { + return "unknown"; + } +})(); + +const MAX_STACK_ENTRIES = 4; +const MAX_STACK_CHARS = 200; + +/** + * Append our own entry and bound the result, dropping WHOLE entries. + * + * Neither cap cuts characters: slicing the joined string severs an identifier + * and leaves a fragment the platform parses as a real client name. And the + * reserved slot is ours, since it is the only entry this layer can vouch for. + */ +function boundedStack(callerEntries: string[], own: string): string { + const kept: string[] = []; + let budget = MAX_STACK_CHARS - own.length; + for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) { + const cost = entry.length + ", ".length; + if (cost > budget) break; + budget -= cost; + kept.push(entry); + } + return [...kept, own].join(", "); +} + +/** + * Stamp surface identity onto the shared client, once, at construction. + * + * Tagging individual call sites was not enough: automatic recall, capture, the + * memory tools and deletion all go through this same client, so everything + * except the explicit slash commands reached the platform as generic SDK + * traffic. Every request method in the SDK sends `this.headers`, so setting + * them here covers all of them. + * + * X-Mem0-Source and X-Application are set-once, so a wrapper that already named + * a surface keeps it. X-Mem0-Client is append-only, so the platform sees the + * whole chain rather than only the last speaker. + */ +export function applySurfaceHeaders(client: MemoryClient): void { + const headers = client.headers as Record; + if (!headers["X-Mem0-Source"]?.trim()) headers["X-Mem0-Source"] = PLATFORM_SOURCE; + if (!headers["X-Application"]?.trim()) headers["X-Application"] = PLATFORM_APPLICATION; + + const existing = (headers["X-Mem0-Client"] ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + headers["X-Mem0-Client"] = boundedStack(existing, `mem0-pi-agent/${PLUGIN_VERSION}`); +} diff --git a/integrations/pi-agent-plugin/src/commands.ts b/integrations/pi-agent-plugin/src/commands.ts index 9379bed9a..e969478a1 100644 --- a/integrations/pi-agent-plugin/src/commands.ts +++ b/integrations/pi-agent-plugin/src/commands.ts @@ -1,3 +1,4 @@ +import type { SearchMemoryOptions } from "mem0ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type MemoryClient from "mem0ai"; import type { Mem0Config, ScopeContext, Scope } from "./types.ts"; @@ -5,6 +6,12 @@ import { DEFAULT_CUSTOM_CATEGORIES } from "./types.ts"; import { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts"; import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts"; import { captureCommandEvent } from "./telemetry.ts"; +import { PLATFORM_SOURCE } from "./attribution.ts"; + +// Wire identity is set once on the shared client in entry.ts, which covers +// every path including recall, capture, tools and deletion. It stays in the +// body of the two calls below as well: body `source` is what the backend reads +// when the header is absent. const SEARCH_TOP_K = 10; @@ -29,7 +36,12 @@ export function registerCommands( threshold: config.searchThreshold, topK: SEARCH_TOP_K, rerank: true, - }); + source: PLATFORM_SOURCE, + // Widened by exactly this one property. `source` reaches the wire via the + // SDK's camelToSnakeKeys spread, but it is absent from SearchMemoryOptions + // in the published mem0ai types. A blanket `as never` would also disable + // checking of filters, threshold, topK and rerank above. + } as SearchMemoryOptions & { source: string }); return result.results ?? []; }; @@ -45,7 +57,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 }, ); captureCommandEvent("mem0-remember", {}, telemetryCtx); diff --git a/integrations/pi-agent-plugin/src/entry.ts b/integrations/pi-agent-plugin/src/entry.ts index d49d78ad0..256c05aa4 100644 --- a/integrations/pi-agent-plugin/src/entry.ts +++ b/integrations/pi-agent-plugin/src/entry.ts @@ -10,6 +10,7 @@ import { captureEvent } from "./telemetry.ts"; import * as os from "node:os"; import type { ScopeContext } from "./types.ts"; import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts"; +import { applySurfaceHeaders } from "./attribution.ts"; export { buildRecallContext } from "../../agent-plugin-core/typescript/src/lifecycle.ts"; @@ -29,6 +30,11 @@ export default function mem0Extension(pi: ExtensionAPI): void { } const mem0 = new MemoryClient({ apiKey: config.apiKey }); + // Every path below shares this client: automatic recall, capture, the memory + // tools and deletion as well as the slash commands. Attribution belongs here + // rather than on individual calls, or everything except the commands reports + // as generic SDK traffic. + applySurfaceHeaders(mem0); const scopeCtx: ScopeContext = { userId: resolveUserId(config.userId), diff --git a/integrations/vercel-ai-sdk/src/mem0-utils.ts b/integrations/vercel-ai-sdk/src/mem0-utils.ts index 55d485cbc..b7a041939 100644 --- a/integrations/vercel-ai-sdk/src/mem0-utils.ts +++ b/integrations/vercel-ai-sdk/src/mem0-utils.ts @@ -1,3 +1,10 @@ +declare const __MEM0_PROVIDER_VERSION__: string | undefined; + +// Replaced at build time by tsup `define`. The fallback only applies when the +// source is run unbundled, such as in tests. +const PROVIDER_VERSION = + typeof __MEM0_PROVIDER_VERSION__ !== "undefined" ? __MEM0_PROVIDER_VERSION__ : "dev"; + import { LanguageModelV3Prompt } from '@ai-sdk/provider'; import { Mem0ConfigSettings } from './mem0-types'; import { loadApiKey } from '@ai-sdk/provider-utils'; @@ -277,7 +284,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 +342,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/integrations/vercel-ai-sdk/tsconfig.json b/integrations/vercel-ai-sdk/tsconfig.json index b05d5db02..e40350730 100644 --- a/integrations/vercel-ai-sdk/tsconfig.json +++ b/integrations/vercel-ai-sdk/tsconfig.json @@ -12,6 +12,7 @@ "noUnusedLocals": false, "noUnusedParameters": false, "preserveWatchOutput": true, + "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "types": ["@types/node", "jest"], diff --git a/integrations/vercel-ai-sdk/tsup.config.ts b/integrations/vercel-ai-sdk/tsup.config.ts index 2c8f74a6d..ff2d298c0 100644 --- a/integrations/vercel-ai-sdk/tsup.config.ts +++ b/integrations/vercel-ai-sdk/tsup.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'tsup' +import pkg from './package.json' export default defineConfig([ { @@ -6,5 +7,11 @@ export default defineConfig([ entry: ['src/index.ts'], format: ['cjs', 'esm'], sourcemap: true, + // Injected rather than written in the source. A hardcoded literal matches + // package.json on the day it is written and misreports the client version + // from the next release bump onwards. Same mechanism as mem0-ts. + define: { + __MEM0_PROVIDER_VERSION__: JSON.stringify(pkg.version), + }, }, ]) \ No newline at end of file diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index ff259f2fe..33e03f0c2 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -95,6 +95,66 @@ interface ClientIdentity { const IDENTITY_CACHE_MAX_DEFAULT = 50; const identityByCredentials = new Map>(); +declare const __MEM0_SDK_VERSION__: string | undefined; + +// Injected by tsup (see mem0-ts/tsup.config.ts `define`), the same mechanism +// telemetry.ts already uses. A hardcoded literal goes stale at the next release +// bump and then misreports the client version forever. +const SDK_VERSION = + typeof __MEM0_SDK_VERSION__ !== "undefined" ? __MEM0_SDK_VERSION__ : "dev"; + +const MAX_STACK_ENTRIES = 4; +const MAX_STACK_CHARS = 200; + +/** + * Append our own entry and bound the result, dropping WHOLE entries. + * + * Neither cap cuts characters: slicing the joined string severs an identifier + * and leaves a fragment the platform parses as a real client name. And the + * reserved slot is ours. Pushing first and then trimming to four dropped exactly + * the entry this exists to add whenever a caller already sent four, so we + * vanished from our own stack while every caller claim survived. + */ +function boundedStack(callerEntries: string[], own: string): string { + const kept: string[] = []; + let budget = MAX_STACK_CHARS - own.length; + for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) { + const cost = entry.length + ", ".length; + if (cost > budget) break; + budget -= cost; + kept.push(entry); + } + return [...kept, own].join(", "); +} + +/** + * 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) + : []; + const headers: Record = { + "X-Mem0-Client": boundedStack(entries, `mem0-js/${SDK_VERSION}`), + }; + 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 +189,7 @@ export default class MemoryClient { this.headers = { Authorization: `Token ${this.apiKey}`, "Content-Type": "application/json", + ...surfaceHeaders(), }; this.client = axios.create({ diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index c230441e0..cbe541321 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -30,6 +30,9 @@ export interface SearchMemoryOptions { showExpired?: boolean; referenceDate?: string | number; keywordSearch?: boolean; + /** Surface that produced the call, e.g. "OPENCLAW". Must be a value the + * backend's EventSource enum knows, or it buckets into OTHERS. */ + source?: string; } export interface GetAllMemoryOptions { diff --git a/mem0/client/main.py b/mem0/client/main.py index a52986dcd..3a82d9e5a 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -79,6 +79,95 @@ 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 _apply_client_headers(client: Any, api_key: str, user_id: str) -> None: + """Merge our headers into a caller-supplied client without erasing theirs. + + A wrapper may hand us a client already carrying its own X-Mem0-Source or a + partial X-Mem0-Client stack. Blanket update() replaced both, which is the + opposite of the set-once / append-only contract: the outermost layer is the + one whose identity should survive. + """ + existing = client.headers + mine = _client_headers(api_key, user_id) + + outer_stack = existing.get("X-Mem0-Client") + if outer_stack: + entries = [part.strip() for part in str(outer_stack).split(",") if part.strip()] + mine["X-Mem0-Client"] = _bounded_stack(entries, f"mem0-python/{_sdk_version()}") + + for name, value in mine.items(): + if name in ("X-Mem0-Source", "X-Application") and existing.get(name): + continue + existing[name] = value + + +MAX_STACK_ENTRIES = 4 +MAX_STACK_CHARS = 200 + + +def _bounded_stack(caller_entries, own: str) -> str: + """Append our own entry and bound the result, dropping WHOLE entries. + + Two rules, and the second is the one that was wrong. Neither cap cuts + characters: a blunt slice severs an identifier and leaves a fragment that + parses as a real client name. And the reserved slot is OURS. Appending first + and then trimming to four dropped exactly the entry this function exists to + add, every time a caller already sent four, so the SDK vanished from its own + stack while the caller's claims all survived. + """ + kept = [] + budget = MAX_STACK_CHARS - len(own) + for entry in list(caller_entries)[: MAX_STACK_ENTRIES - 1]: + cost = len(entry) + len(", ") + if cost > budget: + break + budget -= cost + kept.append(entry) + return ", ".join(kept + [own]) + + +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() + entries = [part.strip() for part in existing.split(",") if part.strip()] if existing else [] + return _bounded_stack(entries, f"mem0-python/{_sdk_version()}") + + class MemoryClient: """Client for interacting with the Mem0 API. @@ -129,19 +218,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, - } - ) + _apply_client_headers(self.client, 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() @@ -1018,19 +1099,11 @@ class AsyncMemoryClient: self.async_client = client # Ensure the client has the correct base_url and headers self.async_client.base_url = httpx.URL(self.host) - self.async_client.headers.update( - { - "Authorization": f"Token {self.api_key}", - "Mem0-User-ID": self.user_id, - } - ) + _apply_client_headers(self.async_client, self.api_key, self.user_id) 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 +1126,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() diff --git a/tests/test_client_surface_headers.py b/tests/test_client_surface_headers.py new file mode 100644 index 000000000..51d09d306 --- /dev/null +++ b/tests/test_client_surface_headers.py @@ -0,0 +1,63 @@ +"""Surface-identity headers, and that a client can be constructed at all. + +The construction test exists because it was not there: a signature change to +_bounded_stack missed the _client_stack call site, every MemoryClient(...) raised +TypeError, and the whole suite stayed green because nothing built one. +""" + +import os +from unittest.mock import patch + +from mem0.client.main import _bounded_stack, _client_headers, _client_stack + + +def test_a_client_can_be_constructed(): + from mem0 import MemoryClient + + # _validate_api_key normally populates org/project from the API response; + # stubbing it leaves them None, which a later accessor rejects. Set them the + # way a real validation would. This test is about construction reaching the + # header stage at all. + def _stub(self): + self.org_id, self.project_id = "org", "proj" + + with patch.object(MemoryClient, "_validate_api_key", _stub): + client = MemoryClient(api_key="m0-test") + + assert client.client.headers["X-Mem0-Client"].startswith("mem0-python/") + + +# AsyncMemoryClient is deliberately not constructed here: its validation path +# makes a real request to /v1/ping/, and a unit test that needs the network is +# worse than none. It shares _client_headers with the sync client, which is the +# code the construction test above actually guards. +def test_headers_carry_this_sdk(): + headers = _client_headers("m0-test", "u1") + assert headers["X-Mem0-Client"].startswith("mem0-python/") + + +def test_our_entry_survives_a_caller_that_already_filled_the_stack(): + # Appending first and trimming to four dropped exactly the entry the + # function exists to add. + stack = _bounded_stack(["a/1", "b/2", "c/3", "d/4"], "mem0-python/9.9.9") + + assert "mem0-python/9.9.9" in stack + assert len(stack.split(",")) <= 4 + + +def test_the_character_cap_drops_whole_entries_not_characters(): + long_entries = [f"{'n' * 90}/1.0", f"{'m' * 90}/1.0", "c/3"] + stack = _bounded_stack(long_entries, "mem0-python/9.9.9") + + assert len(stack) <= 200 + assert stack.endswith("mem0-python/9.9.9") + for entry in stack.split(","): + assert entry.strip().count("/") == 1, f"severed entry: {entry!r}" + + +def test_an_outer_stack_is_appended_to_not_replaced(): + with patch.dict(os.environ, {"MEM0_CLIENT_STACK": "openclaw/2.1.0"}): + stack = _client_stack() + + assert stack.startswith("openclaw/2.1.0") + assert "mem0-python/" in stack