mem0-cli 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/PKG-INFO +1 -1
  2. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/pyproject.toml +1 -1
  3. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/__init__.py +1 -1
  4. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/app.py +78 -4
  5. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/backend/platform.py +32 -5
  6. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/init_cmd.py +16 -0
  7. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/config.py +16 -0
  8. mem0_cli-0.2.3/src/mem0_cli/telemetry.py +146 -0
  9. mem0_cli-0.2.3/src/mem0_cli/telemetry_sender.py +108 -0
  10. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/.gitignore +0 -0
  11. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/README.md +0 -0
  12. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/__main__.py +0 -0
  13. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/backend/__init__.py +0 -0
  14. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/backend/base.py +0 -0
  15. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/branding.py +0 -0
  16. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/__init__.py +0 -0
  17. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/config_cmd.py +0 -0
  18. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/entities.py +0 -0
  19. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/events_cmd.py +0 -0
  20. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/memory.py +0 -0
  21. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/utils.py +0 -0
  22. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/output.py +0 -0
  23. {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mem0-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: The official CLI for mem0 — the memory layer for AI agents
5
5
  Author-email: "mem0.ai" <founders@mem0.ai>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mem0-cli"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "The official CLI for mem0 — the memory layer for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """mem0 CLI — the command-line interface for the mem0 memory layer."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import json as _json
6
7
  import os
7
8
  import stat as _stat_mod
@@ -12,7 +13,7 @@ import typer
12
13
  from rich.console import Console
13
14
 
14
15
  from mem0_cli import __version__
15
- from mem0_cli.branding import BRAND_COLOR, print_error
16
+ from mem0_cli.branding import BRAND_COLOR, print_error, print_warning
16
17
 
17
18
  console = Console()
18
19
  err_console = Console(stderr=True)
@@ -55,6 +56,44 @@ event_app = typer.Typer(
55
56
  # entity_app and event_app registered after Memory commands to control panel ordering
56
57
 
57
58
 
59
+ # ── Validated user identity (set by _get_backend_and_config) ──────────────
60
+
61
+ _validated_user_email: str | None = None
62
+
63
+ # ── Telemetry helper ─────────────────────────────────────────────────────
64
+
65
+
66
+ def _fire_telemetry(command_name: str, extra: dict | None = None) -> None:
67
+ """Fire a PostHog telemetry event (non-blocking, never fails)."""
68
+ try:
69
+ from mem0_cli.telemetry import capture_event
70
+
71
+ props = {"command": command_name}
72
+ if extra:
73
+ props.update(extra)
74
+ capture_event(f"cli.{command_name}", props, pre_resolved_email=_validated_user_email)
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ @config_app.callback(invoke_without_command=True)
80
+ def _config_callback(ctx: typer.Context) -> None:
81
+ if ctx.invoked_subcommand:
82
+ _fire_telemetry(f"config.{ctx.invoked_subcommand}")
83
+
84
+
85
+ @entity_app.callback(invoke_without_command=True)
86
+ def _entity_callback(ctx: typer.Context) -> None:
87
+ if ctx.invoked_subcommand:
88
+ _fire_telemetry(f"entity.{ctx.invoked_subcommand}")
89
+
90
+
91
+ @event_app.callback(invoke_without_command=True)
92
+ def _event_callback(ctx: typer.Context) -> None:
93
+ if ctx.invoked_subcommand:
94
+ _fire_telemetry(f"event.{ctx.invoked_subcommand}")
95
+
96
+
58
97
  # ── Helpers ───────────────────────────────────────────────────────────────
59
98
 
60
99
 
@@ -62,9 +101,16 @@ def _get_backend_and_config(
62
101
  api_key: str | None = None,
63
102
  base_url: str | None = None,
64
103
  ):
65
- """Build and return the Platform backend plus the loaded config."""
104
+ """Build and return the Platform backend plus the loaded config.
105
+
106
+ Validates the API key upfront via ``/v1/ping/`` and caches the
107
+ resolved user email for telemetry.
108
+ """
109
+ global _validated_user_email
110
+
66
111
  from mem0_cli.backend import get_backend
67
- from mem0_cli.config import load_config
112
+ from mem0_cli.backend.platform import AuthError
113
+ from mem0_cli.config import load_config, save_config
68
114
 
69
115
  config = load_config()
70
116
 
@@ -81,7 +127,29 @@ def _get_backend_and_config(
81
127
  )
82
128
  raise typer.Exit(1)
83
129
 
84
- return get_backend(config), config
130
+ backend = get_backend(config)
131
+
132
+ # Validate the API key upfront with a fast timeout
133
+ try:
134
+ ping_data = backend.ping(timeout=5.0)
135
+ email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
136
+ if email:
137
+ _validated_user_email = email
138
+ if config.platform.user_email != email:
139
+ config.platform.user_email = email
140
+ with contextlib.suppress(Exception):
141
+ save_config(config)
142
+ except AuthError:
143
+ print_error(
144
+ err_console,
145
+ "Invalid or expired API key.",
146
+ hint="Run 'mem0 init' or set MEM0_API_KEY environment variable.",
147
+ )
148
+ raise typer.Exit(1) from None
149
+ except Exception:
150
+ print_warning(err_console, "Could not validate API key (network issue). Proceeding anyway.")
151
+
152
+ return backend, config
85
153
 
86
154
 
87
155
  def _get_backend(
@@ -165,8 +233,11 @@ def main_callback(
165
233
  if version:
166
234
  from mem0_cli.commands.utils import cmd_version
167
235
 
236
+ _fire_telemetry("version")
168
237
  cmd_version()
169
238
  raise typer.Exit()
239
+ if ctx.invoked_subcommand:
240
+ _fire_telemetry(ctx.invoked_subcommand)
170
241
 
171
242
 
172
243
  # ── Memory: add ───────────────────────────────────────────────────────────
@@ -571,12 +642,14 @@ def delete(
571
642
 
572
643
  # ── Dispatch ─────────────────────────────────────────────────────
573
644
  if memory_id is not None:
645
+ _fire_telemetry("delete", {"delete_mode": "single"})
574
646
  from mem0_cli.commands.memory import cmd_delete
575
647
 
576
648
  backend = _get_backend(api_key, base_url)
577
649
  cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
578
650
 
579
651
  elif all_:
652
+ _fire_telemetry("delete", {"delete_mode": "all"})
580
653
  from mem0_cli.commands.memory import cmd_delete_all
581
654
 
582
655
  backend, config = _get_backend_and_config(api_key, base_url)
@@ -584,6 +657,7 @@ def delete(
584
657
  cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output)
585
658
 
586
659
  else: # --entity
660
+ _fire_telemetry("delete", {"delete_mode": "entity"})
587
661
  from mem0_cli.commands.entities import cmd_entities_delete
588
662
 
589
663
  backend = _get_backend(api_key, base_url)
@@ -6,6 +6,7 @@ from typing import Any
6
6
 
7
7
  import httpx
8
8
 
9
+ from mem0_cli import __version__
9
10
  from mem0_cli.backend.base import Backend
10
11
  from mem0_cli.config import PlatformConfig
11
12
 
@@ -21,11 +22,17 @@ class PlatformBackend(Backend):
21
22
  headers={
22
23
  "Authorization": f"Token {config.api_key}",
23
24
  "Content-Type": "application/json",
25
+ "X-Mem0-Source": "cli",
26
+ "X-Mem0-Client-Language": "python",
27
+ "X-Mem0-Client-Version": __version__,
24
28
  },
25
29
  timeout=30.0,
26
30
  )
27
31
 
28
32
  def _request(self, method: str, path: str, **kwargs: Any) -> Any:
33
+ from mem0_cli.state import is_agent_mode
34
+
35
+ self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
29
36
  resp = self._client.request(method, path, **kwargs)
30
37
  if resp.status_code == 401:
31
38
  raise AuthError("Authentication failed. Your API key may be invalid or expired.")
@@ -86,6 +93,7 @@ class PlatformBackend(Backend):
86
93
  payload["categories"] = categories
87
94
  if enable_graph:
88
95
  payload["enable_graph"] = True
96
+ payload["source"] = "CLI"
89
97
 
90
98
  return self._request("POST", "/v1/memories/", json=payload)
91
99
 
@@ -165,6 +173,7 @@ class PlatformBackend(Backend):
165
173
  payload["fields"] = fields
166
174
  if enable_graph:
167
175
  payload["enable_graph"] = True
176
+ payload["source"] = "CLI"
168
177
 
169
178
  result = self._request("POST", "/v2/memories/search/", json=payload)
170
179
  return (
@@ -174,7 +183,7 @@ class PlatformBackend(Backend):
174
183
  )
175
184
 
176
185
  def get(self, memory_id: str) -> dict:
177
- return self._request("GET", f"/v1/memories/{memory_id}/")
186
+ return self._request("GET", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
178
187
 
179
188
  def list_memories(
180
189
  self,
@@ -213,6 +222,7 @@ class PlatformBackend(Backend):
213
222
  payload["filters"] = api_filters
214
223
  if enable_graph:
215
224
  payload["enable_graph"] = True
225
+ payload["source"] = "CLI"
216
226
 
217
227
  result = self._request("POST", "/v2/memories/", json=payload, params=params)
218
228
  return (
@@ -229,6 +239,7 @@ class PlatformBackend(Backend):
229
239
  payload["text"] = content
230
240
  if metadata:
231
241
  payload["metadata"] = metadata
242
+ payload["source"] = "CLI"
232
243
  return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
233
244
 
234
245
  def delete(
@@ -242,7 +253,7 @@ class PlatformBackend(Backend):
242
253
  run_id: str | None = None,
243
254
  ) -> dict:
244
255
  if all:
245
- params: dict[str, str] = {}
256
+ params: dict[str, str] = {"source": "CLI"}
246
257
  if user_id:
247
258
  params["user_id"] = user_id
248
259
  if agent_id:
@@ -253,7 +264,7 @@ class PlatformBackend(Backend):
253
264
  params["run_id"] = run_id
254
265
  return self._request("DELETE", "/v1/memories/", params=params)
255
266
  elif memory_id:
256
- return self._request("DELETE", f"/v1/memories/{memory_id}/")
267
+ return self._request("DELETE", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
257
268
  else:
258
269
  raise ValueError("Either memory_id or --all is required")
259
270
 
@@ -278,9 +289,25 @@ class PlatformBackend(Backend):
278
289
  # Delete each provided entity via the v2 path-based endpoint
279
290
  result: dict = {}
280
291
  for entity_type, entity_id in entities.items():
281
- result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/")
292
+ result = self._request(
293
+ "DELETE", f"/v2/entities/{entity_type}/{entity_id}/", params={"source": "CLI"}
294
+ )
282
295
  return result
283
296
 
297
+ def ping(self, timeout: float | None = None) -> dict:
298
+ """Call the ping endpoint and return the raw response.
299
+
300
+ When *timeout* is given it overrides the client-level timeout so that
301
+ validation pings can fail fast without blocking the user.
302
+ """
303
+ if timeout is not None:
304
+ resp = self._client.get("/v1/ping/", timeout=timeout)
305
+ if resp.status_code == 401:
306
+ raise AuthError("Authentication failed. Your API key may be invalid or expired.")
307
+ resp.raise_for_status()
308
+ return resp.json()
309
+ return self._request("GET", "/v1/ping/")
310
+
284
311
  def status(
285
312
  self,
286
313
  *,
@@ -289,7 +316,7 @@ class PlatformBackend(Backend):
289
316
  ) -> dict[str, Any]:
290
317
  """Check connectivity using the ping endpoint."""
291
318
  try:
292
- self._request("GET", "/v1/ping/")
319
+ self.ping()
293
320
  return {"connected": True, "backend": "platform", "base_url": self.base_url}
294
321
  except Exception as e:
295
322
  return {"connected": False, "backend": "platform", "error": str(e)}
@@ -108,6 +108,10 @@ def _email_login(
108
108
  The caller expects at minimum an ``api_key`` field.
109
109
  """
110
110
  url = base_url.rstrip("/")
111
+ _source_headers = {
112
+ "X-Mem0-Source": "cli",
113
+ "X-Mem0-Client-Language": "python",
114
+ }
111
115
 
112
116
  with httpx.Client(timeout=30.0) as client:
113
117
  # If code is already provided, skip sending — user already has a code
@@ -116,6 +120,7 @@ def _email_login(
116
120
  resp = client.post(
117
121
  f"{url}/api/v1/auth/email_code/",
118
122
  json={"email": email},
123
+ headers=_source_headers,
119
124
  )
120
125
  if resp.status_code == 429:
121
126
  print_error(err_console, "Too many attempts. Try again in a few minutes.")
@@ -148,6 +153,7 @@ def _email_login(
148
153
  resp = client.post(
149
154
  f"{url}/api/v1/auth/email_code/verify/",
150
155
  json={"email": email, "code": code.strip()},
156
+ headers=_source_headers,
151
157
  )
152
158
  if resp.status_code == 429:
153
159
  print_error(err_console, "Too many attempts. Try again in a few minutes.")
@@ -229,6 +235,7 @@ def run_init(
229
235
  raise typer.Exit(1)
230
236
  config.platform.api_key = api_key_val
231
237
  config.platform.base_url = base_url
238
+ config.platform.user_email = email
232
239
  config.defaults.user_id = (
233
240
  user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
234
241
  )
@@ -299,6 +306,7 @@ def run_init(
299
306
  raise typer.Exit(1)
300
307
  config.platform.api_key = api_key_val
301
308
  config.platform.base_url = base_url
309
+ config.platform.user_email = email_addr
302
310
  config.defaults.user_id = (
303
311
  user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
304
312
  )
@@ -384,6 +392,14 @@ def _validate_platform(config: Mem0Config) -> None:
384
392
  )
385
393
  if status.get("connected"):
386
394
  print_success(console, "Connected to mem0 Platform!")
395
+ # Cache user_email from ping response for telemetry distinct_id
396
+ try:
397
+ ping_data = backend.ping()
398
+ user_email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
399
+ if user_email:
400
+ config.platform.user_email = user_email
401
+ except Exception:
402
+ pass
387
403
  else:
388
404
  print_error(
389
405
  err_console,
@@ -27,6 +27,7 @@ CONFIG_VERSION = 1
27
27
  class PlatformConfig:
28
28
  api_key: str = ""
29
29
  base_url: str = DEFAULT_BASE_URL
30
+ user_email: str = ""
30
31
 
31
32
 
32
33
  @dataclass
@@ -38,16 +39,23 @@ class DefaultsConfig:
38
39
  enable_graph: bool = False
39
40
 
40
41
 
42
+ @dataclass
43
+ class TelemetryConfig:
44
+ anonymous_id: str = ""
45
+
46
+
41
47
  @dataclass
42
48
  class Mem0Config:
43
49
  version: int = CONFIG_VERSION
44
50
  defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
45
51
  platform: PlatformConfig = field(default_factory=PlatformConfig)
52
+ telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
46
53
 
47
54
 
48
55
  SHORT_KEY_ALIASES: dict[str, str] = {
49
56
  "api_key": "platform.api_key",
50
57
  "base_url": "platform.base_url",
58
+ "user_email": "platform.user_email",
51
59
  "user_id": "defaults.user_id",
52
60
  "agent_id": "defaults.agent_id",
53
61
  "app_id": "defaults.app_id",
@@ -76,6 +84,7 @@ def load_config() -> Mem0Config:
76
84
  plat = data.get("platform", {})
77
85
  config.platform.api_key = plat.get("api_key", "")
78
86
  config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
87
+ config.platform.user_email = plat.get("user_email", "")
79
88
 
80
89
  defaults = data.get("defaults", {})
81
90
  config.defaults.user_id = defaults.get("user_id", "")
@@ -84,6 +93,9 @@ def load_config() -> Mem0Config:
84
93
  config.defaults.run_id = defaults.get("run_id", "")
85
94
  config.defaults.enable_graph = defaults.get("enable_graph", False)
86
95
 
96
+ telemetry = data.get("telemetry", {})
97
+ config.telemetry.anonymous_id = telemetry.get("anonymous_id", "")
98
+
87
99
  # Environment variable overrides
88
100
  env_key = os.environ.get("MEM0_API_KEY")
89
101
  if env_key:
@@ -132,6 +144,10 @@ def save_config(config: Mem0Config) -> None:
132
144
  "platform": {
133
145
  "api_key": config.platform.api_key,
134
146
  "base_url": config.platform.base_url,
147
+ "user_email": config.platform.user_email,
148
+ },
149
+ "telemetry": {
150
+ "anonymous_id": config.telemetry.anonymous_id,
135
151
  },
136
152
  }
137
153
 
@@ -0,0 +1,146 @@
1
+ """CLI telemetry — anonymous usage tracking via PostHog.
2
+
3
+ Sends fire-and-forget events to PostHog by spawning a detached subprocess
4
+ (telemetry_sender.py). The parent CLI process exits immediately; the
5
+ subprocess handles email resolution, caching, and the HTTP POST.
6
+
7
+ Disable with: MEM0_TELEMETRY=false
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import platform
17
+ import subprocess
18
+ import sys
19
+ import uuid
20
+ from typing import Any
21
+
22
+ POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
23
+ POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
24
+
25
+
26
+ def _is_telemetry_enabled() -> bool:
27
+ val = os.environ.get("MEM0_TELEMETRY", "true").lower()
28
+ return val not in ("false", "0", "no")
29
+
30
+
31
+ def _get_or_create_anonymous_id() -> str:
32
+ """Return a persistent per-machine anonymous ID, generating one if needed.
33
+
34
+ Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that
35
+ repeat runs on the same machine share one PostHog identity instead of
36
+ collapsing into a single shared fallback string.
37
+ """
38
+ from mem0_cli.config import load_config, save_config
39
+
40
+ config = load_config()
41
+ if config.telemetry.anonymous_id:
42
+ return config.telemetry.anonymous_id
43
+
44
+ new_id = f"cli-anon-{uuid.uuid4().hex}"
45
+ config.telemetry.anonymous_id = new_id
46
+ with contextlib.suppress(Exception):
47
+ save_config(config)
48
+ return new_id
49
+
50
+
51
+ def _get_distinct_id() -> str:
52
+ """Return a stable anonymous identifier for the current user.
53
+
54
+ Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
55
+ persistent per-machine anonymous ID.
56
+ """
57
+ try:
58
+ from mem0_cli.config import load_config
59
+
60
+ config = load_config()
61
+ if config.platform.user_email:
62
+ return config.platform.user_email
63
+ if config.platform.api_key:
64
+ return hashlib.md5(config.platform.api_key.encode()).hexdigest()
65
+ except Exception:
66
+ pass
67
+ try:
68
+ return _get_or_create_anonymous_id()
69
+ except Exception:
70
+ return f"cli-anon-{uuid.uuid4().hex}"
71
+
72
+
73
+ def capture_event(
74
+ event_name: str,
75
+ properties: dict[str, Any] | None = None,
76
+ pre_resolved_email: str | None = None,
77
+ ) -> None:
78
+ """Fire a PostHog event via a detached subprocess (non-blocking).
79
+
80
+ When *pre_resolved_email* is provided (e.g. from an upfront ping
81
+ validation), it is used directly as the PostHog distinct ID and the
82
+ subprocess skips its own ``/v1/ping/`` call.
83
+ """
84
+ if not _is_telemetry_enabled():
85
+ return
86
+
87
+ try:
88
+ from mem0_cli import __version__
89
+ from mem0_cli.config import CONFIG_FILE, load_config, save_config
90
+ from mem0_cli.state import is_agent_mode
91
+
92
+ config = load_config()
93
+ distinct_id = pre_resolved_email or _get_distinct_id()
94
+
95
+ # Detect anonymous → identified transition. If a stored anonymous_id
96
+ # exists and we just resolved to a real identity, fire a one-shot
97
+ # $identify event so PostHog stitches the pre-signup history onto
98
+ # the authenticated profile. Clear the stored id so we don't re-alias.
99
+ anon_id_to_alias: str | None = None
100
+ if (
101
+ distinct_id
102
+ and not distinct_id.startswith("cli-anon-")
103
+ and config.telemetry.anonymous_id
104
+ ):
105
+ anon_id_to_alias = config.telemetry.anonymous_id
106
+ config.telemetry.anonymous_id = ""
107
+ with contextlib.suppress(Exception):
108
+ save_config(config)
109
+
110
+ payload = {
111
+ "api_key": POSTHOG_API_KEY,
112
+ "distinct_id": distinct_id,
113
+ "event": event_name,
114
+ "properties": {
115
+ "source": "CLI",
116
+ "language": "python",
117
+ "cli_version": __version__,
118
+ "agent_mode": is_agent_mode(),
119
+ "python_version": sys.version,
120
+ "os": sys.platform,
121
+ "os_version": platform.version(),
122
+ "$process_person_profile": False,
123
+ "$lib": "posthog-python",
124
+ **(properties or {}),
125
+ },
126
+ }
127
+
128
+ context = {
129
+ "payload": payload,
130
+ "posthog_host": POSTHOG_HOST,
131
+ "needs_email": not distinct_id or "@" not in distinct_id,
132
+ "mem0_api_key": config.platform.api_key or "",
133
+ "mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
134
+ "config_path": str(CONFIG_FILE),
135
+ "anon_distinct_id_to_alias": anon_id_to_alias,
136
+ }
137
+
138
+ subprocess.Popen(
139
+ [sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)],
140
+ stdout=subprocess.DEVNULL,
141
+ stderr=subprocess.DEVNULL,
142
+ start_new_session=True,
143
+ close_fds=True,
144
+ )
145
+ except Exception:
146
+ pass
@@ -0,0 +1,108 @@
1
+ """Standalone telemetry sender — runs as a detached subprocess.
2
+
3
+ Usage: python -m mem0_cli.telemetry_sender '<json context>'
4
+
5
+ This module is spawned by telemetry.capture_event() and runs independently
6
+ of the parent CLI process. It:
7
+
8
+ 1. Resolves the user's email via /v1/ping/ if not already cached
9
+ 2. Caches the email in ~/.mem0/config.json for future runs
10
+ 3. Sends the PostHog event
11
+
12
+ All errors are silently swallowed — this process must never produce output
13
+ or affect the user experience.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ import urllib.request
21
+
22
+
23
+ def main() -> None:
24
+ ctx = json.loads(sys.argv[1])
25
+ payload = ctx["payload"]
26
+
27
+ if ctx.get("needs_email") and ctx.get("mem0_api_key"):
28
+ _resolve_and_cache_email(ctx, payload)
29
+
30
+ # Fire $identify *after* email resolution so PostHog links the stored
31
+ # anonymous id directly to the final identity (email, not the api-key
32
+ # hash). The regular event is sent next so it lands under the merged
33
+ # profile.
34
+ anon_id = ctx.get("anon_distinct_id_to_alias")
35
+ if anon_id:
36
+ _send_identify_event(ctx, payload, anon_id)
37
+
38
+ _send_posthog_event(ctx["posthog_host"], payload)
39
+
40
+
41
+ def _send_identify_event(ctx: dict, payload: dict, anon_id: str) -> None:
42
+ """Send a PostHog $identify event aliasing anon_id → payload['distinct_id']."""
43
+ identify_payload = {
44
+ "api_key": payload["api_key"],
45
+ "event": "$identify",
46
+ "distinct_id": payload["distinct_id"],
47
+ "properties": {
48
+ "$anon_distinct_id": anon_id,
49
+ "$lib": payload.get("properties", {}).get("$lib", "posthog-python"),
50
+ },
51
+ }
52
+ _send_posthog_event(ctx["posthog_host"], identify_payload)
53
+
54
+
55
+ def _resolve_and_cache_email(ctx: dict, payload: dict) -> None:
56
+ """Call /v1/ping/ to get the user's email, update the payload, and cache it."""
57
+ try:
58
+ ping_url = ctx["mem0_base_url"].rstrip("/") + "/v1/ping/"
59
+ req = urllib.request.Request(
60
+ ping_url,
61
+ headers={
62
+ "Authorization": "Token " + ctx["mem0_api_key"],
63
+ "Content-Type": "application/json",
64
+ },
65
+ )
66
+ resp = urllib.request.urlopen(req, timeout=10)
67
+ data = json.loads(resp.read())
68
+ email = data.get("user_email")
69
+ if email:
70
+ payload["distinct_id"] = email
71
+ _cache_email(ctx.get("config_path"), email)
72
+ except Exception:
73
+ pass
74
+
75
+
76
+ def _cache_email(config_path: str | None, email: str) -> None:
77
+ """Write user_email into the config file for future runs."""
78
+ if not config_path:
79
+ return
80
+ try:
81
+ with open(config_path) as f:
82
+ cfg = json.load(f)
83
+ cfg.setdefault("platform", {})["user_email"] = email
84
+ with open(config_path, "w") as f:
85
+ json.dump(cfg, f, indent=2)
86
+ except Exception:
87
+ pass
88
+
89
+
90
+ def _send_posthog_event(posthog_host: str, payload: dict) -> None:
91
+ """POST the event to PostHog."""
92
+ try:
93
+ body = json.dumps(payload).encode()
94
+ req = urllib.request.Request(
95
+ posthog_host,
96
+ data=body,
97
+ headers={"Content-Type": "application/json"},
98
+ )
99
+ urllib.request.urlopen(req, timeout=10)
100
+ except Exception:
101
+ pass
102
+
103
+
104
+ if __name__ == "__main__":
105
+ import contextlib
106
+
107
+ with contextlib.suppress(Exception):
108
+ main()
File without changes
File without changes
File without changes