fix(cli): persistent anonymous telemetry ID + pass source=CLI in all API calls (#4789)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
@@ -255,7 +258,7 @@ export class PlatformBackend implements Backend {
|
||||
opts: DeleteOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (opts.all) {
|
||||
const params: Record<string, string> = {};
|
||||
const params: Record<string, string> = { 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<string, unknown>;
|
||||
}
|
||||
if (memoryId) {
|
||||
return (await this._request(
|
||||
"DELETE",
|
||||
`/v1/memories/${memoryId}/`,
|
||||
)) as Record<string, unknown>;
|
||||
return (await this._request("DELETE", `/v1/memories/${memoryId}/`, {
|
||||
params: { source: "CLI" },
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
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<string, unknown>;
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||
|
||||
__version__ = "0.2.2"
|
||||
__version__ = "0.2.3"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1160,6 +1160,17 @@ mode: "wide"
|
||||
|
||||
<Tab title="CLI">
|
||||
|
||||
<Update label="2026-04-11" description="Python v0.2.3 / Node v0.2.3">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Replaced shared `"anonymous-cli"` fallback with a persistent per-machine random hash (`cli-anon-<uuid>`), 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))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-06" description="Python v0.2.2 / Node v0.2.2">
|
||||
|
||||
**New Features:**
|
||||
|
||||
Reference in New Issue
Block a user