diff --git a/mem0-ts/src/client/config.ts b/mem0-ts/src/client/config.ts index 646360661..5ebd7faf2 100644 --- a/mem0-ts/src/client/config.ts +++ b/mem0-ts/src/client/config.ts @@ -3,7 +3,9 @@ * * Used to stitch PostHog identities: SDKs and CLIs persist anonymous * distinct_id values here, and the TS MemoryClient reads those on init to - * fire $identify and merge them into the email identity. + * fire $identify and merge them into the email identity. The OSS user_id is + * only a stitch candidate when telemetry.oss_used_at proves OSS Memory + * actually ran here (the platform client never mints ids itself). * * Node-only. Browsers (no `process.versions.node`) no-op. */ @@ -11,6 +13,7 @@ export interface Mem0AnonIds { oss?: string; cli?: string; + ossUsedAt?: string; aliasedPairs: string[]; } @@ -110,6 +113,10 @@ export async function readMem0AnonIds(): Promise { typeof telemetry.anonymous_id === "string" ? telemetry.anonymous_id : undefined, + ossUsedAt: + typeof telemetry.oss_used_at === "string" && telemetry.oss_used_at + ? telemetry.oss_used_at + : undefined, aliasedPairs: Array.isArray(telemetry.aliased_pairs) ? telemetry.aliased_pairs.filter( (item: unknown) => typeof item === "string", @@ -137,6 +144,26 @@ export async function isMem0Aliased( return aliasedPairs.includes(aliasPairMarker(node, anonId, email)); } +export async function markMem0OssUsed(): Promise { + const node = await getNodeFs(); + if (!node) return; + try { + const config = loadConfig(node) ?? {}; + const telemetry = + config.telemetry && typeof config.telemetry === "object" + ? config.telemetry + : {}; + if (typeof telemetry.oss_used_at === "string" && telemetry.oss_used_at) { + return; + } + telemetry.oss_used_at = new Date().toISOString(); + config.telemetry = telemetry; + writeConfig(node, config); + } catch { + // Best-effort: read-only filesystems and unwritable paths just skip. + } +} + export async function markMem0Aliased( anonId: string, email: string, diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 89fe45cef..cbb315199 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -27,12 +27,7 @@ import { isTelemetryEnabled, telemetry, } from "./telemetry"; -import { - getOrCreateMem0UserId, - isMem0Aliased, - markMem0Aliased, - readMem0AnonIds, -} from "./config"; +import { isMem0Aliased, markMem0Aliased, readMem0AnonIds } from "./config"; import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils"; import { createExceptionFromResponse, MemoryError } from "../common/exceptions"; @@ -151,12 +146,16 @@ export default class MemoryClient { try { const email = this.telemetryId; if (!email || !email.includes("@")) return; - const sharedAnonId = await getOrCreateMem0UserId(); const anonIds = await readMem0AnonIds(); - if (!anonIds && !sharedAnonId) return; - const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter( - (id): id is string => !!id && id !== email, - ); + if (!anonIds) return; + // The OSS user_id is only a stitch candidate when telemetry.oss_used_at + // proves OSS Memory actually ran here; never mint an id from the + // platform client path. The CLI anon id is presence-based (only the + // CLIs write it). + const candidates = [ + anonIds.ossUsedAt ? anonIds.oss : undefined, + anonIds.cli, + ].filter((id): id is string => !!id && id !== email); const seen = new Set(); for (const anonId of candidates) { if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue; diff --git a/mem0-ts/src/client/telemetry.ts b/mem0-ts/src/client/telemetry.ts index df3a722d4..78bade1a2 100644 --- a/mem0-ts/src/client/telemetry.ts +++ b/mem0-ts/src/client/telemetry.ts @@ -85,6 +85,7 @@ class UnifiedTelemetry implements TelemetryClient { properties: { $anon_distinct_id: anonId, client_source: "typescript", + client_version: version, $lib: "posthog-node", }, }; diff --git a/mem0-ts/src/client/tests/telemetry-aliasing.test.ts b/mem0-ts/src/client/tests/telemetry-aliasing.test.ts index a0351bbf1..67b30fc86 100644 --- a/mem0-ts/src/client/tests/telemetry-aliasing.test.ts +++ b/mem0-ts/src/client/tests/telemetry-aliasing.test.ts @@ -13,6 +13,7 @@ import { getOrCreateMem0UserId, isMem0Aliased, markMem0Aliased, + markMem0OssUsed, readMem0AnonIds, } from "../config"; import { TEST_API_KEY } from "./helpers"; @@ -145,6 +146,55 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => { markMem0Aliased("oss-uuid", "user@example.com"), ).resolves.toBeUndefined(); }); + + test("markMem0OssUsed writes marker preserving other fields", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] }, + }), + ); + await markMem0OssUsed(); + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.user_id).toBe("oss-uuid"); + expect(written.telemetry.anonymous_id).toBe("cli-anon"); + expect(written.telemetry.aliased_pairs).toEqual(["pair-marker"]); + expect(written.telemetry.oss_used_at).toBeTruthy(); + }); + + test("markMem0OssUsed is idempotent", async () => { + await markMem0OssUsed(); + const first = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ).telemetry.oss_used_at; + await markMem0OssUsed(); + const second = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ).telemetry.oss_used_at; + expect(second).toBe(first); + }); + + test("readMem0AnonIds surfaces ossUsedAt", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" }, + }), + ); + const ids = await readMem0AnonIds(); + expect(ids?.oss).toBe("oss-uuid"); + expect(ids?.ossUsedAt).toBe("2026-01-01T00:00:00.000Z"); + }); + + test("markMem0OssUsed does not throw when target dir is unwritable", async () => { + fs.writeFileSync(path.join(tmpHome, "blocker"), "x"); + process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir + await expect(markMem0OssUsed()).resolves.toBeUndefined(); + }); }); // ─── telemetry.captureIdentify ─────────────────────────────── @@ -166,6 +216,7 @@ describe("telemetry.captureIdentify", () => { expect(payload.event).toBe("$identify"); expect(payload.distinct_id).toBe("user@example.com"); expect(payload.properties.$anon_distinct_id).toBe("anon-uuid"); + expect(payload.properties.client_version).toBeTruthy(); expect(payload.properties.$process_person_profile).toBeUndefined(); }); @@ -220,7 +271,10 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { test("fires $identify on first init and persists pair marker", async () => { fs.writeFileSync( path.join(tmpHome, "config.json"), - JSON.stringify({ user_id: "oss-uuid" }), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" }, + }), ); const fetchMock = setupMockFetchWithPostHog(); @@ -244,17 +298,38 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { expect(written.telemetry.aliased_pairs).toHaveLength(1); }); - test("platform-first init creates shared anon ID and identifies it", async () => { + test("platform-only init mints nothing and fires no $identify", async () => { + // Regression: the client used to create the shared anon id itself and + // immediately stitch it, merging an event-less anon person into every + // platform user. The platform path must now be read-only. const fetchMock = setupMockFetchWithPostHog(); const client = makeStubClient("test@example.com"); await (client as any)._maybeAliasAnonToEmail(); - const written = JSON.parse( - fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + expect(fs.existsSync(path.join(tmpHome, "config.json"))).toBe(false); + + const identifyCalls = (fetchMock.mock.calls as any[]).filter( + ([, init]: [string, RequestInit]) => { + if (!init?.body) return false; + return JSON.parse(init.body as string).event === "$identify"; + }, ); - expect(written.user_id).toBeTruthy(); - expect(written.telemetry.aliased_pairs).toHaveLength(1); + expect(identifyCalls.length).toBe(0); + }); + + test("oss id without oss_used_at marker is not stitched; cli id still is", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { anonymous_id: "cli-anon" }, + }), + ); + const fetchMock = setupMockFetchWithPostHog(); + + const client = makeStubClient("test@example.com"); + await (client as any)._maybeAliasAnonToEmail(); const identifyCalls = (fetchMock.mock.calls as any[]).filter( ([, init]: [string, RequestInit]) => { @@ -264,8 +339,32 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { ); expect(identifyCalls.length).toBe(1); const body = JSON.parse(identifyCalls[0][1].body); - expect(body.distinct_id).toBe("test@example.com"); - expect(body.properties.$anon_distinct_id).toBe(written.user_id); + expect(body.properties.$anon_distinct_id).toBe("cli-anon"); + }); + + test("stitches config written by the Python SDK (cross-SDK key compat)", async () => { + // Python writes datetime.now(timezone.utc).isoformat(): +00:00 offset. + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { oss_used_at: "2026-01-01T00:00:00+00:00" }, + }), + ); + const fetchMock = setupMockFetchWithPostHog(); + + const client = makeStubClient("test@example.com"); + await (client as any)._maybeAliasAnonToEmail(); + + const identifyCalls = (fetchMock.mock.calls as any[]).filter( + ([, init]: [string, RequestInit]) => { + if (!init?.body) return false; + return JSON.parse(init.body as string).event === "$identify"; + }, + ); + expect(identifyCalls.length).toBe(1); + const body = JSON.parse(identifyCalls[0][1].body); + expect(body.properties.$anon_distinct_id).toBe("oss-uuid"); }); test("second init does not refire $identify", async () => { @@ -273,7 +372,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { path.join(tmpHome, "config.json"), JSON.stringify({ user_id: "oss-uuid", - telemetry: {}, + telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" }, }), ); await markMem0Aliased("oss-uuid", "test@example.com"); @@ -296,7 +395,10 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { path.join(tmpHome, "config.json"), JSON.stringify({ user_id: "oss-uuid", - telemetry: { anonymous_id: "cli-anon" }, + telemetry: { + anonymous_id: "cli-anon", + oss_used_at: "2026-01-01T00:00:00.000Z", + }, }), ); const fetchMock = setupMockFetchWithPostHog(); @@ -407,4 +509,18 @@ describe("config.ts in browser-like environment", () => { jest.resetModules(); } }); + + test("markMem0OssUsed is a no-op when not Node", async () => { + const originalProcess = global.process; + // @ts-expect-error force-undefining global to simulate a browser + delete global.process; + try { + jest.resetModules(); + const { markMem0OssUsed: browserMark } = await import("../config"); + await expect(browserMark()).resolves.toBeUndefined(); + } finally { + global.process = originalProcess; + jest.resetModules(); + } + }); }); diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index f6501945c..05591e615 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -39,7 +39,7 @@ import { } from "./memory.types"; import { parse_vision_messages } from "../utils/memory"; import { HistoryManager } from "../storage/base"; -import { captureClientEvent } from "../utils/telemetry"; +import { captureClientEvent, isTelemetryEnabled } from "../utils/telemetry"; import { lemmatizeForBm25 } from "../utils/lemmatization"; import { extractEntities, @@ -53,7 +53,7 @@ import { ScoredResult, } from "../utils/scoring"; import { getDefaultVectorStoreDbPath } from "../utils/sqlite"; -import { getOrCreateMem0UserId } from "../../../client/config"; +import { getOrCreateMem0UserId, markMem0OssUsed } from "../../../client/config"; // Entity params that must be passed via filters - check both snake_case and camelCase const ENTITY_PARAMS = [ @@ -451,6 +451,12 @@ export class Memory { try { await this._getTelemetryId(); + // Record genuine OSS usage so the platform client may stitch this + // machine's anon id to a platform email later. + if (isTelemetryEnabled()) { + await markMem0OssUsed(); + } + // Capture initialization event await captureClientEvent("init", this, { api_version: this.apiVersion, diff --git a/mem0-ts/src/oss/src/utils/telemetry.ts b/mem0-ts/src/oss/src/utils/telemetry.ts index 9f75f5d05..2f34416f7 100644 --- a/mem0-ts/src/oss/src/utils/telemetry.ts +++ b/mem0-ts/src/oss/src/utils/telemetry.ts @@ -126,4 +126,8 @@ async function captureClientEvent( ); } -export { telemetry, captureClientEvent }; +function isTelemetryEnabled(): boolean { + return MEM0_TELEMETRY; +} + +export { telemetry, captureClientEvent, isTelemetryEnabled }; diff --git a/mem0/client/main.py b/mem0/client/main.py index 01798bd7a..b248b40e3 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -19,16 +19,13 @@ from mem0.client.types import ( from mem0.client.utils import api_error_handler # Exception classes are referenced in docstrings only -from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config +from mem0.memory.setup import get_user_id, is_aliased, is_oss_used, mark_aliased, read_anon_ids from mem0.memory.telemetry import capture_client_event, client_telemetry logger = logging.getLogger(__name__) warnings.filterwarnings("default", category=DeprecationWarning) -# Setup user config -setup_config() - # Entity parameters that must be passed via filters, not top-level ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) @@ -45,6 +42,10 @@ def _validate_and_trim_search_query(query: str) -> str: def _maybe_alias_anon_to_email(user_email): """Fire $identify per prior anon ID so PostHog merges them into email. + The OSS user_id is only a stitch candidate when telemetry.oss_used_at + proves OSS Memory actually ran here; bare user_id values were historically + minted at import time by platform-only clients and must not be stitched. + The CLI anon id keeps presence-based stitching (only the CLIs write it). Idempotent via telemetry.aliased_pairs: only writes markers when telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works. Best-effort: never raises. @@ -55,8 +56,9 @@ def _maybe_alias_anon_to_email(user_email): return try: anon_ids = read_anon_ids() + oss_id = anon_ids.get("oss") if is_oss_used() else None seen = set() - for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")): + for anon_id in (oss_id, anon_ids.get("cli")): if not anon_id or anon_id == user_email or anon_id in seen: continue seen.add(anon_id) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 333d2510f..751cc00ae 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -23,7 +23,7 @@ from mem0.configs.prompts import ( ) from mem0.exceptions import ValidationError as Mem0ValidationError from mem0.memory.base import MemoryBase -from mem0.memory.setup import mem0_dir, setup_config +from mem0.memory.setup import mark_oss_used, mem0_dir, setup_config from mem0.memory.storage import SQLiteManager from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event from mem0.memory.utils import ( @@ -341,7 +341,6 @@ def _build_session_scope(filters): return "&".join(parts) -setup_config() logger = logging.getLogger(__name__) @@ -410,6 +409,13 @@ class Memory(MemoryBase): self.config.vector_store.provider, ) + # Mint the OSS anon id (and record genuine OSS usage) only on real OSS + # init, never at import time: platform-only clients must not create + # stitch candidates. Must run before the first capture_event so the + # telemetry singleton picks up the minted user_id. + if MEM0_TELEMETRY: + setup_config() + mark_oss_used() capture_event("mem0.init", self, {"sync_type": "sync"}) @property @@ -1882,6 +1888,9 @@ class AsyncMemory(MemoryBase): self.config.vector_store.provider, ) + if MEM0_TELEMETRY: + setup_config() + mark_oss_used() capture_event("mem0.init", self, {"sync_type": "async"}) @property diff --git a/mem0/memory/setup.py b/mem0/memory/setup.py index 7d98bcae4..369fcb036 100644 --- a/mem0/memory/setup.py +++ b/mem0/memory/setup.py @@ -2,6 +2,7 @@ import json import logging import os import uuid +from datetime import datetime, timezone from hashlib import sha256 # Set up the directory path @@ -44,10 +45,12 @@ def _write_config(config): def setup_config(): """Ensure ~/.mem0/config.json exists with a top-level user_id. - Idempotent: backfills user_id for users whose config was written by the - CLI (which writes telemetry.anonymous_id but no top-level user_id). - Without this, OSS Python telemetry is silently dropped because - get_user_id() returns None when user_id is missing. + Called lazily from OSS Memory/AsyncMemory init (never at import time, so + platform-only MemoryClient usage mints no anon id). Idempotent: backfills + user_id for users whose config was written by the CLI (which writes + telemetry.anonymous_id but no top-level user_id). Without this, OSS Python + telemetry is silently dropped because get_user_id() returns None when + user_id is missing. """ config = _load_config() if config.get("user_id"): @@ -119,6 +122,31 @@ def mark_aliased(anon_id, email): _write_config(config) +def is_oss_used(): + """Return whether OSS Memory has genuinely initialized on this machine. + + Gates identity stitching: the top-level user_id is only a stitch candidate + when telemetry.oss_used_at is present, because bare user_id values were + historically minted at import time by platform-only clients. + """ + config = _load_config() + telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {} + return bool(telemetry.get("oss_used_at")) + + +def mark_oss_used(): + """Persist telemetry.oss_used_at on first genuine OSS Memory init. Never raises.""" + config = _load_config() + telemetry = config.get("telemetry") + if not isinstance(telemetry, dict): + telemetry = {} + if telemetry.get("oss_used_at"): + return + telemetry["oss_used_at"] = datetime.now(timezone.utc).isoformat() + config["telemetry"] = telemetry + _write_config(config) + + def get_or_create_user_id(vector_store=None): """Store user_id in vector store and return it. diff --git a/mem0/memory/telemetry.py b/mem0/memory/telemetry.py index 0c2c544ad..428f9e694 100644 --- a/mem0/memory/telemetry.py +++ b/mem0/memory/telemetry.py @@ -123,7 +123,11 @@ class AnonymousTelemetry: self.posthog.capture( distinct_id=email, event="$identify", - properties={"$anon_distinct_id": anon_id, "client_source": "python"}, + properties={ + "$anon_distinct_id": anon_id, + "client_source": "python", + "client_version": mem0.__version__, + }, ) return True except Exception as e: diff --git a/tests/test_telemetry_aliasing.py b/tests/test_telemetry_aliasing.py index bd6fcf0ee..68e3729f2 100644 --- a/tests/test_telemetry_aliasing.py +++ b/tests/test_telemetry_aliasing.py @@ -164,11 +164,68 @@ class TestMarkAliased: assert not setup_module.is_aliased("oss-uuid", "other@example.com") +# ─── oss_used marker ───────────────────────────────────────────────────────── + + +class TestOssUsedMarker: + def test_mark_writes_marker_preserving_fields(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config( + tmp_mem0_dir, + { + "user_id": "oss-uuid", + "telemetry": {"anonymous_id": "cli-anon", "aliased_pairs": ["pair-marker"]}, + }, + ) + setup_module.mark_oss_used() + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert config["user_id"] == "oss-uuid" + assert config["telemetry"]["anonymous_id"] == "cli-anon" + assert config["telemetry"]["aliased_pairs"] == ["pair-marker"] + assert config["telemetry"]["oss_used_at"] + assert setup_module.is_oss_used() + + def test_mark_is_idempotent(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + setup_module.mark_oss_used() + first = json.loads((tmp_mem0_dir / "config.json").read_text())["telemetry"]["oss_used_at"] + setup_module.mark_oss_used() + second = json.loads((tmp_mem0_dir / "config.json").read_text())["telemetry"]["oss_used_at"] + assert first == second + + def test_is_oss_used_false_when_no_config(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + assert not setup_module.is_oss_used() + + def test_is_oss_used_false_without_marker(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config(tmp_mem0_dir, {"user_id": "oss-uuid", "telemetry": {"anonymous_id": "cli-anon"}}) + assert not setup_module.is_oss_used() + + def test_is_oss_used_false_on_malformed_json(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + (tmp_mem0_dir / "config.json").write_text("{not json") + assert not setup_module.is_oss_used() + + def test_mark_does_not_raise_when_config_path_unwritable(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + (tmp_mem0_dir / "config.json").mkdir() # open() on a directory fails + setup_module.mark_oss_used() # must not raise + assert not setup_module.is_oss_used() + + # ─── capture_identify ──────────────────────────────────────────────────────── class TestCaptureIdentify: def test_fires_identify_with_anon_distinct_id(self): + import mem0 import mem0.memory.telemetry as telemetry_module with patch.object(telemetry_module, "MEM0_TELEMETRY", True): @@ -181,6 +238,7 @@ class TestCaptureIdentify: assert kwargs["distinct_id"] == "user@example.com" assert kwargs["event"] == "$identify" assert kwargs["properties"]["$anon_distinct_id"] == "anon-123" + assert kwargs["properties"]["client_version"] == mem0.__version__ def test_skips_when_anon_equals_email(self): import mem0.memory.telemetry as telemetry_module @@ -230,8 +288,7 @@ class TestCaptureIdentify: class TestMaybeAliasAnonToEmail: """Test the alias helper in isolation by mocking out the config readers - and the telemetry client, since module-level setup_config() side effects - make end-to-end fixturing awkward.""" + and the telemetry client, so no real ~/.mem0/config.json is touched.""" def test_fires_identify_for_oss_uuid(self): from mem0.client import main as client_main @@ -242,6 +299,7 @@ class TestMaybeAliasAnonToEmail: "read_anon_ids", return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []}, ), + patch.object(client_main, "is_oss_used", return_value=True), patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased") as mark, patch.object(client_main, "client_telemetry") as telemetry, @@ -252,6 +310,7 @@ class TestMaybeAliasAnonToEmail: mark.assert_called_once_with("oss-uuid", "user@example.com") def test_fires_identify_for_cli_anon(self): + """The CLI anon id is presence-based: it stitches even without the oss_used marker.""" from mem0.client import main as client_main with ( @@ -260,6 +319,7 @@ class TestMaybeAliasAnonToEmail: "read_anon_ids", return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_pairs": []}, ), + patch.object(client_main, "is_oss_used", return_value=False), patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased"), patch.object(client_main, "client_telemetry") as telemetry, @@ -277,6 +337,7 @@ class TestMaybeAliasAnonToEmail: "read_anon_ids", return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []}, ), + patch.object(client_main, "is_oss_used", return_value=True), patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased"), patch.object(client_main, "client_telemetry") as telemetry, @@ -288,6 +349,26 @@ class TestMaybeAliasAnonToEmail: assert ("oss-uuid", "user@example.com") in calls assert ("cli-anon", "user@example.com") in calls + def test_oss_id_requires_oss_used_marker(self): + """Without proof of real OSS usage, the oss user_id must never be + stitched (it may have been minted by an old platform-only client).""" + from mem0.client import main as client_main + + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []}, + ), + patch.object(client_main, "is_oss_used", return_value=False), + patch.object(client_main, "is_aliased", return_value=False), + patch.object(client_main, "mark_aliased"), + patch.object(client_main, "client_telemetry") as telemetry, + ): + telemetry.capture_identify.return_value = True + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_called_once_with("cli-anon", "user@example.com") + def test_skips_when_pair_already_aliased(self): from mem0.client import main as client_main @@ -297,6 +378,7 @@ class TestMaybeAliasAnonToEmail: "read_anon_ids", return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": ["pair-marker"]}, ), + patch.object(client_main, "is_oss_used", return_value=True), patch.object(client_main, "is_aliased", return_value=True), patch.object(client_main, "mark_aliased") as mark, patch.object(client_main, "client_telemetry") as telemetry, @@ -342,6 +424,7 @@ class TestMaybeAliasAnonToEmail: "read_anon_ids", return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []}, ), + patch.object(client_main, "is_oss_used", return_value=True), patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased") as mark, patch.object(client_main, "client_telemetry", mock_telemetry), @@ -359,6 +442,7 @@ class TestMaybeAliasAnonToEmail: "read_anon_ids", return_value={"oss": "user@example.com", "cli": None, "aliased_pairs": []}, ), + patch.object(client_main, "is_oss_used", return_value=True), patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased"), patch.object(client_main, "client_telemetry") as telemetry, @@ -386,8 +470,12 @@ class TestEndToEndIdempotency: calls fire $identify exactly once thanks to the persisted pair marker.""" def test_second_call_is_noop_after_pair_marker_persisted(self, tmp_mem0_dir): - # Pre-populate config with an OSS user_id only. - _write_config(tmp_mem0_dir, {"user_id": "oss-uuid"}) + # Pre-populate config with an OSS user_id plus the oss_used_at marker + # (without the marker the oss id is no longer a stitch candidate). + _write_config( + tmp_mem0_dir, + {"user_id": "oss-uuid", "telemetry": {"oss_used_at": "2026-01-01T00:00:00+00:00"}}, + ) # Reload setup so it uses the tempdir, then reload client.main so it # picks up the freshly-loaded read_anon_ids/mark_aliased bindings. import mem0.memory.setup as setup_module @@ -409,3 +497,112 @@ class TestEndToEndIdempotency: config = json.loads((tmp_mem0_dir / "config.json").read_text()) assert len(config["telemetry"]["aliased_pairs"]) == 1 + + def test_oss_then_platform_stitches_exactly_once(self, tmp_mem0_dir): + """Genuine OSS usage (setup_config + mark_oss_used, as Memory.__init__ + does) followed by platform inits stitches the minted id exactly once.""" + import mem0.memory.setup as setup_module + + setup_module.setup_config() + setup_module.mark_oss_used() + oss_id = json.loads((tmp_mem0_dir / "config.json").read_text())["user_id"] + + from mem0.client import main as client_main + + importlib.reload(client_main) + + with patch.object(client_main, "client_telemetry") as telemetry: + telemetry.capture_identify.return_value = True + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_called_once_with(oss_id, "user@example.com") + client_main._maybe_alias_anon_to_email("user@example.com") + assert telemetry.capture_identify.call_count == 1 + + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert len(config["telemetry"]["aliased_pairs"]) == 1 + assert config["telemetry"]["oss_used_at"] + + +# ─── Platform-only init must not mint or stitch ────────────────────────────── + + +class TestPlatformOnlyDoesNotMint: + """Regression for the bug where importing the client minted an anon id at + import time and then stitched it on MemoryClient init, merging an + event-less anon person into every platform user.""" + + def test_no_config_created_and_no_identify(self, tmp_mem0_dir): + from mem0.client import main as client_main + + # Re-execute the client module: it must not create config.json. + importlib.reload(client_main) + assert not (tmp_mem0_dir / "config.json").exists() + + with patch.object(client_main, "client_telemetry") as telemetry: + telemetry.capture_identify.return_value = True + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_not_called() + + assert not (tmp_mem0_dir / "config.json").exists() + + +# ─── Lazy minting from OSS Memory init ─────────────────────────────────────── + + +class TestLazyOssMinting: + """OSS Memory/AsyncMemory init mints the anon id and records genuine OSS + usage; nothing happens at import time or with telemetry disabled.""" + + def test_memory_init_mints_and_marks(self, tmp_path): + import mem0.memory.main as memory_main + + with ( + patch.object(memory_main, "EmbedderFactory"), + patch.object(memory_main, "VectorStoreFactory"), + patch.object(memory_main, "LlmFactory"), + patch.object(memory_main, "SQLiteManager"), + patch.object(memory_main, "capture_event"), + patch.object(memory_main, "mem0_dir", str(tmp_path)), + patch.object(memory_main, "setup_config") as setup, + patch.object(memory_main, "mark_oss_used") as mark, + patch.object(memory_main, "MEM0_TELEMETRY", True), + ): + memory_main.Memory() + setup.assert_called_once() + mark.assert_called_once() + + def test_memory_init_skips_minting_when_telemetry_disabled(self, tmp_path): + import mem0.memory.main as memory_main + + with ( + patch.object(memory_main, "EmbedderFactory"), + patch.object(memory_main, "VectorStoreFactory"), + patch.object(memory_main, "LlmFactory"), + patch.object(memory_main, "SQLiteManager"), + patch.object(memory_main, "capture_event"), + patch.object(memory_main, "mem0_dir", str(tmp_path)), + patch.object(memory_main, "setup_config") as setup, + patch.object(memory_main, "mark_oss_used") as mark, + patch.object(memory_main, "MEM0_TELEMETRY", False), + ): + memory_main.Memory() + setup.assert_not_called() + mark.assert_not_called() + + def test_async_memory_init_mints_and_marks(self, tmp_path): + import mem0.memory.main as memory_main + + with ( + patch.object(memory_main, "EmbedderFactory"), + patch.object(memory_main, "VectorStoreFactory"), + patch.object(memory_main, "LlmFactory"), + patch.object(memory_main, "SQLiteManager"), + patch.object(memory_main, "capture_event"), + patch.object(memory_main, "mem0_dir", str(tmp_path)), + patch.object(memory_main, "setup_config") as setup, + patch.object(memory_main, "mark_oss_used") as mark, + patch.object(memory_main, "MEM0_TELEMETRY", True), + ): + memory_main.AsyncMemory() + setup.assert_called_once() + mark.assert_called_once()