fix(mem0-plugin): accurate per-editor telemetry attribution + OpenCode telemetry (#5518)
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./integrations/mem0-plugin",
|
"source": "./integrations/mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./integrations/mem0-plugin",
|
"source": "./integrations/mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
"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",
|
"name": "mem0",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
|
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"version": "0.2.9",
|
"version": "0.2.10",
|
||||||
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
|
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"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.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to the `@mem0/opencode-plugin` will be documented in this file.
|
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
|
## 0.1.3 — File-context injection, session summaries & activity timeline
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { existsSync, readdirSync, cpSync, mkdirSync, readFileSync, writeFileSync
|
|||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
import { captureEvent } from "./telemetry";
|
||||||
|
|
||||||
async function getUserId(): Promise<string> {
|
async function getUserId(): Promise<string> {
|
||||||
if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID;
|
if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID;
|
||||||
@@ -368,6 +369,8 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
|||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureEvent("session_start", { memory_count: memoryCount }, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NUDGE_RE.test(safeText)) {
|
if (NUDGE_RE.test(safeText)) {
|
||||||
@@ -602,6 +605,17 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
|||||||
if (MEM0_MCP_RE.test(toolName)) {
|
if (MEM0_MCP_RE.test(toolName)) {
|
||||||
if (toolName.includes("add_memory")) stats.adds++;
|
if (toolName.includes("add_memory")) stats.adds++;
|
||||||
if (toolName.includes("search")) stats.searches++;
|
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) {
|
if (toolName === "bash" && toolOutput.length >= 50) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mem0/opencode-plugin",
|
"name": "@mem0/opencode-plugin",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions",
|
"description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions",
|
||||||
"main": "dist/index.js",
|
"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
|
"emitDeclarationOnly": true
|
||||||
},
|
},
|
||||||
"include": ["**/*"],
|
"include": ["**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
All notable changes to the Mem0 plugin will be documented in this file.
|
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
|
## 0.2.9 — File-context injection, session summaries & activity timeline
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${PLUGIN_ROOT}/scripts/block_memory_write.sh",
|
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/block_memory_write.sh",
|
||||||
"timeout": 3
|
"timeout": 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
|
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
|
||||||
"timeout": 3
|
"timeout": 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${PLUGIN_ROOT}/scripts/on_file_read.sh",
|
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_file_read.sh",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"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..."
|
"statusMessage": "Loading mem0 context..."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"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...",
|
"statusMessage": "Checking memory relevance...",
|
||||||
"timeout": 12
|
"timeout": 12
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"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
|
"timeout": 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
||||||
"timeout": 12
|
"timeout": 12
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${PLUGIN_ROOT}/scripts/on_stop.sh",
|
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_stop.sh",
|
||||||
"timeout": 30
|
"timeout": 30
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"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..."
|
"statusMessage": "Preparing pre-compaction summary..."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "mem0",
|
"id": "mem0",
|
||||||
"name": "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.",
|
"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" },
|
"author": { "name": "Mem0", "email": "support@mem0.ai" },
|
||||||
"publisher": "mem0ai",
|
"publisher": "mem0ai",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ set -uo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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)
|
# Run the shared tracker (output is ignored)
|
||||||
"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true
|
"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ set -uo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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 "")
|
TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [ -z "$TEXT" ]; then
|
if [ -z "$TEXT" ]; then
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ set -uo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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 "")
|
TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [ -z "$TEXT" ]; then
|
if [ -z "$TEXT" ]; then
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ if [ -n "$AGENT_ID" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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
|
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||||
|
|
||||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ set -uo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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 "")
|
TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [ -z "$TEXT" ]; then
|
if [ -z "$TEXT" ]; then
|
||||||
|
|||||||
@@ -26,16 +26,27 @@ import sys
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
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:
|
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:
|
with open(plugin_json) as f:
|
||||||
return json.load(f).get("version", "unknown")
|
return json.load(f).get("version", "unknown")
|
||||||
except (OSError, json.JSONDecodeError, KeyError):
|
except (OSError, json.JSONDecodeError, KeyError):
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
PLUGIN_VERSION = _load_plugin_version()
|
|
||||||
|
|
||||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||||
@@ -58,6 +69,11 @@ def _distinct_id() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def detect_platform() -> 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"):
|
if os.environ.get("PLUGIN_ROOT"):
|
||||||
return "codex"
|
return "codex"
|
||||||
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
|
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:
|
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
|
||||||
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
|
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
|
||||||
|
plat = detect_platform()
|
||||||
return {
|
return {
|
||||||
"api_key": POSTHOG_API_KEY,
|
"api_key": POSTHOG_API_KEY,
|
||||||
"distinct_id": _distinct_id(),
|
"distinct_id": _distinct_id(),
|
||||||
@@ -82,8 +99,8 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
|
|||||||
"properties": {
|
"properties": {
|
||||||
**(properties or {}),
|
**(properties or {}),
|
||||||
"source": "plugin",
|
"source": "plugin",
|
||||||
"platform": detect_platform(),
|
"platform": plat,
|
||||||
"plugin_version": PLUGIN_VERSION,
|
"plugin_version": _load_plugin_version(plat),
|
||||||
"project_hash": _sha256(project_id),
|
"project_hash": _sha256(project_id),
|
||||||
"os": sys.platform,
|
"os": sys.platform,
|
||||||
"os_version": platform.version(),
|
"os_version": platform.version(),
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def test_system_props_override_caller_props(monkeypatch):
|
|||||||
# System props must win
|
# System props must win
|
||||||
assert props["source"] == "plugin"
|
assert props["source"] == "plugin"
|
||||||
assert props["platform"] == telemetry.detect_platform()
|
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
|
# Caller-only props still present
|
||||||
assert props["memory_count"] == 42
|
assert props["memory_count"] == 42
|
||||||
|
|
||||||
@@ -125,25 +125,31 @@ def test_hash_deterministic():
|
|||||||
def test_platform_claude_code(monkeypatch):
|
def test_platform_claude_code(monkeypatch):
|
||||||
import telemetry
|
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("CURSOR_PLUGIN_ROOT", raising=False)
|
||||||
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
|
monkeypatch.setenv("CLAUDECODE", "1")
|
||||||
assert telemetry.detect_platform() == "claude-code"
|
assert telemetry.detect_platform() == "claude-code"
|
||||||
|
|
||||||
|
|
||||||
def test_platform_cursor(monkeypatch):
|
def test_platform_cursor(monkeypatch):
|
||||||
import telemetry
|
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("CLAUDECODE", raising=False)
|
||||||
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
||||||
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
|
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
|
||||||
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
|
|
||||||
assert telemetry.detect_platform() == "cursor"
|
assert telemetry.detect_platform() == "cursor"
|
||||||
|
|
||||||
|
|
||||||
def test_platform_codex(monkeypatch):
|
def test_platform_codex(monkeypatch):
|
||||||
import telemetry
|
import telemetry
|
||||||
|
|
||||||
|
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
|
||||||
|
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
|
||||||
monkeypatch.delenv("CLAUDECODE", raising=False)
|
monkeypatch.delenv("CLAUDECODE", raising=False)
|
||||||
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
|
||||||
monkeypatch.delenv("CURSOR_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"
|
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):
|
def test_send_fails_silently(monkeypatch):
|
||||||
import telemetry
|
import telemetry
|
||||||
|
|
||||||
@@ -175,3 +223,39 @@ def test_cli_no_args_exits_nonzero(monkeypatch):
|
|||||||
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
|
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
|
||||||
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
|
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
|
||||||
assert telemetry.main() == 1
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user