diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md index 9b85cddb1..221c8206f 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -82,6 +82,15 @@ 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/`. 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/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/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/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/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/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/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/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 = ""