diff --git a/integrations/agent-plugin-core/build/build.py b/integrations/agent-plugin-core/build/build.py index 7a72e5c51..e675efb02 100644 --- a/integrations/agent-plugin-core/build/build.py +++ b/integrations/agent-plugin-core/build/build.py @@ -81,6 +81,28 @@ def replace_output(staged: Path, output: Path) -> Path: return output +def _render_harness_id(host: str) -> 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. + """ + tag = host.upper().replace("-", "_") + "_PLUGIN" + return ( + '"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n' + "\n" + f'HARNESS_ID = "{host}"\n' + f'SOURCE_TAG = "{tag}"\n' + "\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" + 'PLATFORM_SOURCE = "MEM0_PLUGIN"\n' + f'PLATFORM_APPLICATION = "{host}"\n' + ) + + def _bundle_python( staged: Path, host: str, @@ -96,6 +118,12 @@ def _bundle_python( continue shutil.copy2(source, core / source.name) + # Generated per host so identity does not depend on an entrypoint remembering + # 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") + values = { "PLUGIN_ROOT": plugin_root, "PLUGIN_DATA": "${PLUGIN_DATA}", diff --git a/integrations/agent-plugin-core/python/telemetry.py b/integrations/agent-plugin-core/python/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/agent-plugin-core/python/telemetry.py +++ b/integrations/agent-plugin-core/python/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False, diff --git a/integrations/agent-plugin-core/tests/test_uninitialised_identity.py b/integrations/agent-plugin-core/tests/test_uninitialised_identity.py new file mode 100644 index 000000000..14f911bee --- /dev/null +++ b/integrations/agent-plugin-core/tests/test_uninitialised_identity.py @@ -0,0 +1,179 @@ +"""Core telemetry behaviour with NO telemetry.init(), in a real subprocess. + +Why this file exists +-------------------- +``telemetry.py`` lives in ``agent-plugin-core/python/`` but its only tests lived +under ``claude-code-plugin/tests/``, behind a ``conftest.py`` that calls +``configure_harness()`` and ``telemetry.init()`` at import. Core behaviour was +therefore only ever exercised inside an already-configured module. + +Two processes in the real pipeline never call ``init()``: + +- ``mcp_server.py``, which records every manual search; +- the detached ``python3 telemetry.py`` sender that ``spawn_flush()`` starts at + session start, after every skill command, and when the MCP server exits. + +Both fell back to module defaults, so MCP searches reported ``harness=generic`` +and everything that sender delivered was labelled ``MEM0_PLUGIN`` regardless of +which of the six plugins produced it. The suite stayed green throughout. + +These tests run in a fresh interpreter with no conftest, against a built host +bundle, which is the only arrangement that can catch that class of bug. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +CORE_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = CORE_ROOT.parents[1] +HOSTS = { + "claude-code": ("claude-code-plugin", "CLAUDE_CODE_PLUGIN"), + "cursor": ("cursor-plugin", "CURSOR_PLUGIN"), + "codex": ("codex-plugin", "CODEX_PLUGIN"), + "kimi": ("kimi-plugin", "KIMI_PLUGIN"), + "antigravity": ("antigravity-plugin", "ANTIGRAVITY_PLUGIN"), + # Portable: no flush_worker and no hook_runner, so its ONLY sender is the + # uninitialised telemetry.py. A native-only test passes here vacuously. + "coding-agent": ("mem0-agent-plugin", "CODING_AGENT_PLUGIN"), +} + + +def _core_dir(directory: str) -> Path: + return REPOSITORY_ROOT / "integrations" / directory / "core" + + +def _run(core: Path, data_dir: Path, body: str) -> str: + """Execute `body` in a fresh interpreter with only the host's core on sys.path.""" + script = f"import sys; sys.path.insert(0, {str(core)!r})\n{body}" + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + env={ + "MEM0_CODE_DATA_DIR": str(data_dir), + "PATH": "/usr/bin:/bin", + "HOME": str(data_dir), + }, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +@pytest.mark.parametrize("harness,spec", sorted(HOSTS.items())) +def test_identity_resolves_without_init(harness, spec): + """Every built host knows what it is with no configuration call at all.""" + directory, source_tag = spec + core = _core_dir(directory) + if not core.exists(): + pytest.skip(f"{directory} is not built in this tree") + + with tempfile.TemporaryDirectory() as tmp: + out = _run( + core, + Path(tmp), + "import telemetry; print(telemetry._harness, telemetry._source_tag)", + ) + assert out == f"{harness} {source_tag}" + + +def test_mcp_server_records_the_real_harness(): + """mcp_server imports telemetry and never initialises it (server.py has no init). + + Its recorded events used to carry harness=generic for every plugin. + """ + 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: + data_dir = Path(tmp) + _run( + core, + data_dir, + "import mcp_server, telemetry; telemetry.record('search', trigger='mcp-search')", + ) + spooled = (data_dir / "telemetry.jsonl").read_text(encoding="utf-8").strip() + + event = json.loads(spooled) + assert event["properties"]["harness"] == "claude-code" + assert event["properties"]["source"] == "CLAUDE_CODE_PLUGIN" + + +def test_the_detached_sender_does_not_relabel_events(): + """`python3 telemetry.py` is the sender spawn_flush() starts, and never inits. + + source is stamped at record time now, so which process sends is irrelevant. + """ + 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: + data_dir = Path(tmp) + _run(core, data_dir, "import telemetry; telemetry.record('search')") + + captured = data_dir / "captured.json" + # Drain with a fresh, unconfigured interpreter, capturing the payload + # instead of posting it. + _run( + core, + data_dir, + "import json, telemetry\n" + "sent = []\n" + "telemetry._post = lambda payload, url: sent.append(payload) or True\n" + "telemetry.flush()\n" + f"open({str(captured)!r}, 'w').write(json.dumps(sent))", + ) + payloads = json.loads(captured.read_text(encoding="utf-8")) + + batches = [p for p in payloads if "batch" in p] + assert batches, "nothing was sent" + properties = batches[0]["batch"][0]["properties"] + assert properties["source"] == "CLAUDE_CODE_PLUGIN" + assert properties["harness"] == "claude-code" + + +def test_every_event_carries_a_uuid_for_dedupe(): + 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: + data_dir = Path(tmp) + _run(core, data_dir, "import telemetry; telemetry.record('search'); telemetry.record('flush')") + lines = (data_dir / "telemetry.jsonl").read_text(encoding="utf-8").strip().splitlines() + + ids = [json.loads(line)["uuid"] for line in lines] + assert len(ids) == 2 + assert len(set(ids)) == 2 + + +def test_source_tag_defaults_agree_between_the_two_modules(): + """configure_harness and telemetry.init must derive the same tag. + + They disagreed: `_plugin` in one and `MEM0__PLUGIN` in the other, + so one plugin could emit three different source values depending on which + process sent the batch. + """ + 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 memory_core, telemetry\n" + "memory_core.configure_harness('kimi')\n" + "telemetry.init(harness='kimi')\n" + "print(memory_core.harness_config()['source_tag'].upper(), telemetry._source_tag)", + ) + left, right = out.split() + assert left == right == "KIMI_PLUGIN" diff --git a/integrations/antigravity-plugin/core/_harness_id.py b/integrations/antigravity-plugin/core/_harness_id.py new file mode 100644 index 000000000..2c8a515f4 --- /dev/null +++ b/integrations/antigravity-plugin/core/_harness_id.py @@ -0,0 +1,9 @@ +"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit.""" + +HARNESS_ID = "antigravity" +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. +PLATFORM_SOURCE = "MEM0_PLUGIN" +PLATFORM_APPLICATION = "antigravity" diff --git a/integrations/antigravity-plugin/core/telemetry.py b/integrations/antigravity-plugin/core/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/antigravity-plugin/core/telemetry.py +++ b/integrations/antigravity-plugin/core/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False, diff --git a/integrations/claude-code-plugin/core/_harness_id.py b/integrations/claude-code-plugin/core/_harness_id.py new file mode 100644 index 000000000..6c3e1ce15 --- /dev/null +++ b/integrations/claude-code-plugin/core/_harness_id.py @@ -0,0 +1,9 @@ +"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit.""" + +HARNESS_ID = "claude-code" +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. +PLATFORM_SOURCE = "MEM0_PLUGIN" +PLATFORM_APPLICATION = "claude-code" diff --git a/integrations/claude-code-plugin/core/telemetry.py b/integrations/claude-code-plugin/core/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/claude-code-plugin/core/telemetry.py +++ b/integrations/claude-code-plugin/core/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False, diff --git a/integrations/codex-plugin/core/_harness_id.py b/integrations/codex-plugin/core/_harness_id.py new file mode 100644 index 000000000..152313a21 --- /dev/null +++ b/integrations/codex-plugin/core/_harness_id.py @@ -0,0 +1,9 @@ +"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit.""" + +HARNESS_ID = "codex" +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. +PLATFORM_SOURCE = "MEM0_PLUGIN" +PLATFORM_APPLICATION = "codex" diff --git a/integrations/codex-plugin/core/telemetry.py b/integrations/codex-plugin/core/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/codex-plugin/core/telemetry.py +++ b/integrations/codex-plugin/core/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False, diff --git a/integrations/cursor-plugin/core/_harness_id.py b/integrations/cursor-plugin/core/_harness_id.py new file mode 100644 index 000000000..40241a7f4 --- /dev/null +++ b/integrations/cursor-plugin/core/_harness_id.py @@ -0,0 +1,9 @@ +"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit.""" + +HARNESS_ID = "cursor" +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. +PLATFORM_SOURCE = "MEM0_PLUGIN" +PLATFORM_APPLICATION = "cursor" diff --git a/integrations/cursor-plugin/core/telemetry.py b/integrations/cursor-plugin/core/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/cursor-plugin/core/telemetry.py +++ b/integrations/cursor-plugin/core/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False, diff --git a/integrations/kimi-plugin/core/_harness_id.py b/integrations/kimi-plugin/core/_harness_id.py new file mode 100644 index 000000000..aa17750b1 --- /dev/null +++ b/integrations/kimi-plugin/core/_harness_id.py @@ -0,0 +1,9 @@ +"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit.""" + +HARNESS_ID = "kimi" +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. +PLATFORM_SOURCE = "MEM0_PLUGIN" +PLATFORM_APPLICATION = "kimi" diff --git a/integrations/kimi-plugin/core/telemetry.py b/integrations/kimi-plugin/core/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/kimi-plugin/core/telemetry.py +++ b/integrations/kimi-plugin/core/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False, diff --git a/integrations/mem0-agent-plugin/core/_harness_id.py b/integrations/mem0-agent-plugin/core/_harness_id.py new file mode 100644 index 000000000..b0836d6de --- /dev/null +++ b/integrations/mem0-agent-plugin/core/_harness_id.py @@ -0,0 +1,9 @@ +"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit.""" + +HARNESS_ID = "coding-agent" +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. +PLATFORM_SOURCE = "MEM0_PLUGIN" +PLATFORM_APPLICATION = "coding-agent" diff --git a/integrations/mem0-agent-plugin/core/telemetry.py b/integrations/mem0-agent-plugin/core/telemetry.py index 1b6bd854e..157db297d 100644 --- a/integrations/mem0-agent-plugin/core/telemetry.py +++ b/integrations/mem0-agent-plugin/core/telemetry.py @@ -34,8 +34,23 @@ from typing import Any import memory_core -_harness: str = "generic" -_source_tag: str = "MEM0_PLUGIN" +# Seeded from the per-host module the build generates into core/. Two processes +# in this pipeline never call init() — mcp_server.py, and the detached +# `python3 telemetry.py` sender that spawn_flush() starts — so a module default +# was what every one of their events got labelled with. +try: # pragma: no cover - absent only in the un-built shared source tree + from _harness_id import HARNESS_ID as _DEFAULT_HARNESS + from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION + from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE + from _harness_id import SOURCE_TAG as _DEFAULT_SOURCE_TAG +except ImportError: + _DEFAULT_HARNESS = "generic" + _DEFAULT_SOURCE_TAG = "MEM0_PLUGIN" + _PLATFORM_SOURCE = "MEM0_PLUGIN" + _PLATFORM_APPLICATION = "" + +_harness: str = _DEFAULT_HARNESS +_source_tag: str = _DEFAULT_SOURCE_TAG _PRIVATE_KEYS = { "apikey", "authorization", @@ -61,10 +76,19 @@ _PRIVATE_KEYS = { } -def init(harness: str = "generic", source_tag: str = "") -> None: +def init(harness: str = "", source_tag: str = "") -> None: + """Override the generated identity. Optional — core/_harness_id.py is the default. + + The fallback shape matches memory_core.configure_harness's (``_PLUGIN``). + It used to be ``MEM0__PLUGIN`` here and ``_plugin`` there, which + meant one plugin could emit three different source values depending on which + process happened to send the batch. + """ global _harness, _source_tag - _harness = harness - _source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN" + _harness = harness or _DEFAULT_HARNESS + _source_tag = source_tag or ( + f"{_harness.upper().replace('-', '_')}_PLUGIN" if harness else _DEFAULT_SOURCE_TAG + ) POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" @@ -200,8 +224,13 @@ def record( except OSError: pass properties = _safe_value(properties) + # Stamped in the RECORDING process, beside harness. `source` used to be + # read in the sending process from a module global, so whichever process + # drained the spool named every event in it. flush() spreads per-event + # properties last, so this now wins over any sender's default. properties.update( harness=_harness, + source=_source_tag, plugin_version=memory_core.PLUGIN_VERSION, os=sys.platform, python_version=platform.python_version(), @@ -213,6 +242,7 @@ def record( line = json.dumps( { "event": f"{EVENT_PREFIX}.{event}", + "uuid": str(uuid.uuid4()), "timestamp": memory_core.utc_now(), "properties": { key: value for key, value in properties.items() if value is not None @@ -398,6 +428,8 @@ def flush() -> int: "distinct_id": distinct_id, "timestamp": event.get("timestamp"), "properties": { + # Fallback only: events recorded by a build before source + # moved into record() have none of their own. "source": _source_tag, "language": "python", "$process_person_profile": False,