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.
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/PKG-INFO +1 -1
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/pyproject.toml +1 -1
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/__init__.py +1 -1
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/app.py +78 -4
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/backend/platform.py +32 -5
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/init_cmd.py +16 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/config.py +16 -0
- mem0_cli-0.2.3/src/mem0_cli/telemetry.py +146 -0
- mem0_cli-0.2.3/src/mem0_cli/telemetry_sender.py +108 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/.gitignore +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/README.md +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/__main__.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/backend/__init__.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/backend/base.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/branding.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/__init__.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/config_cmd.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/entities.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/events_cmd.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/memory.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/commands/utils.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/output.py +0 -0
- {mem0_cli-0.2.1 → mem0_cli-0.2.3}/src/mem0_cli/state.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|