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",
|
"name": "@mem0/cli",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export class PlatformBackend implements Backend {
|
|||||||
if (opts.expires) payload.expiration_date = opts.expires;
|
if (opts.expires) payload.expiration_date = opts.expires;
|
||||||
if (opts.categories) payload.categories = opts.categories;
|
if (opts.categories) payload.categories = opts.categories;
|
||||||
if (opts.enableGraph) payload.enable_graph = true;
|
if (opts.enableGraph) payload.enable_graph = true;
|
||||||
|
payload.source = "CLI";
|
||||||
|
|
||||||
return (await this._request("POST", "/v1/memories/", {
|
return (await this._request("POST", "/v1/memories/", {
|
||||||
json: payload,
|
json: payload,
|
||||||
@@ -176,6 +177,7 @@ export class PlatformBackend implements Backend {
|
|||||||
if (opts.keyword) payload.keyword_search = true;
|
if (opts.keyword) payload.keyword_search = true;
|
||||||
if (opts.fields) payload.fields = opts.fields;
|
if (opts.fields) payload.fields = opts.fields;
|
||||||
if (opts.enableGraph) payload.enable_graph = true;
|
if (opts.enableGraph) payload.enable_graph = true;
|
||||||
|
payload.source = "CLI";
|
||||||
|
|
||||||
const result = (await this._request("POST", "/v2/memories/search/", {
|
const result = (await this._request("POST", "/v2/memories/search/", {
|
||||||
json: payload,
|
json: payload,
|
||||||
@@ -186,10 +188,9 @@ export class PlatformBackend implements Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||||
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
|
return (await this._request("GET", `/v1/memories/${memoryId}/`, {
|
||||||
string,
|
params: { source: "CLI" },
|
||||||
unknown
|
})) as Record<string, unknown>;
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMemories(
|
async listMemories(
|
||||||
@@ -227,6 +228,7 @@ export class PlatformBackend implements Backend {
|
|||||||
});
|
});
|
||||||
if (apiFilters) payload.filters = apiFilters;
|
if (apiFilters) payload.filters = apiFilters;
|
||||||
if (opts.enableGraph) payload.enable_graph = true;
|
if (opts.enableGraph) payload.enable_graph = true;
|
||||||
|
payload.source = "CLI";
|
||||||
|
|
||||||
const result = (await this._request("POST", "/v2/memories/", {
|
const result = (await this._request("POST", "/v2/memories/", {
|
||||||
json: payload,
|
json: payload,
|
||||||
@@ -245,6 +247,7 @@ export class PlatformBackend implements Backend {
|
|||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (content) payload.text = content;
|
if (content) payload.text = content;
|
||||||
if (metadata) payload.metadata = metadata;
|
if (metadata) payload.metadata = metadata;
|
||||||
|
payload.source = "CLI";
|
||||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||||
json: payload,
|
json: payload,
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
@@ -255,7 +258,7 @@ export class PlatformBackend implements Backend {
|
|||||||
opts: DeleteOptions = {},
|
opts: DeleteOptions = {},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
if (opts.all) {
|
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.userId) params.user_id = opts.userId;
|
||||||
if (opts.agentId) params.agent_id = opts.agentId;
|
if (opts.agentId) params.agent_id = opts.agentId;
|
||||||
if (opts.appId) params.app_id = opts.appId;
|
if (opts.appId) params.app_id = opts.appId;
|
||||||
@@ -265,10 +268,9 @@ export class PlatformBackend implements Backend {
|
|||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
if (memoryId) {
|
if (memoryId) {
|
||||||
return (await this._request(
|
return (await this._request("DELETE", `/v1/memories/${memoryId}/`, {
|
||||||
"DELETE",
|
params: { source: "CLI" },
|
||||||
`/v1/memories/${memoryId}/`,
|
})) as Record<string, unknown>;
|
||||||
)) as Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
throw new Error("Either memoryId or --all is required");
|
throw new Error("Either memoryId or --all is required");
|
||||||
}
|
}
|
||||||
@@ -291,6 +293,7 @@ export class PlatformBackend implements Backend {
|
|||||||
result = (await this._request(
|
result = (await this._request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
`/v2/entities/${entityType}/${entityId}/`,
|
`/v2/entities/${entityType}/${entityId}/`,
|
||||||
|
{ params: { source: "CLI" } },
|
||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ export interface DefaultsConfig {
|
|||||||
enableGraph: boolean;
|
enableGraph: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetryConfig {
|
||||||
|
anonymousId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Mem0Config {
|
export interface Mem0Config {
|
||||||
version: number;
|
version: number;
|
||||||
defaults: DefaultsConfig;
|
defaults: DefaultsConfig;
|
||||||
platform: PlatformConfig;
|
platform: PlatformConfig;
|
||||||
|
telemetry: TelemetryConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultConfig(): Mem0Config {
|
export function createDefaultConfig(): Mem0Config {
|
||||||
@@ -52,6 +57,9 @@ export function createDefaultConfig(): Mem0Config {
|
|||||||
baseUrl: DEFAULT_BASE_URL,
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
userEmail: "",
|
userEmail: "",
|
||||||
},
|
},
|
||||||
|
telemetry: {
|
||||||
|
anonymousId: "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +88,9 @@ export function loadConfig(): Mem0Config {
|
|||||||
config.defaults.appId = defaults.app_id ?? "";
|
config.defaults.appId = defaults.app_id ?? "";
|
||||||
config.defaults.runId = defaults.run_id ?? "";
|
config.defaults.runId = defaults.run_id ?? "";
|
||||||
config.defaults.enableGraph = defaults.enable_graph ?? false;
|
config.defaults.enableGraph = defaults.enable_graph ?? false;
|
||||||
|
|
||||||
|
const telemetry = data.telemetry ?? {};
|
||||||
|
config.telemetry.anonymousId = telemetry.anonymous_id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variable overrides
|
// Environment variable overrides
|
||||||
@@ -119,6 +130,9 @@ export function saveConfig(config: Mem0Config): void {
|
|||||||
base_url: config.platform.baseUrl,
|
base_url: config.platform.baseUrl,
|
||||||
user_email: config.platform.userEmail,
|
user_email: config.platform.userEmail,
|
||||||
},
|
},
|
||||||
|
telemetry: {
|
||||||
|
anonymous_id: config.telemetry.anonymousId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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";
|
import { CLI_VERSION } from "./version.js";
|
||||||
|
|
||||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
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.
|
* Return a stable anonymous identifier for the current user.
|
||||||
*
|
*
|
||||||
* Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback.
|
* Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
|
||||||
* Matches the SDK pattern in mem0-ts/src/client/mem0.ts.
|
* persistent per-machine anonymous ID.
|
||||||
*/
|
*/
|
||||||
function getDistinctId(): string {
|
function getDistinctId(): string {
|
||||||
try {
|
try {
|
||||||
@@ -47,7 +70,11 @@ function getDistinctId(): string {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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 config = loadConfig();
|
||||||
const distinctId = preResolvedEmail || getDistinctId();
|
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 = {
|
const payload = {
|
||||||
api_key: POSTHOG_API_KEY,
|
api_key: POSTHOG_API_KEY,
|
||||||
distinct_id: distinctId,
|
distinct_id: distinctId,
|
||||||
@@ -92,6 +138,7 @@ export function captureEvent(
|
|||||||
mem0ApiKey: config.platform.apiKey || "",
|
mem0ApiKey: config.platform.apiKey || "",
|
||||||
mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai",
|
mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai",
|
||||||
configPath: CONFIG_FILE,
|
configPath: CONFIG_FILE,
|
||||||
|
anonDistinctIdToAlias: anonIdToAlias,
|
||||||
};
|
};
|
||||||
|
|
||||||
const child = spawn(
|
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() {
|
async function main() {
|
||||||
const ctx = JSON.parse(process.argv[2]);
|
const ctx = JSON.parse(process.argv[2]);
|
||||||
const payload = ctx.payload;
|
const payload = ctx.payload;
|
||||||
@@ -102,6 +115,14 @@ async function main() {
|
|||||||
await resolveAndCacheEmail(ctx, payload);
|
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);
|
await sendPosthogEvent(ctx.posthogHost, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0-cli"
|
name = "mem0-cli"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
"""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
|
payload["categories"] = categories
|
||||||
if enable_graph:
|
if enable_graph:
|
||||||
payload["enable_graph"] = True
|
payload["enable_graph"] = True
|
||||||
|
payload["source"] = "CLI"
|
||||||
|
|
||||||
return self._request("POST", "/v1/memories/", json=payload)
|
return self._request("POST", "/v1/memories/", json=payload)
|
||||||
|
|
||||||
@@ -172,6 +173,7 @@ class PlatformBackend(Backend):
|
|||||||
payload["fields"] = fields
|
payload["fields"] = fields
|
||||||
if enable_graph:
|
if enable_graph:
|
||||||
payload["enable_graph"] = True
|
payload["enable_graph"] = True
|
||||||
|
payload["source"] = "CLI"
|
||||||
|
|
||||||
result = self._request("POST", "/v2/memories/search/", json=payload)
|
result = self._request("POST", "/v2/memories/search/", json=payload)
|
||||||
return (
|
return (
|
||||||
@@ -181,7 +183,7 @@ class PlatformBackend(Backend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self, memory_id: str) -> dict:
|
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(
|
def list_memories(
|
||||||
self,
|
self,
|
||||||
@@ -220,6 +222,7 @@ class PlatformBackend(Backend):
|
|||||||
payload["filters"] = api_filters
|
payload["filters"] = api_filters
|
||||||
if enable_graph:
|
if enable_graph:
|
||||||
payload["enable_graph"] = True
|
payload["enable_graph"] = True
|
||||||
|
payload["source"] = "CLI"
|
||||||
|
|
||||||
result = self._request("POST", "/v2/memories/", json=payload, params=params)
|
result = self._request("POST", "/v2/memories/", json=payload, params=params)
|
||||||
return (
|
return (
|
||||||
@@ -236,6 +239,7 @@ class PlatformBackend(Backend):
|
|||||||
payload["text"] = content
|
payload["text"] = content
|
||||||
if metadata:
|
if metadata:
|
||||||
payload["metadata"] = metadata
|
payload["metadata"] = metadata
|
||||||
|
payload["source"] = "CLI"
|
||||||
return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
|
return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
@@ -249,7 +253,7 @@ class PlatformBackend(Backend):
|
|||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if all:
|
if all:
|
||||||
params: dict[str, str] = {}
|
params: dict[str, str] = {"source": "CLI"}
|
||||||
if user_id:
|
if user_id:
|
||||||
params["user_id"] = user_id
|
params["user_id"] = user_id
|
||||||
if agent_id:
|
if agent_id:
|
||||||
@@ -260,7 +264,7 @@ class PlatformBackend(Backend):
|
|||||||
params["run_id"] = run_id
|
params["run_id"] = run_id
|
||||||
return self._request("DELETE", "/v1/memories/", params=params)
|
return self._request("DELETE", "/v1/memories/", params=params)
|
||||||
elif memory_id:
|
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:
|
else:
|
||||||
raise ValueError("Either memory_id or --all is required")
|
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
|
# Delete each provided entity via the v2 path-based endpoint
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
for entity_type, entity_id in entities.items():
|
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
|
return result
|
||||||
|
|
||||||
def ping(self, timeout: float | None = None) -> dict:
|
def ping(self, timeout: float | None = None) -> dict:
|
||||||
|
|||||||
@@ -39,11 +39,17 @@ class DefaultsConfig:
|
|||||||
enable_graph: bool = False
|
enable_graph: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelemetryConfig:
|
||||||
|
anonymous_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Mem0Config:
|
class Mem0Config:
|
||||||
version: int = CONFIG_VERSION
|
version: int = CONFIG_VERSION
|
||||||
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
||||||
platform: PlatformConfig = field(default_factory=PlatformConfig)
|
platform: PlatformConfig = field(default_factory=PlatformConfig)
|
||||||
|
telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
|
||||||
|
|
||||||
|
|
||||||
SHORT_KEY_ALIASES: dict[str, str] = {
|
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||||
@@ -87,6 +93,9 @@ def load_config() -> Mem0Config:
|
|||||||
config.defaults.run_id = defaults.get("run_id", "")
|
config.defaults.run_id = defaults.get("run_id", "")
|
||||||
config.defaults.enable_graph = defaults.get("enable_graph", False)
|
config.defaults.enable_graph = defaults.get("enable_graph", False)
|
||||||
|
|
||||||
|
telemetry = data.get("telemetry", {})
|
||||||
|
config.telemetry.anonymous_id = telemetry.get("anonymous_id", "")
|
||||||
|
|
||||||
# Environment variable overrides
|
# Environment variable overrides
|
||||||
env_key = os.environ.get("MEM0_API_KEY")
|
env_key = os.environ.get("MEM0_API_KEY")
|
||||||
if env_key:
|
if env_key:
|
||||||
@@ -137,6 +146,9 @@ def save_config(config: Mem0Config) -> None:
|
|||||||
"base_url": config.platform.base_url,
|
"base_url": config.platform.base_url,
|
||||||
"user_email": config.platform.user_email,
|
"user_email": config.platform.user_email,
|
||||||
},
|
},
|
||||||
|
"telemetry": {
|
||||||
|
"anonymous_id": config.telemetry.anonymous_id,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(CONFIG_FILE, "w") as f:
|
with open(CONFIG_FILE, "w") as f:
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ Disable with: MEM0_TELEMETRY=false
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||||
@@ -26,11 +28,31 @@ def _is_telemetry_enabled() -> bool:
|
|||||||
return val not in ("false", "0", "no")
|
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:
|
def _get_distinct_id() -> str:
|
||||||
"""Return a stable anonymous identifier for the current user.
|
"""Return a stable anonymous identifier for the current user.
|
||||||
|
|
||||||
Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback.
|
Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
|
||||||
Matches the SDK pattern in mem0/client/main.py.
|
persistent per-machine anonymous ID.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from mem0_cli.config import load_config
|
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()
|
return hashlib.md5(config.platform.api_key.encode()).hexdigest()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return "anonymous-cli"
|
try:
|
||||||
|
return _get_or_create_anonymous_id()
|
||||||
|
except Exception:
|
||||||
|
return f"cli-anon-{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
def capture_event(
|
def capture_event(
|
||||||
@@ -61,12 +86,27 @@ def capture_event(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from mem0_cli import __version__
|
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
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
distinct_id = pre_resolved_email or _get_distinct_id()
|
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 = {
|
payload = {
|
||||||
"api_key": POSTHOG_API_KEY,
|
"api_key": POSTHOG_API_KEY,
|
||||||
"distinct_id": distinct_id,
|
"distinct_id": distinct_id,
|
||||||
@@ -92,6 +132,7 @@ def capture_event(
|
|||||||
"mem0_api_key": config.platform.api_key or "",
|
"mem0_api_key": config.platform.api_key or "",
|
||||||
"mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
|
"mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
|
||||||
"config_path": str(CONFIG_FILE),
|
"config_path": str(CONFIG_FILE),
|
||||||
|
"anon_distinct_id_to_alias": anon_id_to_alias,
|
||||||
}
|
}
|
||||||
|
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
|
|||||||
@@ -27,9 +27,31 @@ def main() -> None:
|
|||||||
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
|
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
|
||||||
_resolve_and_cache_email(ctx, payload)
|
_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)
|
_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:
|
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."""
|
"""Call /v1/ping/ to get the user's email, update the payload, and cache it."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1160,6 +1160,17 @@ mode: "wide"
|
|||||||
|
|
||||||
<Tab title="CLI">
|
<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">
|
<Update label="2026-04-06" description="Python v0.2.2 / Node v0.2.2">
|
||||||
|
|
||||||
**New Features:**
|
**New Features:**
|
||||||
|
|||||||
Reference in New Issue
Block a user