fix(mem0-plugin): accurate per-editor telemetry attribution + OpenCode telemetry (#5518)

This commit is contained in:
Kartik
2026-06-13 16:48:29 +05:30
committed by GitHub
parent 06d33f6cc4
commit f4773a0baf
22 changed files with 387 additions and 26 deletions
+1 -1
View File
@@ -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"
}
]
}
+1 -1
View File
@@ -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"
}
]
}
@@ -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",
@@ -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",
@@ -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",
@@ -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
@@ -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<string> {
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) {
@@ -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",
@@ -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<string, unknown>;
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<string, unknown>;
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();
});
});
@@ -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<string, unknown>,
apiKey: string | undefined,
): Record<string, unknown> | 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<string, unknown>,
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 */
}
}
@@ -16,5 +16,5 @@
"emitDeclarationOnly": true
},
"include": ["**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+15
View File
@@ -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
@@ -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..."
}
]
+1 -1
View File
@@ -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",
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+22 -5
View File
@@ -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(),
@@ -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}"
@@ -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<string, unknown> | undefined {
return _getEventQueue().find((e) => (e as Record<string, unknown>).event === name) as
| Record<string, unknown>
| 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<string, unknown>;
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<string, unknown>).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);
});
});