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,