From f4773a0baf5b0e8f6f4511f86800e348817698bc Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 13 Jun 2026 16:48:29 +0530 Subject: [PATCH] fix(mem0-plugin): accurate per-editor telemetry attribution + OpenCode telemetry (#5518) --- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 2 +- .../mem0-plugin/.claude-plugin/plugin.json | 2 +- .../mem0-plugin/.codex-plugin/plugin.json | 2 +- .../mem0-plugin/.cursor-plugin/plugin.json | 2 +- .../mem0-plugin/.opencode-plugin/CHANGELOG.md | 6 + .../.opencode-plugin/opencode-mem0.ts | 14 +++ .../mem0-plugin/.opencode-plugin/package.json | 2 +- .../.opencode-plugin/telemetry.test.ts | 51 +++++++++ .../mem0-plugin/.opencode-plugin/telemetry.ts | 104 ++++++++++++++++++ .../.opencode-plugin/tsconfig.json | 2 +- integrations/mem0-plugin/CHANGELOG.md | 15 +++ .../mem0-plugin/hooks/codex-hooks.json | 18 +-- integrations/mem0-plugin/plugin.json | 2 +- .../scripts/on_post_tool_use_cursor.sh | 3 + .../scripts/on_pre_compact_cursor.sh | 3 + .../scripts/on_session_start_cursor.sh | 3 + .../mem0-plugin/scripts/on_stop_cursor.sh | 2 + .../scripts/on_user_prompt_cursor.sh | 3 + integrations/mem0-plugin/scripts/telemetry.py | 27 ++++- .../mem0-plugin/tests/test_telemetry.py | 92 +++++++++++++++- .../pi-agent-plugin/src/telemetry.test.ts | 56 ++++++++++ 22 files changed, 387 insertions(+), 26 deletions(-) create mode 100644 integrations/mem0-plugin/.opencode-plugin/telemetry.test.ts create mode 100644 integrations/mem0-plugin/.opencode-plugin/telemetry.ts create mode 100644 integrations/pi-agent-plugin/src/telemetry.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 847fc33e8..fdbe45f43 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./integrations/mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.9" + "version": "0.2.10" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 0aa700e77..0d559f6c7 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./integrations/mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", - "version": "0.2.9" + "version": "0.2.10" } ] } diff --git a/integrations/mem0-plugin/.claude-plugin/plugin.json b/integrations/mem0-plugin/.claude-plugin/plugin.json index 163731f33..0ab6583ee 100644 --- a/integrations/mem0-plugin/.claude-plugin/plugin.json +++ b/integrations/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.9", + "version": "0.2.10", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.codex-plugin/plugin.json b/integrations/mem0-plugin/.codex-plugin/plugin.json index 44aab8b6e..65c39271b 100644 --- a/integrations/mem0-plugin/.codex-plugin/plugin.json +++ b/integrations/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.9", + "version": "0.2.10", "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.cursor-plugin/plugin.json b/integrations/mem0-plugin/.cursor-plugin/plugin.json index d82f5e06e..fafbf6aa6 100644 --- a/integrations/mem0-plugin/.cursor-plugin/plugin.json +++ b/integrations/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.9", + "version": "0.2.10", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.opencode-plugin/CHANGELOG.md b/integrations/mem0-plugin/.opencode-plugin/CHANGELOG.md index 1b88159a7..4e3724046 100644 --- a/integrations/mem0-plugin/.opencode-plugin/CHANGELOG.md +++ b/integrations/mem0-plugin/.opencode-plugin/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the `@mem0/opencode-plugin` will be documented in this file. +## 0.1.4 — Anonymous usage telemetry + +### Added + +- **PostHog telemetry (`telemetry.ts`):** Anonymous, fire-and-forget usage events. Opt out with `MEM0_TELEMETRY=false`. Only fires when an API key is present; never sends memory content, prompts, or the API key — only an anonymized `sha256(apiKey)[:32]` identity plus event type, platform, and plugin version. Emits the same schema as the Mem0 editor plugin (`plugin.*` events, `source: "plugin"`, `platform: "opencode"`) so OpenCode appears as a `platform` in the shared plugin dashboard. Events: `plugin.session_start` (with memory count) and `plugin.tool_use` (`add` / `search` / `update` / `delete`). + ## 0.1.3 — File-context injection, session summaries & activity timeline ### Added diff --git a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts index e91add6a1..e9a4aa10c 100644 --- a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts +++ b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts @@ -9,6 +9,7 @@ import { existsSync, readdirSync, cpSync, mkdirSync, readFileSync, writeFileSync import { homedir } from "os"; import { join } from "path"; import { createHash } from "crypto"; +import { captureEvent } from "./telemetry"; async function getUserId(): Promise { if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID; @@ -368,6 +369,8 @@ const Mem0Plugin: Plugin = async (ctx) => { }); } catch {} } + + captureEvent("session_start", { memory_count: memoryCount }, apiKey); } if (NUDGE_RE.test(safeText)) { @@ -602,6 +605,17 @@ const Mem0Plugin: Plugin = async (ctx) => { if (MEM0_MCP_RE.test(toolName)) { if (toolName.includes("add_memory")) stats.adds++; if (toolName.includes("search")) stats.searches++; + + const tool = toolName.includes("add_memory") + ? "add_memory" + : toolName.includes("search") + ? "search_memories" + : toolName.includes("delete") + ? "delete_memory" + : toolName.includes("update") + ? "update_memory" + : "other"; + captureEvent("tool_use", { tool }, apiKey); } if (toolName === "bash" && toolOutput.length >= 50) { diff --git a/integrations/mem0-plugin/.opencode-plugin/package.json b/integrations/mem0-plugin/.opencode-plugin/package.json index f662ce4c4..02427d4c1 100644 --- a/integrations/mem0-plugin/.opencode-plugin/package.json +++ b/integrations/mem0-plugin/.opencode-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/opencode-plugin", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions", "main": "dist/index.js", diff --git a/integrations/mem0-plugin/.opencode-plugin/telemetry.test.ts b/integrations/mem0-plugin/.opencode-plugin/telemetry.test.ts new file mode 100644 index 000000000..9ef897a26 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/telemetry.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry"; + +const KEY = "m0-testkey123"; + +afterEach(() => { + delete process.env.MEM0_TELEMETRY; +}); + +describe("opencode telemetry", () => { + test("buildEvent uses the shared plugin.* schema with platform=opencode", () => { + const payload = buildEvent("session_start", { memory_count: 5 }, KEY); + expect(payload).not.toBeNull(); + const props = payload!.properties as Record; + expect(payload!.event).toBe("plugin.session_start"); + expect(props.source).toBe("plugin"); + expect(props.platform).toBe("opencode"); + expect(props.memory_count).toBe(5); + expect(props.$process_person_profile).toBe(false); + expect(typeof props.plugin_version).toBe("string"); + }); + + test("distinct_id is sha256(apiKey)[:32] — matches the editor plugin", async () => { + const { createHash } = await import("node:crypto"); + const expected = createHash("sha256").update(KEY).digest("hex").slice(0, 32); + expect(buildEvent("session_start", {}, KEY)!.distinct_id).toBe(expected); + }); + + test("system properties win over caller-supplied ones", () => { + const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)! + .properties as Record; + expect(props.platform).toBe("opencode"); + expect(props.source).toBe("plugin"); + }); + + test("returns null without an API key (no anonymous events)", () => { + expect(buildEvent("session_start", {}, undefined)).toBeNull(); + }); + + test("opt-out via MEM0_TELEMETRY disables events", () => { + process.env.MEM0_TELEMETRY = "false"; + expect(isTelemetryEnabled()).toBe(false); + expect(buildEvent("session_start", {}, KEY)).toBeNull(); + }); + + test("captureEvent never throws (and sends nothing when opted out)", () => { + process.env.MEM0_TELEMETRY = "false"; + expect(() => captureEvent("session_start", {}, KEY)).not.toThrow(); + expect(() => captureEvent("session_start", {}, undefined)).not.toThrow(); + }); +}); diff --git a/integrations/mem0-plugin/.opencode-plugin/telemetry.ts b/integrations/mem0-plugin/.opencode-plugin/telemetry.ts new file mode 100644 index 000000000..243a4cc71 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/telemetry.ts @@ -0,0 +1,104 @@ +/** + * Plugin telemetry for the Mem0 OpenCode plugin — anonymous usage tracking + * via PostHog. + * + * Emits the SAME event schema as the Mem0 editor plugin's telemetry.py + * (event names prefixed `plugin.`, `source: "plugin"`, `platform: "opencode"`, + * `distinct_id = sha256(apiKey)[:32]`) so OpenCode shows up as just another + * `platform` value in the shared plugin dashboard instead of a separate + * event namespace. + * + * Fire-and-forget: never throws, never blocks, failures are swallowed. Only + * fires when an API key is present (same as the editor plugin — anonymous + * installs without a key emit nothing). Disable with MEM0_TELEMETRY=false. + * + * Never sends: memory content, API keys, raw user/project IDs. Only sends: + * event type, platform, plugin version, anonymized hash of the API key. + */ + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; +const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"; +const REQUEST_TIMEOUT_MS = 2_000; + +function _loadPluginVersion(): string { + // Source context: telemetry.ts sits next to package.json (./). + // Bundled context: dist/index.js sits one level below it (../). + for (const rel of ["./package.json", "../package.json"]) { + try { + const pkg = JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf-8")); + if (pkg?.name === "@mem0/opencode-plugin" && pkg.version) return pkg.version; + } catch { + /* try next candidate */ + } + } + return "unknown"; +} + +const PLUGIN_VERSION = _loadPluginVersion(); + +export function isTelemetryEnabled(): boolean { + const val = process.env.MEM0_TELEMETRY; + if (val === undefined) return true; + const s = val.toLowerCase(); + return s !== "false" && s !== "0" && s !== "no" && s !== "off"; +} + +function distinctId(apiKey: string): string { + // Matches telemetry.py `_distinct_id()` so the same user is one person in + // PostHog whether they use OpenCode or any other Mem0 editor plugin. + return createHash("sha256").update(apiKey).digest("hex").slice(0, 32); +} + +/** + * Build the PostHog event payload, or null when telemetry is disabled or no + * API key is available. Pure (aside from env/version reads) and exported for + * testing. System-controlled properties are applied last so a caller cannot + * override `source`/`platform`/etc. + */ +export function buildEvent( + eventType: string, + properties: Record, + apiKey: string | undefined, +): Record | null { + if (!isTelemetryEnabled() || !apiKey) return null; + return { + api_key: POSTHOG_API_KEY, + distinct_id: distinctId(apiKey), + event: `plugin.${eventType}`, + properties: { + ...properties, + source: "plugin", + platform: "opencode", + plugin_version: PLUGIN_VERSION, + os: process.platform, + sample_rate: 1.0, + $process_person_profile: false, + $lib: "posthog-node", + }, + }; +} + +/** Send a usage event, fire-and-forget. Never throws, never blocks. */ +export function captureEvent( + eventType: string, + properties: Record, + apiKey: string | undefined, +): void { + const payload = buildEvent(eventType, properties, apiKey); + if (!payload) return; + try { + void fetch(POSTHOG_HOST, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).catch(() => { + /* fire-and-forget */ + }); + } catch { + /* never throw */ + } +} diff --git a/integrations/mem0-plugin/.opencode-plugin/tsconfig.json b/integrations/mem0-plugin/.opencode-plugin/tsconfig.json index 97d3c9347..a635ca045 100644 --- a/integrations/mem0-plugin/.opencode-plugin/tsconfig.json +++ b/integrations/mem0-plugin/.opencode-plugin/tsconfig.json @@ -16,5 +16,5 @@ "emitDeclarationOnly": true }, "include": ["**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/integrations/mem0-plugin/CHANGELOG.md b/integrations/mem0-plugin/CHANGELOG.md index 4e6bdc907..e8712b798 100644 --- a/integrations/mem0-plugin/CHANGELOG.md +++ b/integrations/mem0-plugin/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to the Mem0 plugin will be documented in this file. +## 0.2.10 — Accurate per-editor telemetry attribution + +### Fixed + +- **Antigravity counted as Claude Code:** `detect_platform()` (`scripts/telemetry.py`) now checks `ANTIGRAVITY_PLUGIN_ROOT` before the `CLAUDE_PLUGIN_ROOT` branch. Antigravity sets both env vars for compatibility, so every Antigravity session was previously attributed to `claude-code`. Telemetry now reports `platform: "antigravity"`. +- **Codex fell back to the generic `plugin` bucket:** Codex installs standalone hooks with absolute paths via `install_codex_hooks.py`, so `PLUGIN_ROOT` is never set at runtime and platform auto-detection failed. Each command in `hooks/codex-hooks.json` now pins `MEM0_PLATFORM=codex` inline (Codex runs hook commands through a shell). Telemetry now reports `platform: "codex"`. +- **Cursor attribution depended on the host env:** Cursor's `*_cursor.sh` wrappers delegate to the shared hook scripts, whose platform detection relied on Cursor exporting `CURSOR_PLUGIN_ROOT` to the subprocess. All five Cursor wrappers now `export MEM0_PLATFORM=cursor` before delegating. +- **`plugin_version` was identical for every editor:** telemetry read `.claude-plugin/plugin.json` for all bash-hook editors, so Antigravity reported `0.2.10` instead of its real `0.1.2`. `_load_plugin_version()` now reads the manifest matching the detected platform, so each editor reports its own version. + +### Added + +- **`MEM0_PLATFORM` override in `detect_platform()`:** An explicit platform marker that wins over env-var auto-detection, letting each editor label its telemetry reliably. New tests in `tests/test_telemetry.py` cover the override, Antigravity attribution, and the Cursor/Codex platform-pinning contracts. + +> Attribution fixes apply to telemetry emitted after users upgrade to this version; PostHog does not backfill past events. + ## 0.2.9 — File-context injection, session summaries & activity timeline ### Added diff --git a/integrations/mem0-plugin/hooks/codex-hooks.json b/integrations/mem0-plugin/hooks/codex-hooks.json index 383006e88..7e0cb002f 100644 --- a/integrations/mem0-plugin/hooks/codex-hooks.json +++ b/integrations/mem0-plugin/hooks/codex-hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/block_memory_write.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/block_memory_write.sh", "timeout": 3 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh", "timeout": 3 } ] @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_file_read.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_file_read.sh", "timeout": 5 } ] @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_session_start.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_session_start.sh", "statusMessage": "Loading mem0 context..." } ] @@ -49,7 +49,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_user_prompt.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_user_prompt.sh", "statusMessage": "Checking memory relevance...", "timeout": 12 } @@ -62,7 +62,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_post_tool_use.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_post_tool_use.sh", "timeout": 3 } ] @@ -72,7 +72,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_bash_output.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_bash_output.sh", "timeout": 12 } ] @@ -83,7 +83,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_stop.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_stop.sh", "timeout": 30 } ] @@ -94,7 +94,7 @@ "hooks": [ { "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_pre_compact.sh", + "command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_pre_compact.sh", "statusMessage": "Preparing pre-compaction summary..." } ] diff --git a/integrations/mem0-plugin/plugin.json b/integrations/mem0-plugin/plugin.json index 0521b9ad0..ef931be88 100644 --- a/integrations/mem0-plugin/plugin.json +++ b/integrations/mem0-plugin/plugin.json @@ -1,7 +1,7 @@ { "id": "mem0", "name": "mem0", - "version": "0.1.1", + "version": "0.1.2", "description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.", "author": { "name": "Mem0", "email": "support@mem0.ai" }, "publisher": "mem0ai", diff --git a/integrations/mem0-plugin/scripts/on_post_tool_use_cursor.sh b/integrations/mem0-plugin/scripts/on_post_tool_use_cursor.sh index b0a5ca4d1..bcfb39ace 100755 --- a/integrations/mem0-plugin/scripts/on_post_tool_use_cursor.sh +++ b/integrations/mem0-plugin/scripts/on_post_tool_use_cursor.sh @@ -8,6 +8,9 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Pin platform so the shared script's telemetry is attributed to cursor. +export MEM0_PLATFORM=cursor + # Run the shared tracker (output is ignored) "$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true diff --git a/integrations/mem0-plugin/scripts/on_pre_compact_cursor.sh b/integrations/mem0-plugin/scripts/on_pre_compact_cursor.sh index 74cf77a14..ca5a5a8e2 100755 --- a/integrations/mem0-plugin/scripts/on_pre_compact_cursor.sh +++ b/integrations/mem0-plugin/scripts/on_pre_compact_cursor.sh @@ -8,6 +8,9 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Pin platform so the shared script's telemetry is attributed to cursor. +export MEM0_PLATFORM=cursor + TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "") if [ -z "$TEXT" ]; then diff --git a/integrations/mem0-plugin/scripts/on_session_start_cursor.sh b/integrations/mem0-plugin/scripts/on_session_start_cursor.sh index 46ffb8006..905a5fb5b 100755 --- a/integrations/mem0-plugin/scripts/on_session_start_cursor.sh +++ b/integrations/mem0-plugin/scripts/on_session_start_cursor.sh @@ -8,6 +8,9 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Pin platform so the shared script's telemetry is attributed to cursor. +export MEM0_PLATFORM=cursor + TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "") if [ -z "$TEXT" ]; then diff --git a/integrations/mem0-plugin/scripts/on_stop_cursor.sh b/integrations/mem0-plugin/scripts/on_stop_cursor.sh index ac24c0253..77a8cd0a4 100755 --- a/integrations/mem0-plugin/scripts/on_stop_cursor.sh +++ b/integrations/mem0-plugin/scripts/on_stop_cursor.sh @@ -18,6 +18,8 @@ if [ -n "$AGENT_ID" ]; then fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Pin platform so this hook's telemetry is attributed to cursor. +export MEM0_PLATFORM=cursor . "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true if [ -z "${MEM0_API_KEY:-}" ]; then diff --git a/integrations/mem0-plugin/scripts/on_user_prompt_cursor.sh b/integrations/mem0-plugin/scripts/on_user_prompt_cursor.sh index bb56c52c6..17a82a7f2 100755 --- a/integrations/mem0-plugin/scripts/on_user_prompt_cursor.sh +++ b/integrations/mem0-plugin/scripts/on_user_prompt_cursor.sh @@ -8,6 +8,9 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Pin platform so the shared script's telemetry is attributed to cursor. +export MEM0_PLATFORM=cursor + TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "") if [ -z "$TEXT" ]; then diff --git a/integrations/mem0-plugin/scripts/telemetry.py b/integrations/mem0-plugin/scripts/telemetry.py index 62948c587..afebcb67b 100644 --- a/integrations/mem0-plugin/scripts/telemetry.py +++ b/integrations/mem0-plugin/scripts/telemetry.py @@ -26,16 +26,27 @@ import sys import urllib.error import urllib.request +# Each editor surface ships its own manifest with its own version line +# (Antigravity is on 0.1.x while Claude/Cursor/Codex are on 0.2.x), so we read +# the manifest matching the detected platform rather than a single shared one. +_PLATFORM_MANIFESTS = { + "antigravity": ("..", "plugin.json"), + "claude-code": ("..", ".claude-plugin", "plugin.json"), + "cursor": ("..", ".cursor-plugin", "plugin.json"), + "codex": ("..", ".codex-plugin", "plugin.json"), +} +_DEFAULT_MANIFEST = ("..", ".claude-plugin", "plugin.json") -def _load_plugin_version() -> str: + +def _load_plugin_version(platform_name: str = "") -> str: + parts = _PLATFORM_MANIFESTS.get(platform_name, _DEFAULT_MANIFEST) try: - plugin_json = os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json") + plugin_json = os.path.join(os.path.dirname(__file__), *parts) with open(plugin_json) as f: return json.load(f).get("version", "unknown") except (OSError, json.JSONDecodeError, KeyError): return "unknown" -PLUGIN_VERSION = _load_plugin_version() POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/" @@ -58,6 +69,11 @@ def _distinct_id() -> str: def detect_platform() -> str: + explicit = os.environ.get("MEM0_PLATFORM") + if explicit: + return explicit + if os.environ.get("ANTIGRAVITY_PLUGIN_ROOT"): + return "antigravity" if os.environ.get("PLUGIN_ROOT"): return "codex" if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"): @@ -75,6 +91,7 @@ def is_enabled() -> bool: def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict: project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown" + plat = detect_platform() return { "api_key": POSTHOG_API_KEY, "distinct_id": _distinct_id(), @@ -82,8 +99,8 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di "properties": { **(properties or {}), "source": "plugin", - "platform": detect_platform(), - "plugin_version": PLUGIN_VERSION, + "platform": plat, + "plugin_version": _load_plugin_version(plat), "project_hash": _sha256(project_id), "os": sys.platform, "os_version": platform.version(), diff --git a/integrations/mem0-plugin/tests/test_telemetry.py b/integrations/mem0-plugin/tests/test_telemetry.py index c0a75a21a..1c15a8158 100644 --- a/integrations/mem0-plugin/tests/test_telemetry.py +++ b/integrations/mem0-plugin/tests/test_telemetry.py @@ -89,7 +89,7 @@ def test_system_props_override_caller_props(monkeypatch): # System props must win assert props["source"] == "plugin" assert props["platform"] == telemetry.detect_platform() - assert props["plugin_version"] == telemetry.PLUGIN_VERSION + assert props["plugin_version"] == telemetry._load_plugin_version(telemetry.detect_platform()) # Caller-only props still present assert props["memory_count"] == 42 @@ -125,25 +125,31 @@ def test_hash_deterministic(): def test_platform_claude_code(monkeypatch): import telemetry - monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.delenv("MEM0_PLATFORM", raising=False) + monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("PLUGIN_ROOT", raising=False) monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) - monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False) + monkeypatch.setenv("CLAUDECODE", "1") assert telemetry.detect_platform() == "claude-code" def test_platform_cursor(monkeypatch): import telemetry + monkeypatch.delenv("MEM0_PLATFORM", raising=False) + monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("PLUGIN_ROOT", raising=False) monkeypatch.delenv("CLAUDECODE", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path") - monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False) assert telemetry.detect_platform() == "cursor" def test_platform_codex(monkeypatch): import telemetry + monkeypatch.delenv("MEM0_PLATFORM", raising=False) + monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) monkeypatch.delenv("CLAUDECODE", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) @@ -151,6 +157,48 @@ def test_platform_codex(monkeypatch): assert telemetry.detect_platform() == "codex" +def test_platform_explicit_override(monkeypatch): + """MEM0_PLATFORM wins over auto-detection so each editor can label + itself reliably even when host env vars are ambiguous or absent.""" + import telemetry + + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "/path") # conflicting auto-signal + monkeypatch.setenv("MEM0_PLATFORM", "cursor") + assert telemetry.detect_platform() == "cursor" + + +def test_platform_antigravity(monkeypatch): + """Antigravity sets CLAUDE_PLUGIN_ROOT for compatibility but must be + attributed to its own platform, not claude-code.""" + import telemetry + + monkeypatch.delenv("MEM0_PLATFORM", raising=False) + monkeypatch.setenv("ANTIGRAVITY_PLUGIN_ROOT", "/ext") + monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "/ext") # antigravity sets both + assert telemetry.detect_platform() == "antigravity" + + +def test_plugin_version_is_per_editor(monkeypatch): + """Each editor reports the version from its OWN manifest. Antigravity is on + a 0.1.x line while Claude/Cursor/Codex are on 0.2.x, so they must not all + report the same shared version.""" + import telemetry + + plugin_dir = os.path.join(os.path.dirname(__file__), "..") + manifests = { + "antigravity": "plugin.json", + "claude-code": os.path.join(".claude-plugin", "plugin.json"), + "cursor": os.path.join(".cursor-plugin", "plugin.json"), + "codex": os.path.join(".codex-plugin", "plugin.json"), + } + for plat, rel in manifests.items(): + monkeypatch.setenv("MEM0_PLATFORM", plat) + with open(os.path.join(plugin_dir, rel)) as f: + expected = json.load(f)["version"] + payload = telemetry.build_posthog_payload("plugin.test") + assert payload["properties"]["plugin_version"] == expected, f"{plat} should report {expected} from {rel}" + + def test_send_fails_silently(monkeypatch): import telemetry @@ -175,3 +223,39 @@ def test_cli_no_args_exits_nonzero(monkeypatch): monkeypatch.delenv("MEM0_TELEMETRY", raising=False) monkeypatch.setattr(sys, "argv", ["telemetry.py"]) assert telemetry.main() == 1 + + +def test_cursor_wrappers_pin_platform(): + """Cursor wrappers delegate to the shared scripts, which auto-detect the + platform from host env vars. Since Cursor may not export CURSOR_PLUGIN_ROOT + to the subprocess, each wrapper must pin MEM0_PLATFORM=cursor so the + delegated telemetry is attributed to cursor, not the 'plugin' fallback.""" + scripts_dir = os.path.join(os.path.dirname(__file__), "..", "scripts") + cursor_wrappers = [ + "on_session_start_cursor.sh", + "on_user_prompt_cursor.sh", + "on_post_tool_use_cursor.sh", + "on_pre_compact_cursor.sh", + "on_stop_cursor.sh", + ] + for name in cursor_wrappers: + with open(os.path.join(scripts_dir, name)) as f: + content = f.read() + assert "export MEM0_PLATFORM=cursor" in content, f"{name} must `export MEM0_PLATFORM=cursor` before delegating" + + +def test_codex_hooks_pin_platform(): + """Codex installs standalone hooks (absolute paths) via install_codex_hooks.py, + so PLUGIN_ROOT is not set at runtime and the platform falls back to 'plugin'. + Codex runs hook commands through a shell, so every command pins + MEM0_PLATFORM=codex inline for correct attribution.""" + hooks_path = os.path.join(os.path.dirname(__file__), "..", "hooks", "codex-hooks.json") + with open(hooks_path) as f: + config = json.load(f) + + commands = [ + h["command"] for entries in config["hooks"].values() for entry in entries for h in entry.get("hooks", []) + ] + assert commands, "expected at least one codex hook command" + for cmd in commands: + assert "MEM0_PLATFORM=codex" in cmd, f"codex hook command missing platform pin: {cmd}" diff --git a/integrations/pi-agent-plugin/src/telemetry.test.ts b/integrations/pi-agent-plugin/src/telemetry.test.ts new file mode 100644 index 000000000..514dfbf74 --- /dev/null +++ b/integrations/pi-agent-plugin/src/telemetry.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + _getEventQueue, + _resetForTesting, + captureCommandEvent, + captureEvent, + captureToolEvent, +} from "./telemetry.ts"; + +const CTX = { apiKey: "m0-testkey123" }; + +function findEvent(name: string): Record | undefined { + return _getEventQueue().find((e) => (e as Record).event === name) as + | Record + | undefined; +} + +beforeEach(() => { + delete process.env.MEM0_TELEMETRY; + _resetForTesting(); +}); + +afterEach(() => { + delete process.env.MEM0_TELEMETRY; + _resetForTesting(); +}); + +describe("pi-agent telemetry", () => { + it.each(["add", "search", "update", "delete"])( + "captureToolEvent tracks the %s operation as pi.tool.mem0_memory", + (action) => { + captureToolEvent(action, { success: true }, CTX); + const ev = findEvent("pi.tool.mem0_memory"); + expect(ev).toBeDefined(); + const props = ev!.properties as Record; + expect(props.action).toBe(action); + expect(props.success).toBe(true); + expect(props.source).toBe("PI_AGENT_PLUGIN"); + expect(props.$process_person_profile).toBe(false); + }, + ); + + it("captureCommandEvent emits a namespaced pi.command.* event", () => { + captureCommandEvent("mem0-search", { result_count: 3 }, CTX); + const ev = findEvent("pi.command.mem0-search"); + expect(ev).toBeDefined(); + expect((ev!.properties as Record).result_count).toBe(3); + }); + + it("respects the MEM0_TELEMETRY opt-out", () => { + process.env.MEM0_TELEMETRY = "false"; + captureEvent("pi.session.start", {}, CTX); + captureToolEvent("add", {}, CTX); + expect(_getEventQueue()).toHaveLength(0); + }); +});