feat(profiles): User Profiles v1 — SDK methods + docs (#7340)

Co-authored-by: Pratik <10096516+pratikgajjar@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Karthik
2026-09-24 00:11:22 +05:30
committed by GitHub
co-authored by Pratik Claude Opus 4.8
parent 0cddc36d52
commit ea9bbcabed
20 changed files with 2961 additions and 8 deletions
+8
View File
@@ -23,6 +23,14 @@ export type {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileStatus,
ProfileResponse,
ProfileJobResponse,
ProfileJobStatus,
ProfileSettings,
ProfileSettingsResponse,
EntityProfileSettings,
} from "./mem0.types";
// Re-export enums as values (not type-only)
+203 -3
View File
@@ -1,4 +1,5 @@
import axios from "axios";
import { v7 as uuidv7 } from "uuid";
import {
AllUsers,
PaginatedMemories,
@@ -20,6 +21,12 @@ import {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileResponse,
ProfileJobResponse,
ProfileJobStatus,
ProfileSettings,
ProfileSettingsResponse,
} from "./mem0.types";
import {
captureClientEvent,
@@ -92,6 +99,9 @@ interface ClientIdentity {
}
// Shares one ping per (host, api key) across clients; FIFO-capped.
// One collection for every generation; the operation is a body field.
const PROFILE_JOBS_PATH = "/v2/profiles/jobs/";
const IDENTITY_CACHE_MAX_DEFAULT = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
@@ -305,7 +315,8 @@ export default class MemoryClient {
});
}
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
/** Fetch with no key conversion, for payloads carrying user-controlled property names. */
async _fetchRawJson(url: string, options: any): Promise<any> {
const response = await fetch(url, {
...options,
headers: {
@@ -318,8 +329,11 @@ export default class MemoryClient {
const errorData = await response.text();
throw createExceptionFromResponse(response.status, errorData);
}
const jsonResponse = await response.json();
return snakeToCamelKeys(jsonResponse);
return response.json();
}
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
return snakeToCamelKeys(await this._fetchRawJson(url, options));
}
_preparePayload(
@@ -816,6 +830,192 @@ export default class MemoryClient {
return response;
}
/**
* Get the memory profile for a single user.
*
* Branch on `status`, not on an empty `profile`: generation is asynchronous,
* so a known user without a profile yet is a normal response.
*/
async getProfile(data: { entityId: string }): Promise<ProfileResponse> {
this._captureEvent("get_profile", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/entities/user/${encodeURIComponent(data.entityId)}/profile/`,
{
headers: this.headers,
},
);
return response;
}
/**
* Generate or refresh the profile for one user, now.
*
* Profiles are otherwise built once a user crosses an internal message
* threshold, so a new user has none for its first few memories. Returns as
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
*
* Pass `idempotencyKey` and reuse it to retry a lost request without starting
* (and being billed for) a second job.
*/
async generateProfile(data: {
entityId: string;
idempotencyKey?: string;
}): Promise<ProfileJobResponse> {
this._captureEvent("generate_profile", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: {
...this.headers,
"Idempotency-Key": data.idempotencyKey ?? uuidv7(),
},
body: JSON.stringify({
operation: "trigger",
entity_type: "user",
entity_id: data.entityId,
}),
},
);
return response;
}
/** Get the profile settings for the current project. */
async getProfileSettings(): Promise<ProfileSettingsResponse> {
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.
*
* Each entity type carries its own schema, so every one has to be restored
* from the raw body — otherwise camel-casing rewrites the customer's field
* names and a profile comes back under keys they never chose.
*/
private _settingsWithVerbatimSchema(raw: any): ProfileSettingsResponse {
const settings = snakeToCamelKeys(raw) as ProfileSettingsResponse;
if (!raw || typeof raw !== "object") {
return settings;
}
const rawEntities = raw.entities;
if (rawEntities && typeof rawEntities === "object") {
for (const [entityType, entitySettings] of Object.entries(rawEntities)) {
if (
entitySettings &&
typeof entitySettings === "object" &&
"schema" in entitySettings &&
settings.entities?.[entityType as ProfileEntityType]
) {
settings.entities[entityType as ProfileEntityType]!.schema = (
entitySettings as Record<string, any>
).schema;
}
}
}
return settings;
}
/**
* Update profile settings. Only the fields you pass are written.
*
* `schema` and `customInstructions` are per user and are nested under
* `entities` for the API; only `enabled` is project-wide. Sending them flat
* is rejected with `Unsupported settings`.
*/
async updateProfileSettings(
settings: ProfileSettings,
): Promise<ProfileSettingsResponse> {
const payloadKeys = Object.keys(settings || {});
this._captureEvent("update_profile_settings", [payloadKeys]);
await this._awaitIdentity();
const { schema, customInstructions, enabled } = settings || {};
const body: Record<string, any> = {};
if (enabled !== undefined) {
body.enabled = enabled;
}
const entitySettings: Record<string, any> = {};
// The schema's property names are the customer's and must reach the API verbatim.
if (schema !== undefined) {
entitySettings.schema = schema;
}
if (customInstructions !== undefined) {
entitySettings.custom_instructions = customInstructions;
}
if (Object.keys(entitySettings).length > 0) {
body.entities = { user: entitySettings };
}
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 users, to check a schema.
*
* Real generations against real memories, and the results are kept: the
* profiles are written to those users and count toward usage.
*/
async sampleProfiles(data?: {
limit?: number;
idempotencyKey?: string;
}): Promise<ProfileJobResponse> {
this._captureEvent("sample_profiles", []);
await this._awaitIdentity();
const response = await this._fetchWithErrorHandling(
`${this.host}${PROFILE_JOBS_PATH}`,
{
method: "POST",
headers: {
...this.headers,
"Idempotency-Key": data?.idempotencyKey ?? uuidv7(),
},
body: JSON.stringify({
operation: "sample",
// Required: the API refuses a job that does not name an entity kind.
entity_type: "user",
...this._prepareParams({ limit: data?.limit }),
}),
},
);
return response;
}
/**
* Read one generation job. Accepts the `statusUrl` from a create call, or a
* bare job id. Prefer `statusUrl` so a route change needs no client update.
*/
async getProfileJob(jobIdOrStatusUrl: string): Promise<ProfileJobStatus> {
await this._awaitIdentity();
const path = jobIdOrStatusUrl.startsWith("/")
? jobIdOrStatusUrl
: `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`;
return this._fetchWithErrorHandling(`${this.host}${path}`, {
method: "GET",
headers: this.headers,
});
}
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
+95
View File
@@ -239,3 +239,98 @@ export interface GetMemoryExportPayload {
filters?: Record<string, any>;
memoryExportId?: string;
}
// ─── Profile Types ──────────────────────────────────────────
/** The entity kind that carries a profile. */
export type ProfileEntityType = "user";
/**
* `succeeded` is the only state in which `profile` is guaranteed to hold content.
*
* These are values, not keys, so the client does not camel-case them: the wire
* spelling is what a comparison has to match.
*/
export type ProfileStatus =
| "succeeded"
| "pending"
| "failed"
| "not_enabled"
| "insufficient_data";
export interface ProfileResponse {
/** Shaped by the project's schema; keys are not camel-cased. */
profile: Record<string, any>;
status: ProfileStatus;
entityType: ProfileEntityType;
entityId: string;
updatedAt: string | null;
generationCount: number;
/** The last generation's failure reason, when `status` is `failed`. */
error?: string | null;
}
/** Every accepted generation. `statusUrl` is the server's own poll path. */
export interface ProfileJobResponse {
jobId: string;
status: string;
statusUrl: string;
operation: string;
entityType: ProfileEntityType;
/** Entities reserved against usage for this job. */
entityCountReserved?: number;
/** Null when the job has no associated event. */
eventId?: string | null;
replayed?: boolean;
/** Sample runs only: how many entities were picked. */
sampled?: number;
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
entityIds?: string[];
}
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
export interface ProfileSettings {
/** Turn profile generation on or off. Project-wide. */
enabled?: boolean;
/** JSON Schema for the profile. Every property needs a `description`. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
}
/** One entity type's stored configuration. */
export interface EntityProfileSettings {
/** The customer's JSON Schema, with its property names verbatim. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
[key: string]: any;
}
/**
* The settings as stored. `schema` and `customInstructions` are per entity
* type and nest under `entities`; only `enabled` is project-wide.
*/
export interface ProfileSettingsResponse {
enabled?: boolean;
entities?: Partial<Record<ProfileEntityType, EntityProfileSettings>>;
capabilities?: Record<string, any>;
[key: string]: any;
}
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
export interface ProfileJobStatus {
job: {
id: string;
operation: string;
entityType: ProfileEntityType;
status: string;
/** Null until `enumerationComplete`. */
total: number | null;
enumerationComplete: boolean;
/** succeeded + failed + skipped. */
completed: number;
succeeded: number;
failed: number;
skipped: number;
[key: string]: any;
};
}
@@ -0,0 +1,341 @@
/**
* MemoryClient unit tests — profiles.
* Verifies request construction and the verbatim round-trip of user-controlled
* profile/schema keys, not mock response echo.
*/
import { MemoryClient } from "../mem0";
import type { ProfileStatus } from "../mem0.types";
import { TEST_API_KEY } from "./helpers";
import {
setupMockFetch,
findFetchCall,
getFetchBody,
installConsoleSuppression,
} from "./setup";
installConsoleSuppression();
describe("MemoryClient - getProfile()", () => {
test("reads the v2 entity route and keeps profile keys verbatim", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/alice/profile/", {
status: 200,
body: {
// Customer schema keys: camel-casing these would break the schema they wrote.
profile: {
favorite_topics: ["hiking"],
work_style: { preferred_hours: "mornings" },
},
status: "succeeded",
entity_type: "user",
entity_id: "alice",
updated_at: "2026-02-08T00:00:00Z",
generation_count: 3,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/entities/user/alice/profile/");
expect(call).toBeDefined();
expect(result.profile).toEqual({
favorite_topics: ["hiking"],
work_style: { preferred_hours: "mornings" },
});
expect(result.entityType).toBe("user");
expect(result.entityId).toBe("alice");
expect(result.generationCount).toBe(3);
expect(result.status).toBe("succeeded");
});
test("status keeps its wire spelling", async () => {
// A status is a VALUE, not a key, so the client does not camel-case it.
// Declaring the union as `insufficientData` made tsc reject the comparison
// that works and accept one that can never be true.
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/bob/profile/", {
status: 200,
body: {
profile: {},
status: "insufficient_data",
entity_type: "user",
entity_id: "bob",
},
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfile({ entityId: "bob" });
expect(result.status).toBe("insufficient_data");
// Assignable without a cast: the declared union must contain the wire value.
const status: ProfileStatus = result.status;
expect(status).not.toBe("insufficientData");
});
test("scopes to user and encodes the entity id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/", {
status: 200,
body: { profile: {}, status: "pending", entity_type: "user" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getProfile({ entityId: "a/b" });
const call = findFetchCall(mock, "/v2/entities/user/a%2Fb/profile/");
expect(call).toBeDefined();
});
});
describe("MemoryClient - generateProfile()", () => {
test("sends operation trigger with the entity to the jobs collection", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "00000000-0000-7000-8000-000000000001",
status: "QUEUED",
status_url: "/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
operation: "trigger",
entity_type: "user",
entity_count_reserved: 1,
event_id: "00000000-0000-7000-8000-000000000002",
replayed: false,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.generateProfile({ entityId: "alice" });
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
expect(body.operation).toBe("trigger");
expect(body.entity_type).toBe("user");
expect(body.entity_id).toBe("alice");
// A generated Idempotency-Key is always sent so the create can be retried safely.
const headers = call![1].headers as Record<string, string>;
expect(headers["Idempotency-Key"]).toBeTruthy();
expect(result.jobId).toBe("00000000-0000-7000-8000-000000000001");
expect(result.statusUrl).toBe(
"/v2/profiles/jobs/00000000-0000-7000-8000-000000000001/",
);
expect(result.replayed).toBe(false);
});
test("reuses a caller-supplied idempotency key", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "j1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/j1/",
operation: "trigger",
entity_type: "user",
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.generateProfile({
entityId: "alice",
idempotencyKey: "retry-key-123",
});
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
const headers = call![1].headers as Record<string, string>;
expect(headers["Idempotency-Key"]).toBe("retry-key-123");
});
});
describe("MemoryClient - profile settings", () => {
test("sends schema property names verbatim and returns them unchanged", async () => {
const schema = {
type: "object",
properties: {
favorite_topics: {
type: "array",
description: "Topics the user returns to",
items: { type: "string" },
},
workStyle: {
type: "string",
description: "How the user prefers to work",
},
},
};
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: true,
entities: {
user: {
schema,
custom_instructions: "Focus on durable preferences",
},
},
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.updateProfileSettings({
enabled: true,
schema,
customInstructions: "Focus on durable preferences",
});
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
// Mixed casing goes out exactly as written, nested under the entity type.
// Exact, not a subset match: the customer's keys must go out unchanged.
expect(body.entities).toEqual({
user: { schema, custom_instructions: "Focus on durable preferences" },
});
expect(body.enabled).toBe(true);
// A flat schema is rejected by the API with "Unsupported settings".
expect("schema" in body).toBe(false);
// And the customer's property names survive the round trip.
expect(result.entities?.user?.schema).toEqual(schema);
expect(result.entities?.user?.customInstructions).toBe(
"Focus on durable preferences",
);
});
test("scopes entity-level settings under user", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: { enabled: true },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.updateProfileSettings({
schema: { type: "object", properties: {} },
});
const body = getFetchBody(
findFetchCall(mock, "/v2/profiles/settings/", "POST")!,
);
expect(Object.keys(body.entities as object)).toEqual(["user"]);
});
test("omits fields the caller did not set", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: false,
entities: { user: { schema: null, custom_instructions: null } },
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.updateProfileSettings({ enabled: false });
const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
const body = getFetchBody(call!);
expect(body.enabled).toBe(false);
// Nothing entity-scoped was passed, so no entities key is sent at all.
expect("entities" in body).toBe(false);
expect("schema" in body).toBe(false);
expect("custom_instructions" in body).toBe(false);
});
test("getProfileSettings reads the v2 route", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/settings/", {
status: 200,
body: {
enabled: true,
entities: {
user: {
schema: { properties: { favorite_topics: { type: "array" } } },
custom_instructions: null,
},
},
capabilities: { full_rebuild: false },
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.getProfileSettings();
expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined();
expect(result.enabled).toBe(true);
expect(result.entities?.user?.schema).toEqual({
properties: { favorite_topics: { type: "array" } },
});
expect(result.capabilities?.fullRebuild).toBe(false);
});
});
describe("MemoryClient - sampleProfiles()", () => {
test("sampleProfiles omits limit when unset", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "job_1",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_1/",
operation: "sample",
entity_type: "user",
sampled: 5,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.sampleProfiles();
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
// Every job names an entity kind: the API refuses one that does not.
expect(getFetchBody(call!)).toEqual({
operation: "sample",
entity_type: "user",
});
});
test("sampleProfiles passes an explicit limit", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/jobs/", {
status: 202,
body: {
job_id: "job_2",
status: "QUEUED",
status_url: "/v2/profiles/jobs/job_2/",
operation: "sample",
entity_type: "user",
sampled: 3,
results: [],
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.sampleProfiles({ limit: 3 });
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
expect(getFetchBody(call!).operation).toBe("sample");
expect(getFetchBody(call!).limit).toBe(3);
expect(getFetchBody(call!).entity_type).toBe("user");
expect(result.sampled).toBe(3);
});
});
+3
View File
@@ -34,6 +34,9 @@ const OPAQUE_VALUE_KEYS = new Set([
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
"customCategories",
"custom_categories",
// A profile's keys come from the customer's own JSON Schema. The schema itself
// is handled in `updateProfileSettings`, so that `schema` is not opaque globally.
"profile",
]);
/**