feat(profiles): SDK methods and docs for entity profiles

Adds the profile surface to both SDKs and documents it.

Python (MemoryClient + AsyncMemoryClient) and TypeScript gain:
get/generate profile, get/update settings, sample, regenerate.

A profile's keys come from the customer's own JSON Schema, so the TS
client keeps `profile` opaque and passes `schema` through untouched in
both directions. Camel-casing them would return field names that do not
match the schema the customer wrote.

Reads use the v2 envelope, where a known entity with no profile yet is a
200 carrying a status rather than a 404, so an empty state is
distinguishable from an error.

Verified against a live environment running this API: settings
round-trip, a partial update leaves the schema intact, generation
produces a profile under the configured schema, and sample, regenerate,
cooldown and the 400/404 paths all answer as documented.
This commit is contained in:
Pratik
2026-09-10 16:04:09 -07:00
parent d873892dad
commit 623e33f2db
17 changed files with 1668 additions and 4 deletions
+8
View File
@@ -23,6 +23,14 @@ export type {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileStatus,
ProfileResponse,
ProfileTriggerResponse,
ProfileSettings,
ProfileSampleResult,
ProfileSamplesResponse,
ProfileRegenerateResponse,
} from "./mem0.types";
// Re-export enums as values (not type-only)
+149 -3
View File
@@ -20,6 +20,12 @@ import {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
ProfileEntityType,
ProfileResponse,
ProfileTriggerResponse,
ProfileSettings,
ProfileSamplesResponse,
ProfileRegenerateResponse,
} from "./mem0.types";
import {
captureClientEvent,
@@ -244,7 +250,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: {
@@ -257,8 +264,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(
@@ -755,6 +765,142 @@ export default class MemoryClient {
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<ProfileResponse> {
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<ProfileTriggerResponse> {
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<ProfileSettings> {
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<ProfileSettings> {
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<string, any> = 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<ProfileSamplesResponse> {
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<ProfileRegenerateResponse> {
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 }> {
+54
View File
@@ -236,3 +236,57 @@ export interface GetMemoryExportPayload {
filters?: Record<string, any>;
memoryExportId?: string;
}
// ─── Profile Types ──────────────────────────────────────────
/** Entity kinds that can carry a profile. */
export type ProfileEntityType = "user" | "agent";
/** `succeeded` is the only state in which `profile` is guaranteed to hold content. */
export type ProfileStatus =
"succeeded" | "pending" | "failed" | "notEnabled" | "insufficientData";
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;
}
export interface ProfileTriggerResponse {
message: string;
entityType: ProfileEntityType;
entityId: string;
profileId: string;
status: string;
}
export interface ProfileSettings {
enabled?: boolean;
/** JSON Schema for the profile. Every property needs a `description`. */
schema?: Record<string, any> | null;
customInstructions?: string | null;
}
export interface ProfileSampleResult {
entityType: ProfileEntityType;
entityId: string;
profileId?: string;
[key: string]: any;
}
export interface ProfileSamplesResponse {
message: string;
sampled: number;
results: Array<ProfileSampleResult>;
}
export interface ProfileRegenerateResponse {
status: string;
message: string;
projectId: string;
existingProfileCount: number;
}
@@ -0,0 +1,238 @@
/**
* 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 { 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("defaults to user and encodes the entity id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/agent/", {
status: 200,
body: { profile: {}, status: "pending", entity_type: "agent" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getProfile({ entityId: "a/b", entityType: "agent" });
const call = findFetchCall(mock, "/v2/entities/agent/a%2Fb/profile/");
expect(call).toBeDefined();
});
});
describe("MemoryClient - generateProfile()", () => {
test("posts entity_type and entity_id to the trigger route", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/trigger/", {
status: 202,
body: {
message: "Profile generation started.",
entity_type: "user",
entity_id: "alice",
profile_id: "p_1",
status: "PENDING",
},
});
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/trigger/", "POST");
expect(call).toBeDefined();
const body = getFetchBody(call!);
expect(body.entity_type).toBe("user");
expect(body.entity_id).toBe("alice");
expect(result.profileId).toBe("p_1");
});
});
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,
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.
expect(body.schema).toEqual(schema);
expect(body.custom_instructions).toBe("Focus on durable preferences");
expect(body.enabled).toBe(true);
expect(result.schema).toEqual(schema);
expect(result.customInstructions).toBe("Focus on durable preferences");
});
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, 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);
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,
schema: { properties: { favorite_topics: { type: "array" } } },
custom_instructions: null,
},
});
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.schema).toEqual({
properties: { favorite_topics: { type: "array" } },
});
});
});
describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => {
test("sampleProfiles omits limit when unset", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/samples/", {
status: 202,
body: { message: "Sampling 5 users.", sampled: 5, results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.sampleProfiles();
const call = findFetchCall(mock, "/v2/profiles/samples/", "POST");
expect(getFetchBody(call!)).toEqual({});
});
test("sampleProfiles passes an explicit limit", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/samples/", {
status: 202,
body: { message: "Sampling 3 users.", 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/samples/", "POST");
expect(getFetchBody(call!).limit).toBe(3);
expect(result.sampled).toBe(3);
});
test("regenerateProfiles posts to the regenerate route", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/profiles/regenerate/", {
status: 202,
body: {
status: "accepted",
message: "Regenerating profiles.",
project_id: "proj_abc",
existing_profile_count: 12,
},
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result = await client.regenerateProfiles();
expect(
findFetchCall(mock, "/v2/profiles/regenerate/", "POST"),
).toBeDefined();
expect(result.existingProfileCount).toBe(12);
expect(result.projectId).toBe("proj_abc");
});
});
+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",
]);
/**