From 47ce17c21ba9f5cbafb888c2c90f8e6751f51ba0 Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Tue, 15 Sep 2026 00:32:39 +0530 Subject: [PATCH] fix(integrations): apply the header contract the docs described MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the contract documented but not implemented, and one client path missed entirely. AsyncMemoryClient's custom-client branch still carried the old literal header dict, so `AsyncMemoryClient(client=...)` sent no surface identity at all — the exact asymmetry this work set out to remove. Both custom-client branches also used a blanket headers.update(), which overwrites. That is the one code path where an outer layer's identity can physically be present, and it was the one path that erased it. They now check-then-set the identity headers and append to an existing client stack, which is what set-once and append-only were supposed to mean. AGENTS.md claimed a plugin calling the Python SDK produces `mem0-plugin/0.3.1, mem0-python/2.0.19`. Nothing in the repo sets the env vars that would make that happen, so the concatenation was unreachable. Replaced with the three ways an integration can actually declare itself, in preference order. memory_core's comment said the backend reads X-Mem0-Source. That is only true from the platform release shipping alongside this, and a reader would otherwise trust it and build header-only attribution that silently does nothing — which is how vercel-ai-sdk was written in the first cut. Corrected in all seven copies, and the body value is what makes attribution work against either backend. mem0-ts hardcoded SDK_VERSION = "3.1.8" while the repo already injects __MEM0_SDK_VERSION__ via tsup, the same mechanism telemetry.ts uses. The hardcode was correct only until the next release bump. Dropped both `as never` casts in pi-agent. They suppressed an excess-property error but also disabled checking of every other option at those call sites, so a typo in filters or threshold would have compiled. SearchMemoryOptions now declares `source` instead. Stack truncation cut mid-identifier, leaving a fragment that parses as a real client name. It now drops whole entries. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb --- integrations/AGENTS.md | 22 ++++++--- .../agent-plugin-core/python/memory_core.py | 9 ++-- .../tests/test_uninitialised_identity.py | 27 ++++++++++ .../antigravity-plugin/core/memory_core.py | 9 ++-- .../claude-code-plugin/core/memory_core.py | 9 ++-- integrations/codex-plugin/core/memory_core.py | 9 ++-- .../cursor-plugin/core/memory_core.py | 9 ++-- integrations/kimi-plugin/core/memory_core.py | 9 ++-- .../mem0-agent-plugin/core/memory_core.py | 9 ++-- integrations/pi-agent-plugin/src/commands.ts | 12 ++--- mem0-ts/src/client/mem0.ts | 8 ++- mem0-ts/src/client/mem0.types.ts | 3 ++ mem0/client/main.py | 49 ++++++++++++++++--- 13 files changed, 142 insertions(+), 42 deletions(-) diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md index 75b60009e..9b85cddb1 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -60,13 +60,23 @@ and the rules on them are what keep one layer from erasing another: | `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. +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. -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. +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 diff --git a/integrations/agent-plugin-core/python/memory_core.py b/integrations/agent-plugin-core/python/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/agent-plugin-core/python/memory_core.py +++ b/integrations/agent-plugin-core/python/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/agent-plugin-core/tests/test_uninitialised_identity.py b/integrations/agent-plugin-core/tests/test_uninitialised_identity.py index 14f911bee..1c742b518 100644 --- a/integrations/agent-plugin-core/tests/test_uninitialised_identity.py +++ b/integrations/agent-plugin-core/tests/test_uninitialised_identity.py @@ -177,3 +177,30 @@ def test_source_tag_defaults_agree_between_the_two_modules(): ) left, right = out.split() 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" diff --git a/integrations/antigravity-plugin/core/memory_core.py b/integrations/antigravity-plugin/core/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/antigravity-plugin/core/memory_core.py +++ b/integrations/antigravity-plugin/core/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/claude-code-plugin/core/memory_core.py b/integrations/claude-code-plugin/core/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/claude-code-plugin/core/memory_core.py +++ b/integrations/claude-code-plugin/core/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/codex-plugin/core/memory_core.py b/integrations/codex-plugin/core/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/codex-plugin/core/memory_core.py +++ b/integrations/codex-plugin/core/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/cursor-plugin/core/memory_core.py b/integrations/cursor-plugin/core/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/cursor-plugin/core/memory_core.py +++ b/integrations/cursor-plugin/core/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/kimi-plugin/core/memory_core.py b/integrations/kimi-plugin/core/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/kimi-plugin/core/memory_core.py +++ b/integrations/kimi-plugin/core/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/mem0-agent-plugin/core/memory_core.py b/integrations/mem0-agent-plugin/core/memory_core.py index 31a08ec37..d350e76d2 100644 --- a/integrations/mem0-agent-plugin/core/memory_core.py +++ b/integrations/mem0-agent-plugin/core/memory_core.py @@ -2008,9 +2008,12 @@ 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. + # 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, diff --git a/integrations/pi-agent-plugin/src/commands.ts b/integrations/pi-agent-plugin/src/commands.ts index e90f8833b..f50194ed6 100644 --- a/integrations/pi-agent-plugin/src/commands.ts +++ b/integrations/pi-agent-plugin/src/commands.ts @@ -6,6 +6,10 @@ import { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts"; import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts"; import { captureCommandEvent } from "./telemetry.ts"; +// Surface attribution on the wire. Previously a PostHog property only, so +// the platform saw these calls as generic SDK traffic. +const PLATFORM_SOURCE = "PI_AGENT"; + const SEARCH_TOP_K = 10; export function registerCommands( @@ -22,10 +26,6 @@ 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, { @@ -34,7 +34,7 @@ const PLATFORM_SOURCE = "PI_AGENT"; topK: SEARCH_TOP_K, rerank: true, source: PLATFORM_SOURCE, - } as never); + }); return result.results ?? []; }; @@ -50,7 +50,7 @@ const PLATFORM_SOURCE = "PI_AGENT"; const addParams = resolveAddParams(config.defaultScope, getScopeCtx()); const result = await mem0.add( [{ role: "user", content: text }], - { ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false, source: PLATFORM_SOURCE } as never, + { ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false, source: PLATFORM_SOURCE }, ); captureCommandEvent("mem0-remember", {}, telemetryCtx); diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 6327acffa..ddea270a2 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -95,7 +95,13 @@ interface ClientIdentity { const IDENTITY_CACHE_MAX_DEFAULT = 50; const identityByCredentials = new Map>(); -const SDK_VERSION = "3.1.8"; +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"; /** * Surface-identity headers. 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 d118bc340..abdf61bca 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -89,6 +89,44 @@ def _sdk_version() -> str: 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()] + entries.append(f"mem0-python/{_sdk_version()}") + mine["X-Mem0-Client"] = _bounded_stack(entries) + + for name, value in mine.items(): + if name in ("X-Mem0-Source", "X-Application") and existing.get(name): + continue + existing[name] = value + + +def _bounded_stack(entries) -> str: + """Join stack entries within the cap, dropping whole entries not characters. + + A blunt slice cut mid-identifier and left a fragment that parses as a real + client name. + """ + out = [] + for entry in list(entries)[:4]: + candidate = ", ".join(out + [entry]) + if len(candidate) > 200: + break + out.append(entry) + return ", ".join(out) + + def _client_headers(api_key: str, user_id: str) -> Dict[str, str]: """Auth plus surface-identity headers. @@ -120,7 +158,7 @@ def _client_stack() -> str: 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] + return _bounded_stack(entries) class MemoryClient: @@ -173,7 +211,7 @@ 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(_client_headers(self.api_key, self.user_id)) + _apply_client_headers(self.client, self.api_key, self.user_id) else: self.client = httpx.Client( base_url=self.host, @@ -1054,12 +1092,7 @@ 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,