diff --git a/cli/node/package.json b/cli/node/package.json index b3900d5a4..15a9643d6 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/cli", - "version": "0.2.2", + "version": "0.2.3", "description": "The official CLI for mem0 — the memory layer for AI agents", "type": "module", "bin": { diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index bb7ddccae..7ac121948 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -116,6 +116,7 @@ export class PlatformBackend implements Backend { if (opts.expires) payload.expiration_date = opts.expires; if (opts.categories) payload.categories = opts.categories; if (opts.enableGraph) payload.enable_graph = true; + payload.source = "CLI"; return (await this._request("POST", "/v1/memories/", { json: payload, @@ -176,6 +177,7 @@ export class PlatformBackend implements Backend { if (opts.keyword) payload.keyword_search = true; if (opts.fields) payload.fields = opts.fields; if (opts.enableGraph) payload.enable_graph = true; + payload.source = "CLI"; const result = (await this._request("POST", "/v2/memories/search/", { json: payload, @@ -186,10 +188,9 @@ export class PlatformBackend implements Backend { } async get(memoryId: string): Promise> { - return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record< - string, - unknown - >; + return (await this._request("GET", `/v1/memories/${memoryId}/`, { + params: { source: "CLI" }, + })) as Record; } async listMemories( @@ -227,6 +228,7 @@ export class PlatformBackend implements Backend { }); if (apiFilters) payload.filters = apiFilters; if (opts.enableGraph) payload.enable_graph = true; + payload.source = "CLI"; const result = (await this._request("POST", "/v2/memories/", { json: payload, @@ -245,6 +247,7 @@ export class PlatformBackend implements Backend { const payload: Record = {}; if (content) payload.text = content; if (metadata) payload.metadata = metadata; + payload.source = "CLI"; return (await this._request("PUT", `/v1/memories/${memoryId}/`, { json: payload, })) as Record; @@ -255,7 +258,7 @@ export class PlatformBackend implements Backend { opts: DeleteOptions = {}, ): Promise> { if (opts.all) { - const params: Record = {}; + const params: Record = { source: "CLI" }; if (opts.userId) params.user_id = opts.userId; if (opts.agentId) params.agent_id = opts.agentId; if (opts.appId) params.app_id = opts.appId; @@ -265,10 +268,9 @@ export class PlatformBackend implements Backend { })) as Record; } if (memoryId) { - return (await this._request( - "DELETE", - `/v1/memories/${memoryId}/`, - )) as Record; + return (await this._request("DELETE", `/v1/memories/${memoryId}/`, { + params: { source: "CLI" }, + })) as Record; } throw new Error("Either memoryId or --all is required"); } @@ -291,6 +293,7 @@ export class PlatformBackend implements Backend { result = (await this._request( "DELETE", `/v2/entities/${entityType}/${entityId}/`, + { params: { source: "CLI" } }, )) as Record; } return result; diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts index d69576729..8cbd8d0d4 100644 --- a/cli/node/src/config.ts +++ b/cli/node/src/config.ts @@ -31,10 +31,15 @@ export interface DefaultsConfig { enableGraph: boolean; } +export interface TelemetryConfig { + anonymousId: string; +} + export interface Mem0Config { version: number; defaults: DefaultsConfig; platform: PlatformConfig; + telemetry: TelemetryConfig; } export function createDefaultConfig(): Mem0Config { @@ -52,6 +57,9 @@ export function createDefaultConfig(): Mem0Config { baseUrl: DEFAULT_BASE_URL, userEmail: "", }, + telemetry: { + anonymousId: "", + }, }; } @@ -80,6 +88,9 @@ export function loadConfig(): Mem0Config { config.defaults.appId = defaults.app_id ?? ""; config.defaults.runId = defaults.run_id ?? ""; config.defaults.enableGraph = defaults.enable_graph ?? false; + + const telemetry = data.telemetry ?? {}; + config.telemetry.anonymousId = telemetry.anonymous_id ?? ""; } // Environment variable overrides @@ -119,6 +130,9 @@ export function saveConfig(config: Mem0Config): void { base_url: config.platform.baseUrl, user_email: config.platform.userEmail, }, + telemetry: { + anonymous_id: config.telemetry.anonymousId, + }, }; fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); diff --git a/cli/node/src/telemetry.ts b/cli/node/src/telemetry.ts index c5680bf1d..5828c759f 100644 --- a/cli/node/src/telemetry.ts +++ b/cli/node/src/telemetry.ts @@ -9,10 +9,10 @@ */ import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { CONFIG_FILE, loadConfig } from "./config.js"; +import { CONFIG_FILE, loadConfig, saveConfig } from "./config.js"; import { CLI_VERSION } from "./version.js"; const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; @@ -29,11 +29,34 @@ function isTelemetryEnabled(): boolean { } } +/** + * Return a persistent per-machine anonymous ID, generating one if needed. + * + * Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that + * repeat runs on the same machine share one PostHog identity instead of + * collapsing into a single shared fallback string. + */ +function getOrCreateAnonymousId(): string { + const config = loadConfig(); + if (config.telemetry.anonymousId) { + return config.telemetry.anonymousId; + } + + const newId = `cli-anon-${randomUUID().replace(/-/g, "")}`; + config.telemetry.anonymousId = newId; + try { + saveConfig(config); + } catch { + /* ignore persistence failure — still return the generated ID */ + } + return newId; +} + /** * Return a stable anonymous identifier for the current user. * - * Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback. - * Matches the SDK pattern in mem0-ts/src/client/mem0.ts. + * Priority: cached user_email (from /v1/ping/) > MD5(api_key) > + * persistent per-machine anonymous ID. */ function getDistinctId(): string { try { @@ -47,7 +70,11 @@ function getDistinctId(): string { } catch { /* ignore */ } - return "anonymous-cli"; + try { + return getOrCreateAnonymousId(); + } catch { + return `cli-anon-${randomUUID().replace(/-/g, "")}`; + } } /** @@ -69,6 +96,25 @@ export function captureEvent( const config = loadConfig(); const distinctId = preResolvedEmail || getDistinctId(); + // Detect anonymous → identified transition. If a stored anonymous_id + // exists and we just resolved to a real identity, fire a one-shot + // $identify event so PostHog stitches the pre-signup history onto + // the authenticated profile. Clear the stored id so we don't re-alias. + let anonIdToAlias: string | null = null; + if ( + distinctId && + !distinctId.startsWith("cli-anon-") && + config.telemetry.anonymousId + ) { + anonIdToAlias = config.telemetry.anonymousId; + config.telemetry.anonymousId = ""; + try { + saveConfig(config); + } catch { + /* ignore — alias may double-fire next run, harmless */ + } + } + const payload = { api_key: POSTHOG_API_KEY, distinct_id: distinctId, @@ -92,6 +138,7 @@ export function captureEvent( mem0ApiKey: config.platform.apiKey || "", mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai", configPath: CONFIG_FILE, + anonDistinctIdToAlias: anonIdToAlias, }; const child = spawn( diff --git a/cli/node/telemetry-sender.cjs b/cli/node/telemetry-sender.cjs index e18b2a57d..0343362d6 100644 --- a/cli/node/telemetry-sender.cjs +++ b/cli/node/telemetry-sender.cjs @@ -94,6 +94,19 @@ async function sendPosthogEvent(posthogHost, payload) { } } +async function sendIdentifyEvent(ctx, payload, anonId) { + const identifyPayload = { + api_key: payload.api_key, + event: "$identify", + distinct_id: payload.distinct_id, + properties: { + $anon_distinct_id: anonId, + $lib: (payload.properties && payload.properties.$lib) || "posthog-node", + }, + }; + await sendPosthogEvent(ctx.posthogHost, identifyPayload); +} + async function main() { const ctx = JSON.parse(process.argv[2]); const payload = ctx.payload; @@ -102,6 +115,14 @@ async function main() { await resolveAndCacheEmail(ctx, payload); } + // Fire $identify *after* email resolution so PostHog links the stored + // anonymous id directly to the final identity (email, not the api-key + // hash). The regular event is sent next so it lands under the merged + // profile. + if (ctx.anonDistinctIdToAlias) { + await sendIdentifyEvent(ctx, payload, ctx.anonDistinctIdToAlias); + } + await sendPosthogEvent(ctx.posthogHost, payload); } diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml index fc1090943..7523d5506 100644 --- a/cli/python/pyproject.toml +++ b/cli/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0-cli" -version = "0.2.2" +version = "0.2.3" description = "The official CLI for mem0 — the memory layer for AI agents" readme = "README.md" license = "Apache-2.0" diff --git a/cli/python/src/mem0_cli/__init__.py b/cli/python/src/mem0_cli/__init__.py index 8d0c4e6aa..33fa6b345 100644 --- a/cli/python/src/mem0_cli/__init__.py +++ b/cli/python/src/mem0_cli/__init__.py @@ -1,3 +1,3 @@ """mem0 CLI — the command-line interface for the mem0 memory layer.""" -__version__ = "0.2.2" +__version__ = "0.2.3" diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 9224412e8..9da6a2b58 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -93,6 +93,7 @@ class PlatformBackend(Backend): payload["categories"] = categories if enable_graph: payload["enable_graph"] = True + payload["source"] = "CLI" return self._request("POST", "/v1/memories/", json=payload) @@ -172,6 +173,7 @@ class PlatformBackend(Backend): payload["fields"] = fields if enable_graph: payload["enable_graph"] = True + payload["source"] = "CLI" result = self._request("POST", "/v2/memories/search/", json=payload) return ( @@ -181,7 +183,7 @@ class PlatformBackend(Backend): ) def get(self, memory_id: str) -> dict: - return self._request("GET", f"/v1/memories/{memory_id}/") + return self._request("GET", f"/v1/memories/{memory_id}/", params={"source": "CLI"}) def list_memories( self, @@ -220,6 +222,7 @@ class PlatformBackend(Backend): payload["filters"] = api_filters if enable_graph: payload["enable_graph"] = True + payload["source"] = "CLI" result = self._request("POST", "/v2/memories/", json=payload, params=params) return ( @@ -236,6 +239,7 @@ class PlatformBackend(Backend): payload["text"] = content if metadata: payload["metadata"] = metadata + payload["source"] = "CLI" return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload) def delete( @@ -249,7 +253,7 @@ class PlatformBackend(Backend): run_id: str | None = None, ) -> dict: if all: - params: dict[str, str] = {} + params: dict[str, str] = {"source": "CLI"} if user_id: params["user_id"] = user_id if agent_id: @@ -260,7 +264,7 @@ class PlatformBackend(Backend): params["run_id"] = run_id return self._request("DELETE", "/v1/memories/", params=params) elif memory_id: - return self._request("DELETE", f"/v1/memories/{memory_id}/") + return self._request("DELETE", f"/v1/memories/{memory_id}/", params={"source": "CLI"}) else: raise ValueError("Either memory_id or --all is required") @@ -285,7 +289,9 @@ class PlatformBackend(Backend): # Delete each provided entity via the v2 path-based endpoint result: dict = {} for entity_type, entity_id in entities.items(): - result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/") + result = self._request( + "DELETE", f"/v2/entities/{entity_type}/{entity_id}/", params={"source": "CLI"} + ) return result def ping(self, timeout: float | None = None) -> dict: diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py index 2d5949bd0..88a804f2c 100644 --- a/cli/python/src/mem0_cli/config.py +++ b/cli/python/src/mem0_cli/config.py @@ -39,11 +39,17 @@ class DefaultsConfig: enable_graph: bool = False +@dataclass +class TelemetryConfig: + anonymous_id: str = "" + + @dataclass class Mem0Config: version: int = CONFIG_VERSION defaults: DefaultsConfig = field(default_factory=DefaultsConfig) platform: PlatformConfig = field(default_factory=PlatformConfig) + telemetry: TelemetryConfig = field(default_factory=TelemetryConfig) SHORT_KEY_ALIASES: dict[str, str] = { @@ -87,6 +93,9 @@ def load_config() -> Mem0Config: config.defaults.run_id = defaults.get("run_id", "") config.defaults.enable_graph = defaults.get("enable_graph", False) + telemetry = data.get("telemetry", {}) + config.telemetry.anonymous_id = telemetry.get("anonymous_id", "") + # Environment variable overrides env_key = os.environ.get("MEM0_API_KEY") if env_key: @@ -137,6 +146,9 @@ def save_config(config: Mem0Config) -> None: "base_url": config.platform.base_url, "user_email": config.platform.user_email, }, + "telemetry": { + "anonymous_id": config.telemetry.anonymous_id, + }, } with open(CONFIG_FILE, "w") as f: diff --git a/cli/python/src/mem0_cli/telemetry.py b/cli/python/src/mem0_cli/telemetry.py index 4ca15d986..b0fc50fe9 100644 --- a/cli/python/src/mem0_cli/telemetry.py +++ b/cli/python/src/mem0_cli/telemetry.py @@ -9,12 +9,14 @@ Disable with: MEM0_TELEMETRY=false from __future__ import annotations +import contextlib import hashlib import json import os import platform import subprocess import sys +import uuid from typing import Any POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" @@ -26,11 +28,31 @@ def _is_telemetry_enabled() -> bool: return val not in ("false", "0", "no") +def _get_or_create_anonymous_id() -> str: + """Return a persistent per-machine anonymous ID, generating one if needed. + + Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that + repeat runs on the same machine share one PostHog identity instead of + collapsing into a single shared fallback string. + """ + from mem0_cli.config import load_config, save_config + + config = load_config() + if config.telemetry.anonymous_id: + return config.telemetry.anonymous_id + + new_id = f"cli-anon-{uuid.uuid4().hex}" + config.telemetry.anonymous_id = new_id + with contextlib.suppress(Exception): + save_config(config) + return new_id + + def _get_distinct_id() -> str: """Return a stable anonymous identifier for the current user. - Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback. - Matches the SDK pattern in mem0/client/main.py. + Priority: cached user_email (from /v1/ping/) > MD5(api_key) > + persistent per-machine anonymous ID. """ try: from mem0_cli.config import load_config @@ -42,7 +64,10 @@ def _get_distinct_id() -> str: return hashlib.md5(config.platform.api_key.encode()).hexdigest() except Exception: pass - return "anonymous-cli" + try: + return _get_or_create_anonymous_id() + except Exception: + return f"cli-anon-{uuid.uuid4().hex}" def capture_event( @@ -61,12 +86,27 @@ def capture_event( try: from mem0_cli import __version__ - from mem0_cli.config import CONFIG_FILE, load_config + from mem0_cli.config import CONFIG_FILE, load_config, save_config from mem0_cli.state import is_agent_mode config = load_config() distinct_id = pre_resolved_email or _get_distinct_id() + # Detect anonymous → identified transition. If a stored anonymous_id + # exists and we just resolved to a real identity, fire a one-shot + # $identify event so PostHog stitches the pre-signup history onto + # the authenticated profile. Clear the stored id so we don't re-alias. + anon_id_to_alias: str | None = None + if ( + distinct_id + and not distinct_id.startswith("cli-anon-") + and config.telemetry.anonymous_id + ): + anon_id_to_alias = config.telemetry.anonymous_id + config.telemetry.anonymous_id = "" + with contextlib.suppress(Exception): + save_config(config) + payload = { "api_key": POSTHOG_API_KEY, "distinct_id": distinct_id, @@ -92,6 +132,7 @@ def capture_event( "mem0_api_key": config.platform.api_key or "", "mem0_base_url": config.platform.base_url or "https://api.mem0.ai", "config_path": str(CONFIG_FILE), + "anon_distinct_id_to_alias": anon_id_to_alias, } subprocess.Popen( diff --git a/cli/python/src/mem0_cli/telemetry_sender.py b/cli/python/src/mem0_cli/telemetry_sender.py index e277a53ea..5786b678b 100644 --- a/cli/python/src/mem0_cli/telemetry_sender.py +++ b/cli/python/src/mem0_cli/telemetry_sender.py @@ -27,9 +27,31 @@ def main() -> None: if ctx.get("needs_email") and ctx.get("mem0_api_key"): _resolve_and_cache_email(ctx, payload) + # Fire $identify *after* email resolution so PostHog links the stored + # anonymous id directly to the final identity (email, not the api-key + # hash). The regular event is sent next so it lands under the merged + # profile. + anon_id = ctx.get("anon_distinct_id_to_alias") + if anon_id: + _send_identify_event(ctx, payload, anon_id) + _send_posthog_event(ctx["posthog_host"], payload) +def _send_identify_event(ctx: dict, payload: dict, anon_id: str) -> None: + """Send a PostHog $identify event aliasing anon_id → payload['distinct_id'].""" + identify_payload = { + "api_key": payload["api_key"], + "event": "$identify", + "distinct_id": payload["distinct_id"], + "properties": { + "$anon_distinct_id": anon_id, + "$lib": payload.get("properties", {}).get("$lib", "posthog-python"), + }, + } + _send_posthog_event(ctx["posthog_host"], identify_payload) + + def _resolve_and_cache_email(ctx: dict, payload: dict) -> None: """Call /v1/ping/ to get the user's email, update the payload, and cache it.""" try: diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index 81b38b2b5..71f58af7d 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -1160,6 +1160,17 @@ mode: "wide" + + +**Bug Fixes:** +- **Telemetry:** Replaced shared `"anonymous-cli"` fallback with a persistent per-machine random hash (`cli-anon-`), so anonymous CLI users are counted individually in PostHog instead of collapsing into one identity ([#4789](https://github.com/mem0ai/mem0/pull/4789)) +- **Telemetry:** Added PostHog `$identify` event on first authenticated run to stitch pre-signup anonymous history onto the authenticated user profile ([#4789](https://github.com/mem0ai/mem0/pull/4789)) + +**Improvements:** +- **API:** All API calls now include `source=CLI` in request bodies (POST/PUT) and query params (GET/DELETE) for server-side attribution ([#4789](https://github.com/mem0ai/mem0/pull/4789)) + + + **New Features:**