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:
@@ -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
@@ -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 }> {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user