Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edaf6c4d7a | ||
|
|
f34e42eef3 |
@@ -3,7 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Used to stitch PostHog identities: SDKs and CLIs persist anonymous
|
* Used to stitch PostHog identities: SDKs and CLIs persist anonymous
|
||||||
* distinct_id values here, and the TS MemoryClient reads those on init to
|
* 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.
|
* Node-only. Browsers (no `process.versions.node`) no-op.
|
||||||
*/
|
*/
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
export interface Mem0AnonIds {
|
export interface Mem0AnonIds {
|
||||||
oss?: string;
|
oss?: string;
|
||||||
cli?: string;
|
cli?: string;
|
||||||
|
ossUsedAt?: string;
|
||||||
aliasedPairs: string[];
|
aliasedPairs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +113,10 @@ export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
|
|||||||
typeof telemetry.anonymous_id === "string"
|
typeof telemetry.anonymous_id === "string"
|
||||||
? telemetry.anonymous_id
|
? telemetry.anonymous_id
|
||||||
: undefined,
|
: undefined,
|
||||||
|
ossUsedAt:
|
||||||
|
typeof telemetry.oss_used_at === "string" && telemetry.oss_used_at
|
||||||
|
? telemetry.oss_used_at
|
||||||
|
: undefined,
|
||||||
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
|
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
|
||||||
? telemetry.aliased_pairs.filter(
|
? telemetry.aliased_pairs.filter(
|
||||||
(item: unknown) => typeof item === "string",
|
(item: unknown) => typeof item === "string",
|
||||||
@@ -137,6 +144,26 @@ export async function isMem0Aliased(
|
|||||||
return aliasedPairs.includes(aliasPairMarker(node, anonId, email));
|
return aliasedPairs.includes(aliasPairMarker(node, anonId, email));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markMem0OssUsed(): Promise<void> {
|
||||||
|
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(
|
export async function markMem0Aliased(
|
||||||
anonId: string,
|
anonId: string,
|
||||||
email: string,
|
email: string,
|
||||||
|
|||||||
+10
-11
@@ -27,12 +27,7 @@ import {
|
|||||||
isTelemetryEnabled,
|
isTelemetryEnabled,
|
||||||
telemetry,
|
telemetry,
|
||||||
} from "./telemetry";
|
} from "./telemetry";
|
||||||
import {
|
import { isMem0Aliased, markMem0Aliased, readMem0AnonIds } from "./config";
|
||||||
getOrCreateMem0UserId,
|
|
||||||
isMem0Aliased,
|
|
||||||
markMem0Aliased,
|
|
||||||
readMem0AnonIds,
|
|
||||||
} from "./config";
|
|
||||||
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
||||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||||
|
|
||||||
@@ -151,12 +146,16 @@ export default class MemoryClient {
|
|||||||
try {
|
try {
|
||||||
const email = this.telemetryId;
|
const email = this.telemetryId;
|
||||||
if (!email || !email.includes("@")) return;
|
if (!email || !email.includes("@")) return;
|
||||||
const sharedAnonId = await getOrCreateMem0UserId();
|
|
||||||
const anonIds = await readMem0AnonIds();
|
const anonIds = await readMem0AnonIds();
|
||||||
if (!anonIds && !sharedAnonId) return;
|
if (!anonIds) return;
|
||||||
const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter(
|
// The OSS user_id is only a stitch candidate when telemetry.oss_used_at
|
||||||
(id): id is string => !!id && id !== email,
|
// 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<string>();
|
const seen = new Set<string>();
|
||||||
for (const anonId of candidates) {
|
for (const anonId of candidates) {
|
||||||
if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue;
|
if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class UnifiedTelemetry implements TelemetryClient {
|
|||||||
properties: {
|
properties: {
|
||||||
$anon_distinct_id: anonId,
|
$anon_distinct_id: anonId,
|
||||||
client_source: "typescript",
|
client_source: "typescript",
|
||||||
|
client_version: version,
|
||||||
$lib: "posthog-node",
|
$lib: "posthog-node",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getOrCreateMem0UserId,
|
getOrCreateMem0UserId,
|
||||||
isMem0Aliased,
|
isMem0Aliased,
|
||||||
markMem0Aliased,
|
markMem0Aliased,
|
||||||
|
markMem0OssUsed,
|
||||||
readMem0AnonIds,
|
readMem0AnonIds,
|
||||||
} from "../config";
|
} from "../config";
|
||||||
import { TEST_API_KEY } from "./helpers";
|
import { TEST_API_KEY } from "./helpers";
|
||||||
@@ -145,6 +146,55 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
|||||||
markMem0Aliased("oss-uuid", "user@example.com"),
|
markMem0Aliased("oss-uuid", "user@example.com"),
|
||||||
).resolves.toBeUndefined();
|
).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 ───────────────────────────────
|
// ─── telemetry.captureIdentify ───────────────────────────────
|
||||||
@@ -166,6 +216,7 @@ describe("telemetry.captureIdentify", () => {
|
|||||||
expect(payload.event).toBe("$identify");
|
expect(payload.event).toBe("$identify");
|
||||||
expect(payload.distinct_id).toBe("user@example.com");
|
expect(payload.distinct_id).toBe("user@example.com");
|
||||||
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
||||||
|
expect(payload.properties.client_version).toBeTruthy();
|
||||||
expect(payload.properties.$process_person_profile).toBeUndefined();
|
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 () => {
|
test("fires $identify on first init and persists pair marker", async () => {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(tmpHome, "config.json"),
|
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();
|
const fetchMock = setupMockFetchWithPostHog();
|
||||||
|
|
||||||
@@ -244,17 +298,38 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
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 fetchMock = setupMockFetchWithPostHog();
|
||||||
|
|
||||||
const client = makeStubClient("test@example.com");
|
const client = makeStubClient("test@example.com");
|
||||||
await (client as any)._maybeAliasAnonToEmail();
|
await (client as any)._maybeAliasAnonToEmail();
|
||||||
|
|
||||||
const written = JSON.parse(
|
expect(fs.existsSync(path.join(tmpHome, "config.json"))).toBe(false);
|
||||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
|
||||||
|
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(identifyCalls.length).toBe(0);
|
||||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
});
|
||||||
|
|
||||||
|
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(
|
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||||
([, init]: [string, RequestInit]) => {
|
([, init]: [string, RequestInit]) => {
|
||||||
@@ -264,8 +339,32 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
);
|
);
|
||||||
expect(identifyCalls.length).toBe(1);
|
expect(identifyCalls.length).toBe(1);
|
||||||
const body = JSON.parse(identifyCalls[0][1].body);
|
const body = JSON.parse(identifyCalls[0][1].body);
|
||||||
expect(body.distinct_id).toBe("test@example.com");
|
expect(body.properties.$anon_distinct_id).toBe("cli-anon");
|
||||||
expect(body.properties.$anon_distinct_id).toBe(written.user_id);
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("second init does not refire $identify", async () => {
|
||||||
@@ -273,7 +372,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
path.join(tmpHome, "config.json"),
|
path.join(tmpHome, "config.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
user_id: "oss-uuid",
|
user_id: "oss-uuid",
|
||||||
telemetry: {},
|
telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await markMem0Aliased("oss-uuid", "test@example.com");
|
await markMem0Aliased("oss-uuid", "test@example.com");
|
||||||
@@ -296,7 +395,10 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
path.join(tmpHome, "config.json"),
|
path.join(tmpHome, "config.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
user_id: "oss-uuid",
|
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();
|
const fetchMock = setupMockFetchWithPostHog();
|
||||||
@@ -407,4 +509,18 @@ describe("config.ts in browser-like environment", () => {
|
|||||||
jest.resetModules();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
} from "./memory.types";
|
} from "./memory.types";
|
||||||
import { parse_vision_messages } from "../utils/memory";
|
import { parse_vision_messages } from "../utils/memory";
|
||||||
import { HistoryManager } from "../storage/base";
|
import { HistoryManager } from "../storage/base";
|
||||||
import { captureClientEvent } from "../utils/telemetry";
|
import { captureClientEvent, isTelemetryEnabled } from "../utils/telemetry";
|
||||||
import {
|
import {
|
||||||
detectScaleThresholdFromAddResult,
|
detectScaleThresholdFromAddResult,
|
||||||
detectScaleThresholdFromTopK,
|
detectScaleThresholdFromTopK,
|
||||||
@@ -72,7 +72,7 @@ import {
|
|||||||
ScoredResult,
|
ScoredResult,
|
||||||
} from "../utils/scoring";
|
} from "../utils/scoring";
|
||||||
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
|
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
|
// Entity params that must be passed via filters - check both snake_case and camelCase
|
||||||
const ENTITY_PARAMS = [
|
const ENTITY_PARAMS = [
|
||||||
@@ -470,6 +470,12 @@ export class Memory {
|
|||||||
try {
|
try {
|
||||||
await this._getTelemetryId();
|
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
|
// Capture initialization event
|
||||||
await captureClientEvent("init", this, {
|
await captureClientEvent("init", this, {
|
||||||
api_version: this.apiVersion,
|
api_version: this.apiVersion,
|
||||||
|
|||||||
+7
-5
@@ -19,16 +19,13 @@ from mem0.client.types import (
|
|||||||
from mem0.client.utils import api_error_handler
|
from mem0.client.utils import api_error_handler
|
||||||
|
|
||||||
# Exception classes are referenced in docstrings only
|
# 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
|
from mem0.memory.telemetry import capture_client_event, client_telemetry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
warnings.filterwarnings("default", category=DeprecationWarning)
|
warnings.filterwarnings("default", category=DeprecationWarning)
|
||||||
|
|
||||||
# Setup user config
|
|
||||||
setup_config()
|
|
||||||
|
|
||||||
# Entity parameters that must be passed via filters, not top-level
|
# Entity parameters that must be passed via filters, not top-level
|
||||||
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
|
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):
|
def _maybe_alias_anon_to_email(user_email):
|
||||||
"""Fire $identify per prior anon ID so PostHog merges them into 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
|
Idempotent via telemetry.aliased_pairs: only writes markers when
|
||||||
telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
|
telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
|
||||||
Best-effort: never raises.
|
Best-effort: never raises.
|
||||||
@@ -55,8 +56,9 @@ def _maybe_alias_anon_to_email(user_email):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
anon_ids = read_anon_ids()
|
anon_ids = read_anon_ids()
|
||||||
|
oss_id = anon_ids.get("oss") if is_oss_used() else None
|
||||||
seen = set()
|
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:
|
if not anon_id or anon_id == user_email or anon_id in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(anon_id)
|
seen.add(anon_id)
|
||||||
|
|||||||
+11
-2
@@ -24,7 +24,7 @@ from mem0.configs.prompts import (
|
|||||||
)
|
)
|
||||||
from mem0.exceptions import ValidationError as Mem0ValidationError
|
from mem0.exceptions import ValidationError as Mem0ValidationError
|
||||||
from mem0.memory.base import MemoryBase
|
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.storage import SQLiteManager
|
||||||
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
|
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
|
||||||
from mem0.memory.notices import (
|
from mem0.memory.notices import (
|
||||||
@@ -370,7 +370,6 @@ def _entity_collection_name(provider: str, collection_name: str) -> str:
|
|||||||
return f"{collection_name}{separator}entities"
|
return f"{collection_name}{separator}entities"
|
||||||
|
|
||||||
|
|
||||||
setup_config()
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_PROJECT_UPDATE_UNSUPPORTED_ERROR = "Project updates are not supported by the OSS Memory SDK."
|
_PROJECT_UPDATE_UNSUPPORTED_ERROR = "Project updates are not supported by the OSS Memory SDK."
|
||||||
@@ -469,6 +468,13 @@ class Memory(MemoryBase):
|
|||||||
self.config.vector_store.provider,
|
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"})
|
capture_event("mem0.init", self, {"sync_type": "sync"})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -2009,6 +2015,9 @@ class AsyncMemory(MemoryBase):
|
|||||||
self.config.vector_store.provider,
|
self.config.vector_store.provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if MEM0_TELEMETRY:
|
||||||
|
setup_config()
|
||||||
|
mark_oss_used()
|
||||||
capture_event("mem0.init", self, {"sync_type": "async"})
|
capture_event("mem0.init", self, {"sync_type": "async"})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+32
-4
@@ -3,6 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
# Set up the directory path
|
# Set up the directory path
|
||||||
@@ -56,10 +57,12 @@ def _write_config(config):
|
|||||||
def setup_config():
|
def setup_config():
|
||||||
"""Ensure ~/.mem0/config.json exists with a top-level user_id.
|
"""Ensure ~/.mem0/config.json exists with a top-level user_id.
|
||||||
|
|
||||||
Idempotent: backfills user_id for users whose config was written by the
|
Called lazily from OSS Memory/AsyncMemory init (never at import time, so
|
||||||
CLI (which writes telemetry.anonymous_id but no top-level user_id).
|
platform-only MemoryClient usage mints no anon id). Idempotent: backfills
|
||||||
Without this, OSS Python telemetry is silently dropped because
|
user_id for users whose config was written by the CLI (which writes
|
||||||
get_user_id() returns None when user_id is missing.
|
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()
|
config = _load_config()
|
||||||
if config.get("user_id"):
|
if config.get("user_id"):
|
||||||
@@ -131,6 +134,31 @@ def mark_aliased(anon_id, email):
|
|||||||
_write_config(config)
|
_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):
|
def get_or_create_user_id(vector_store=None):
|
||||||
"""Store user_id in vector store and return it.
|
"""Store user_id in vector store and return it.
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,11 @@ class AnonymousTelemetry:
|
|||||||
self.posthog.capture(
|
self.posthog.capture(
|
||||||
distinct_id=email,
|
distinct_id=email,
|
||||||
event="$identify",
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0ai"
|
name = "mem0ai"
|
||||||
version = "2.0.7"
|
version = "2.0.6"
|
||||||
description = "Long-term memory for AI Agents"
|
description = "Long-term memory for AI Agents"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Mem0", email = "support@mem0.ai" }
|
{ name = "Mem0", email = "support@mem0.ai" }
|
||||||
|
|||||||
@@ -164,11 +164,68 @@ class TestMarkAliased:
|
|||||||
assert not setup_module.is_aliased("oss-uuid", "other@example.com")
|
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 ────────────────────────────────────────────────────────
|
# ─── capture_identify ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestCaptureIdentify:
|
class TestCaptureIdentify:
|
||||||
def test_fires_identify_with_anon_distinct_id(self):
|
def test_fires_identify_with_anon_distinct_id(self):
|
||||||
|
import mem0
|
||||||
import mem0.memory.telemetry as telemetry_module
|
import mem0.memory.telemetry as telemetry_module
|
||||||
|
|
||||||
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
||||||
@@ -181,6 +238,7 @@ class TestCaptureIdentify:
|
|||||||
assert kwargs["distinct_id"] == "user@example.com"
|
assert kwargs["distinct_id"] == "user@example.com"
|
||||||
assert kwargs["event"] == "$identify"
|
assert kwargs["event"] == "$identify"
|
||||||
assert kwargs["properties"]["$anon_distinct_id"] == "anon-123"
|
assert kwargs["properties"]["$anon_distinct_id"] == "anon-123"
|
||||||
|
assert kwargs["properties"]["client_version"] == mem0.__version__
|
||||||
|
|
||||||
def test_skips_when_anon_equals_email(self):
|
def test_skips_when_anon_equals_email(self):
|
||||||
import mem0.memory.telemetry as telemetry_module
|
import mem0.memory.telemetry as telemetry_module
|
||||||
@@ -230,8 +288,7 @@ class TestCaptureIdentify:
|
|||||||
|
|
||||||
class TestMaybeAliasAnonToEmail:
|
class TestMaybeAliasAnonToEmail:
|
||||||
"""Test the alias helper in isolation by mocking out the config readers
|
"""Test the alias helper in isolation by mocking out the config readers
|
||||||
and the telemetry client, since module-level setup_config() side effects
|
and the telemetry client, so no real ~/.mem0/config.json is touched."""
|
||||||
make end-to-end fixturing awkward."""
|
|
||||||
|
|
||||||
def test_fires_identify_for_oss_uuid(self):
|
def test_fires_identify_for_oss_uuid(self):
|
||||||
from mem0.client import main as client_main
|
from mem0.client import main as client_main
|
||||||
@@ -242,6 +299,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
"read_anon_ids",
|
"read_anon_ids",
|
||||||
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
|
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, "is_aliased", return_value=False),
|
||||||
patch.object(client_main, "mark_aliased") as mark,
|
patch.object(client_main, "mark_aliased") as mark,
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
@@ -252,6 +310,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
mark.assert_called_once_with("oss-uuid", "user@example.com")
|
mark.assert_called_once_with("oss-uuid", "user@example.com")
|
||||||
|
|
||||||
def test_fires_identify_for_cli_anon(self):
|
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
|
from mem0.client import main as client_main
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -260,6 +319,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
"read_anon_ids",
|
"read_anon_ids",
|
||||||
return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_pairs": []},
|
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, "is_aliased", return_value=False),
|
||||||
patch.object(client_main, "mark_aliased"),
|
patch.object(client_main, "mark_aliased"),
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
@@ -277,6 +337,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
"read_anon_ids",
|
"read_anon_ids",
|
||||||
return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []},
|
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, "is_aliased", return_value=False),
|
||||||
patch.object(client_main, "mark_aliased"),
|
patch.object(client_main, "mark_aliased"),
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
@@ -288,6 +349,26 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
assert ("oss-uuid", "user@example.com") in calls
|
assert ("oss-uuid", "user@example.com") in calls
|
||||||
assert ("cli-anon", "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):
|
def test_skips_when_pair_already_aliased(self):
|
||||||
from mem0.client import main as client_main
|
from mem0.client import main as client_main
|
||||||
|
|
||||||
@@ -297,6 +378,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
"read_anon_ids",
|
"read_anon_ids",
|
||||||
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": ["pair-marker"]},
|
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, "is_aliased", return_value=True),
|
||||||
patch.object(client_main, "mark_aliased") as mark,
|
patch.object(client_main, "mark_aliased") as mark,
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
@@ -342,6 +424,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
"read_anon_ids",
|
"read_anon_ids",
|
||||||
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
|
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, "is_aliased", return_value=False),
|
||||||
patch.object(client_main, "mark_aliased") as mark,
|
patch.object(client_main, "mark_aliased") as mark,
|
||||||
patch.object(client_main, "client_telemetry", mock_telemetry),
|
patch.object(client_main, "client_telemetry", mock_telemetry),
|
||||||
@@ -359,6 +442,7 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
"read_anon_ids",
|
"read_anon_ids",
|
||||||
return_value={"oss": "user@example.com", "cli": None, "aliased_pairs": []},
|
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, "is_aliased", return_value=False),
|
||||||
patch.object(client_main, "mark_aliased"),
|
patch.object(client_main, "mark_aliased"),
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
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."""
|
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):
|
def test_second_call_is_noop_after_pair_marker_persisted(self, tmp_mem0_dir):
|
||||||
# Pre-populate config with an OSS user_id only.
|
# Pre-populate config with an OSS user_id plus the oss_used_at marker
|
||||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
# (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
|
# Reload setup so it uses the tempdir, then reload client.main so it
|
||||||
# picks up the freshly-loaded read_anon_ids/mark_aliased bindings.
|
# picks up the freshly-loaded read_anon_ids/mark_aliased bindings.
|
||||||
import mem0.memory.setup as setup_module
|
import mem0.memory.setup as setup_module
|
||||||
@@ -409,3 +497,112 @@ class TestEndToEndIdempotency:
|
|||||||
|
|
||||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||||
assert len(config["telemetry"]["aliased_pairs"]) == 1
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user