import axios from "axios"; import { AllUsers, PaginatedMemories, ProjectOptions, Memory, MemoryHistory, AddMemoryOptions, SearchMemoryOptions, GetAllMemoryOptions, DeleteAllMemoryOptions, DeleteMemoryOptions, MemoryUpdateBody, ProjectResponse, PromptUpdatePayload, Webhook, WebhookCreatePayload, WebhookUpdatePayload, Message, FeedbackPayload, CreateMemoryExportPayload, GetMemoryExportPayload, ProfileEntityType, ProfileResponse, ProfileTriggerResponse, ProfileSettings, ProfileSamplesResponse, ProfileRegenerateResponse, } from "./mem0.types"; import { captureClientEvent, generateHash, isTelemetryEnabled, telemetry, } from "./telemetry"; import { getOrCreateMem0UserId, isMem0Aliased, markMem0Aliased, readMem0AnonIds, } from "./config"; import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils"; import { createExceptionFromResponse, MemoryError } from "../common/exceptions"; // Entity params that must be passed via filters - check both snake_case and camelCase const ENTITY_PARAMS = [ "user_id", "agent_id", "app_id", "run_id", "userId", "agentId", "appId", "runId", ]; /** * Validates that no top-level entity parameters are passed. * @throws Error if entity params are found at top level */ function rejectTopLevelEntityParams( options: Record | undefined, methodName: string, ): void { const invalidKeys = Object.keys(options ?? {}).filter((k) => ENTITY_PARAMS.includes(k), ); if (invalidKeys.length > 0) { throw new Error( `Top-level entity parameters [${invalidKeys.join(", ")}] are not supported in ${methodName}(). ` + `Use filters: { user_id: "..." } instead.`, ); } } function encodePathSegment(value: unknown): string { return encodeURIComponent(String(value)); } class APIError extends Error { constructor(message: string) { super(message); this.name = "APIError"; } } interface ClientOptions { apiKey: string; host?: string; /** Max cached identities per process. Defaults to 50. */ identityCacheMax?: number; } interface ClientIdentity { telemetryId: string; organizationId: string | number | null; projectId: string | number | null; } // Shares one ping per (host, api key) across clients; FIFO-capped. const IDENTITY_CACHE_MAX_DEFAULT = 50; const identityByCredentials = new Map>(); export default class MemoryClient { apiKey: string; host: string; private organizationId: string | number | null; private projectId: string | number | null; headers: Record; client: any; telemetryId: string; private initialized: Promise; private identityCacheMax: number; _validateApiKey(): any { if (!this.apiKey) { throw new Error("Mem0 API key is required"); } if (typeof this.apiKey !== "string") { throw new Error("Mem0 API key must be a string"); } if (this.apiKey.trim() === "") { throw new Error("Mem0 API key cannot be empty"); } } constructor(options: ClientOptions) { this.apiKey = options.apiKey; this.host = options.host || "https://api.mem0.ai"; this.organizationId = null; this.projectId = null; this.identityCacheMax = options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT; this.headers = { Authorization: `Token ${this.apiKey}`, "Content-Type": "application/json", }; this.client = axios.create({ baseURL: this.host, headers: { Authorization: `Token ${this.apiKey}` }, timeout: 60000, }); this._validateApiKey(); this.telemetryId = ""; // Memory requests never wait on this; telemetry and _awaitIdentity do. this.initialized = this._resolveIdentity(); } // One ping per credential pair per process, shared via identityByCredentials. private _resolveIdentity(): Promise { const credentials = `${this.host}\u0000${this.apiKey}`; let shared = identityByCredentials.get(credentials); if (!shared) { shared = this._initializeClient(); if (identityByCredentials.size >= this.identityCacheMax) { identityByCredentials.delete( identityByCredentials.keys().next().value!, ); } identityByCredentials.set(credentials, shared); // A failed ping must not be cached, or the process never recovers. shared.then((identity) => { if (!identity.telemetryId) identityByCredentials.delete(credentials); }); } return shared.then((identity) => { this.telemetryId = identity.telemetryId; if (identity.organizationId != null) this.organizationId = identity.organizationId; if (identity.projectId != null) this.projectId = identity.projectId; }); } // Blocks until the ping has populated organizationId/projectId. private async _awaitIdentity(): Promise { await this.initialized; } private async _initializeClient(): Promise { try { await this.ping(); if (!this.telemetryId) { this.telemetryId = generateHash(this.apiKey); } await this._maybeAliasAnonToEmail(); captureClientEvent("init", this, { client_type: "MemoryClient", }).catch((error: any) => { console.error("Failed to capture event:", error); }); } catch (error: any) { console.error("Failed to initialize client:", error); await captureClientEvent("init_error", this, { error: error?.message || "Unknown error", stack: error?.stack || "No stack trace", }); } return { telemetryId: this.telemetryId, organizationId: this.organizationId, projectId: this.projectId, }; } private async _maybeAliasAnonToEmail(): Promise { if (!isTelemetryEnabled()) return; 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, ); const seen = new Set(); for (const anonId of candidates) { if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue; seen.add(anonId); if (await telemetry.captureIdentify(anonId, email)) { await markMem0Aliased(anonId, email); } } } catch (error: any) { console.error("Failed to alias telemetry identity:", error); } } private _captureEvent(methodName: string, args: any[]) { // Deferred until ping() has resolved telemetryId, off the request path. this.initialized .then(() => captureClientEvent(methodName, this, { success: true, args_count: args.length, keys: args.length > 0 ? args[0] : [], }), ) .catch((error: any) => { console.error("Failed to capture event:", error); }); } /** Fetch with no key conversion, for payloads carrying user-controlled property names. */ async _fetchRawJson(url: string, options: any): Promise { const response = await fetch(url, { ...options, headers: { ...options.headers, Authorization: `Token ${this.apiKey}`, "Mem0-User-ID": this.telemetryId, }, }); if (!response.ok) { const errorData = await response.text(); throw createExceptionFromResponse(response.status, errorData); } return response.json(); } async _fetchWithErrorHandling(url: string, options: any): Promise { return snakeToCamelKeys(await this._fetchRawJson(url, options)); } _preparePayload( messages: Array, options: Record, ): object { const payload: any = {}; payload.messages = messages; return camelToSnakeKeys({ ...payload, ...options }); } _prepareParams(options: Record): object { return Object.fromEntries( Object.entries(options).filter(([_, v]) => v != null), ); } async ping(): Promise { try { const response = await this._fetchWithErrorHandling( `${this.host}/v1/ping/`, { method: "GET", headers: { Authorization: `Token ${this.apiKey}`, }, }, ); if (!response || typeof response !== "object") { throw new APIError("Invalid response format from ping endpoint"); } if (response.status !== "ok") { throw new APIError(response.message || "API Key is invalid"); } const { orgId, projectId, userEmail } = response; if (orgId) this.organizationId = orgId; if (projectId) this.projectId = projectId; if (userEmail) this.telemetryId = userEmail; } catch (error: any) { // Pass through structured exceptions and APIError if (error instanceof MemoryError || error instanceof APIError) { throw error; } else { throw new APIError( `Failed to ping server: ${error.message || "Unknown error"}`, ); } } } async add( messages: Array, options: AddMemoryOptions & Record = {}, ): Promise> { // Tightly scoped validation guard to resolve #5465 if (!messages || (Array.isArray(messages) && messages.length === 0)) { throw new Error("Cannot process an empty messages payload."); } const payload = this._preparePayload(messages, options); const payloadKeys = Object.keys(payload); this._captureEvent("add", [payloadKeys]); const response = await this._fetchWithErrorHandling( `${this.host}/v3/memories/add/`, { method: "POST", headers: this.headers, body: JSON.stringify(payload), }, ); return response; } async update( memoryId: string, { text, metadata, timestamp, expirationDate, }: { text?: string; metadata?: Record; timestamp?: number | string; expirationDate?: string | null; }, ): Promise> { if ( text === undefined && metadata === undefined && timestamp === undefined && expirationDate === undefined ) { throw new Error( "At least one of text, metadata, timestamp, or expirationDate must be provided for update.", ); } const payload: Record = {}; if (text !== undefined) payload.text = text; if (metadata !== undefined) payload.metadata = metadata; if (timestamp !== undefined) payload.timestamp = timestamp; if (expirationDate !== undefined) payload.expiration_date = expirationDate; const payloadKeys = Object.keys(payload); this._captureEvent("update", [payloadKeys]); const response = await this._fetchWithErrorHandling( `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, { method: "PUT", headers: this.headers, body: JSON.stringify(payload), }, ); return response; } async get(memoryId: string): Promise { this._captureEvent("get", []); return this._fetchWithErrorHandling( `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, { headers: this.headers, }, ); } async getAll(options?: GetAllMemoryOptions): Promise { // Reject top-level entity params - must use filters instead rejectTopLevelEntityParams(options as Record, "getAll"); const payloadKeys = Object.keys(options || {}); this._captureEvent("get_all", [payloadKeys]); const { page, pageSize, filters, ...rest } = options ?? {}; const body: Record = { ...camelToSnakeKeys(rest), ...(filters && { filters }), }; const queryParams: string[] = []; if (page !== undefined) queryParams.push(`page=${page}`); if (pageSize !== undefined) queryParams.push(`page_size=${pageSize}`); const url = `${this.host}/v3/memories/${queryParams.length ? `?${queryParams.join("&")}` : ""}`; const response = await this._fetchWithErrorHandling(url, { method: "POST", headers: this.headers, body: JSON.stringify(body), }); return response; } async search( query: string, options?: SearchMemoryOptions, ): Promise<{ results: Array }> { // Reject top-level entity params - must use filters instead rejectTopLevelEntityParams(options as Record, "search"); const payloadKeys = Object.keys(options || {}); this._captureEvent("search", [payloadKeys]); const { filters, ...rest } = options ?? {}; const payload: Record = { query, output_format: "v1.1", ...camelToSnakeKeys(rest), ...(filters && { filters }), }; const response = await this._fetchWithErrorHandling( `${this.host}/v3/memories/search/`, { method: "POST", headers: this.headers, body: JSON.stringify(payload), }, ); return response; } async delete( memoryId: string, options: DeleteMemoryOptions = {}, ): Promise<{ message: string }> { this._captureEvent("delete", [Object.keys(options || {})]); const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); // @ts-ignore const query = new URLSearchParams(snakeOptions).toString(); return this._fetchWithErrorHandling( `${this.host}/v1/memories/${encodePathSegment(memoryId)}/${query ? `?${query}` : ""}`, { method: "DELETE", headers: this.headers, }, ); } async deleteAll( options: DeleteAllMemoryOptions = {}, ): Promise<{ message: string }> { const payloadKeys = Object.keys(options || {}); this._captureEvent("delete_all", [payloadKeys]); const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); // @ts-ignore const params = new URLSearchParams(snakeOptions); const response = await this._fetchWithErrorHandling( `${this.host}/v1/memories/?${params}`, { method: "DELETE", headers: this.headers, }, ); return response; } async history(memoryId: string): Promise> { this._captureEvent("history", []); const response = await this._fetchWithErrorHandling( `${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`, { headers: this.headers, }, ); return response; } async users(options?: { page?: number; pageSize?: number; }): Promise { this._captureEvent("users", []); let url = `${this.host}/v1/entities/`; const params: string[] = []; if (options?.page) params.push(`page=${options.page}`); if (options?.pageSize) params.push(`page_size=${options.pageSize}`); if (params.length) url += `?${params.join("&")}`; const response = await this._fetchWithErrorHandling(url, { headers: this.headers, }); return response; } /** * @deprecated The method should not be used, use `deleteUsers` instead. This will be removed in version 2.2.0. */ async deleteUser(data: { entity_id: number; entity_type: string; }): Promise<{ message: string }> { this._captureEvent("delete_user", []); if (!data.entity_type) { data.entity_type = "user"; } const response = await this._fetchWithErrorHandling( `${this.host}/v1/entities/${encodePathSegment(data.entity_type)}/${encodePathSegment(data.entity_id)}/`, { method: "DELETE", headers: this.headers, }, ); return response; } async deleteUsers( params: { userId?: string; agentId?: string; appId?: string; runId?: string; } = {}, ): Promise<{ message: string }> { let to_delete: Array<{ type: string; name: string }> = []; const { userId, agentId, appId, runId } = params; if (userId) { to_delete = [{ type: "user", name: userId }]; } else if (agentId) { to_delete = [{ type: "agent", name: agentId }]; } else if (appId) { to_delete = [{ type: "app", name: appId }]; } else if (runId) { to_delete = [{ type: "run", name: runId }]; } else { const entities = await this.users(); to_delete = entities.results.map((entity) => ({ type: entity.type, name: entity.name, })); } if (to_delete.length === 0) { throw new Error("No entities to delete"); } for (const entity of to_delete) { try { // fetch() reuses the pooled connection; axios here defaulted to // keepAlive: false, one handshake per entity. await this._fetchWithErrorHandling( `${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`, { method: "DELETE", headers: this.headers, }, ); } catch (error: any) { throw new APIError( `Failed to delete ${entity.type} ${entity.name}: ${error.message}`, ); } } this._captureEvent("delete_users", [ { userId, agentId, appId, runId, sync_type: "sync" }, ]); return { message: userId || agentId || appId || runId ? "Entity deleted successfully." : "All users, agents, apps and runs deleted.", }; } async batchUpdate(memories: Array): Promise { this._captureEvent("batch_update", []); const memoriesBody = memories.map((memory) => ({ memory_id: memory.memoryId, text: memory.text, })); const response = await this._fetchWithErrorHandling( `${this.host}/v1/batch/`, { method: "PUT", headers: this.headers, body: JSON.stringify({ memories: memoriesBody }), }, ); return response; } async batchDelete(memories: Array): Promise { this._captureEvent("batch_delete", []); const memoriesBody = memories.map((memory) => ({ memory_id: memory, })); const response = await this._fetchWithErrorHandling( `${this.host}/v1/batch/`, { method: "DELETE", headers: this.headers, body: JSON.stringify({ memories: memoriesBody }), }, ); return response; } async getProject(options: ProjectOptions): Promise { const payloadKeys = Object.keys(options || {}); this._captureEvent("get_project", [payloadKeys]); const { fields } = options; await this._awaitIdentity(); if (!(this.organizationId && this.projectId)) { throw new Error( "organizationId and projectId must be set to access instructions or categories", ); } const params = new URLSearchParams(); fields?.forEach((field) => params.append("fields", camelToSnake(field))); const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/orgs/organizations/${this.organizationId}/projects/${this.projectId}/?${params.toString()}`, { headers: this.headers, }, ); return response; } async updateProject( prompts: PromptUpdatePayload, ): Promise> { this._captureEvent("update_project", []); await this._awaitIdentity(); if (!(this.organizationId && this.projectId)) { throw new Error( "organizationId and projectId must be set to update instructions or categories", ); } const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/orgs/organizations/${this.organizationId}/projects/${this.projectId}/`, { method: "PATCH", headers: this.headers, body: JSON.stringify(camelToSnakeKeys(prompts)), }, ); return response; } // WebHooks async getWebhooks(data?: { projectId?: string }): Promise> { this._captureEvent("get_webhooks", []); if (!data?.projectId) await this._awaitIdentity(); const project_id = data?.projectId || this.projectId; if (!project_id) { throw new Error("projectId must be set to access webhooks"); } const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/webhooks/projects/${project_id}/`, { headers: this.headers, }, ); return response; } async createWebhook(webhook: WebhookCreatePayload): Promise { this._captureEvent("create_webhook", []); await this._awaitIdentity(); if (!this.projectId) { throw new Error("projectId must be set to create a webhook"); } const body = { name: webhook.name, url: webhook.url, event_types: webhook.eventTypes, }; const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/webhooks/projects/${this.projectId}/`, { method: "POST", headers: this.headers, body: JSON.stringify(body), }, ); return response; } async updateWebhook( webhook: WebhookUpdatePayload, ): Promise<{ message: string }> { this._captureEvent("update_webhook", []); const body: Record = {}; if (webhook.name != null) body.name = webhook.name; if (webhook.url != null) body.url = webhook.url; if (webhook.eventTypes != null) body.event_types = webhook.eventTypes; const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/webhooks/${webhook.webhookId}/`, { method: "PUT", headers: this.headers, body: JSON.stringify(body), }, ); return response; } async deleteWebhook(data: { webhookId: string; }): Promise<{ message: string }> { this._captureEvent("delete_webhook", []); const webhook_id = data.webhookId || data; const response = await this._fetchWithErrorHandling( `${this.host}/api/v1/webhooks/${webhook_id}/`, { method: "DELETE", headers: this.headers, }, ); return response; } async feedback(data: FeedbackPayload): Promise<{ message: string }> { const payloadKeys = Object.keys(data || {}); this._captureEvent("feedback", [payloadKeys]); const response = await this._fetchWithErrorHandling( `${this.host}/v1/feedback/`, { method: "POST", headers: this.headers, body: JSON.stringify(camelToSnakeKeys(data)), }, ); return response; } /** * Get the memory profile for a single entity. * * Branch on `status`, not on an empty `profile`: generation is asynchronous, * so a known entity without a profile yet is a normal response. */ async getProfile(data: { entityId: string; entityType?: ProfileEntityType; }): Promise { this._captureEvent("get_profile", []); await this._awaitIdentity(); const entityType = data.entityType ?? "user"; const response = await this._fetchWithErrorHandling( `${this.host}/v2/entities/${encodeURIComponent(entityType)}/${encodeURIComponent(data.entityId)}/profile/`, { headers: this.headers, }, ); return response; } /** * Generate or refresh the profile for one entity, now. * * Profiles are otherwise built once an entity crosses an internal message * threshold, so a new entity has none for its first few memories. Returns as * soon as the work is queued: poll {@link getProfile} and branch on `status`. */ async generateProfile(data: { entityId: string; entityType?: ProfileEntityType; }): Promise { this._captureEvent("generate_profile", []); await this._awaitIdentity(); const response = await this._fetchWithErrorHandling( `${this.host}/v2/profiles/trigger/`, { method: "POST", headers: this.headers, body: JSON.stringify({ entity_type: data.entityType ?? "user", entity_id: data.entityId, }), }, ); return response; } /** Get the profile settings for the current project. */ async getProfileSettings(): Promise { this._captureEvent("get_profile_settings", []); await this._awaitIdentity(); const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, { headers: this.headers, }); return this._settingsWithVerbatimSchema(raw); } /** The envelope keys are ours; the schema's property names are the customer's. */ private _settingsWithVerbatimSchema(raw: any): ProfileSettings { const settings = snakeToCamelKeys(raw) as ProfileSettings; if (raw && typeof raw === "object" && "schema" in raw) { settings.schema = raw.schema; } return settings; } /** Update profile settings. Only the fields you pass are written. */ async updateProfileSettings( settings: ProfileSettings, ): Promise { const payloadKeys = Object.keys(settings || {}); this._captureEvent("update_profile_settings", [payloadKeys]); await this._awaitIdentity(); // The schema's property names are the customer's and must reach the API verbatim. const { schema, ...rest } = settings; const body: Record = camelToSnakeKeys(rest); if (schema !== undefined) { body.schema = schema; } const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, { method: "POST", headers: this.headers, body: JSON.stringify(body), }); return this._settingsWithVerbatimSchema(raw); } /** * Generate profiles for a few real entities, to check a schema. * * Real generations against real memories, and the results are kept. */ async sampleProfiles(data?: { limit?: number; }): Promise { this._captureEvent("sample_profiles", []); await this._awaitIdentity(); const response = await this._fetchWithErrorHandling( `${this.host}/v2/profiles/samples/`, { method: "POST", headers: this.headers, body: JSON.stringify(this._prepareParams({ limit: data?.limit })), }, ); return response; } /** * Rebuild the profile of every entity in the project. * * This is how a new schema reaches entities that already have a profile. */ async regenerateProfiles(): Promise { this._captureEvent("regenerate_profiles", []); await this._awaitIdentity(); const response = await this._fetchWithErrorHandling( `${this.host}/v2/profiles/regenerate/`, { method: "POST", headers: this.headers, body: JSON.stringify({}), }, ); return response; } async createMemoryExport( data: CreateMemoryExportPayload, ): Promise<{ message: string; id: string }> { this._captureEvent("create_memory_export", []); if (!data.filters || !data.schema) { throw new Error("Missing filters or schema"); } // filters and schema are user-controlled blobs whose keys must reach the // API verbatim; only the remaining SDK params (e.g. exportInstructions) // get camel->snake conversion. See issue #5593. const { filters, schema, ...rest } = data; const response = await this._fetchWithErrorHandling( `${this.host}/v1/exports/`, { method: "POST", headers: this.headers, body: JSON.stringify({ ...camelToSnakeKeys(rest), filters, schema, }), }, ); return response; } async getMemoryExport( data: GetMemoryExportPayload, ): Promise<{ message: string; id: string }> { this._captureEvent("get_memory_export", []); if (!data.memoryExportId && !data.filters) { throw new Error("Missing memoryExportId or filters"); } const { filters, ...rest } = data; const response = await this._fetchWithErrorHandling( `${this.host}/v1/exports/get/`, { method: "POST", headers: this.headers, body: JSON.stringify({ ...camelToSnakeKeys(rest), ...(filters && { filters }), }), }, ); return response; } } export { MemoryClient };