refactor: update MemoryClient to use entity options instead of filters

This commit is contained in:
kartik-mem0
2026-04-14 17:13:52 +05:30
parent 28cc8fcee4
commit c3f48d093e
21 changed files with 201 additions and 249 deletions
+15 -30
View File
@@ -23,8 +23,17 @@ import { captureClientEvent, generateHash } from "./telemetry";
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
// Entity parameters that must be passed via filters, not top-level (snake_case only - matches API)
const ENTITY_PARAMS = ["user_id", "agent_id", "app_id", "run_id"];
// 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.
@@ -207,13 +216,7 @@ export default class MemoryClient {
): Promise<Array<Memory>> {
if (this.telemetryId === "") await this.ping();
// Extract filters and spread entity IDs into payload (API expects top-level entity IDs)
const { filters, ...rest } = options;
const payload: Record<string, any> = {
messages,
...camelToSnakeKeys(rest),
...(filters && filters), // Spread filters content into payload
};
const payload = this._preparePayload(messages, options);
const payloadKeys = Object.keys(payload);
this._captureEvent("add", [payloadKeys]);
@@ -355,27 +358,9 @@ export default class MemoryClient {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("delete_all", [payloadKeys]);
// Extract filters and build query params from filters (snake_case keys)
const { filters, ...rest } = options;
const queryParams: Record<string, string> = {};
if (filters) {
// Pass filter keys directly as query params (already snake_case)
for (const [key, value] of Object.entries(filters)) {
if (value != null) {
queryParams[key] = String(value);
}
}
}
// Add any other non-filter params
const otherParams = camelToSnakeKeys(this._prepareParams(rest));
for (const [key, value] of Object.entries(otherParams)) {
if (value != null) {
queryParams[key] = String(value);
}
}
const params = new URLSearchParams(queryParams);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
// @ts-ignore
const params = new URLSearchParams(snakeOptions);
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/?${params}`,
{
+10 -5
View File
@@ -1,6 +1,13 @@
// ─── Entity Options (for add/delete — top-level identity) ───
export interface EntityOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
// ─── Per-Method Options ─────────────────────────────────────
export interface AddMemoryOptions {
filters?: Record<string, any>;
export interface AddMemoryOptions extends EntityOptions {
metadata?: Record<string, any>;
infer?: boolean;
customCategories?: custom_categories[];
@@ -28,9 +35,7 @@ export interface GetAllMemoryOptions {
categories?: string[];
}
export interface DeleteAllMemoryOptions {
filters?: Record<string, any>;
}
export interface DeleteAllMemoryOptions extends EntityOptions {}
// ─── Project Options ────────────────────────────────────────
export interface ProjectOptions {
@@ -52,7 +52,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
];
const result = await client.add(messages, {
filters: { user_id: TEST_USER_ID },
userId: TEST_USER_ID,
});
// v3 API processes memories asynchronously — returns PENDING
@@ -73,7 +73,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
];
const result = await client.add(messages, {
filters: { user_id: TEST_USER_ID },
userId: TEST_USER_ID,
});
expect(result).toHaveProperty("eventId");
});
@@ -208,7 +208,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
test("deleteAll for non-existent user does not throw", async () => {
const result = await client.deleteAll({
filters: { user_id: `nonexistent-user-${randomUUID()}` },
userId: `nonexistent-user-${randomUUID()}`,
});
expect(result).toBeDefined();
@@ -239,7 +239,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
describe("cleanup operations", () => {
test("deletes all memories for test user", async () => {
const result = await client.deleteAll({
filters: { user_id: TEST_USER_ID },
userId: TEST_USER_ID,
});
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
@@ -18,12 +18,10 @@ export default async function globalSetup() {
// Full project wipe — all four filters set explicitly
try {
await client.deleteAll({
filters: {
user_id: "*",
agent_id: "*",
app_id: "*",
run_id: "*",
},
userId: "*",
agentId: "*",
appId: "*",
runId: "*",
});
} catch {
// ignore — may 404 if no data exists
@@ -17,12 +17,10 @@ export default async function globalTeardown() {
try {
await client.deleteAll({
filters: {
user_id: "*",
agent_id: "*",
app_id: "*",
run_id: "*",
},
userId: "*",
agentId: "*",
appId: "*",
runId: "*",
});
} catch {
// ignore
@@ -187,7 +187,7 @@ export async function cleanupTestUser(
userId: string,
): Promise<void> {
try {
await client.deleteAll({ filters: { user_id: userId } });
await client.deleteAll({ userId });
} catch {
// ignore
}
@@ -210,12 +210,10 @@ export async function fullProjectCleanup(client: MemoryClient): Promise<void> {
// Delete all memories — all four filters set explicitly
try {
await client.deleteAll({
filters: {
user_id: "*",
agent_id: "*",
app_id: "*",
run_id: "*",
},
userId: "*",
agentId: "*",
appId: "*",
runId: "*",
});
} catch {
// ignore — may 404 if no data exists
@@ -27,9 +27,7 @@ describe("MemoryClient - add()", () => {
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "Hello" }], {
filters: { user_id: "u1" },
});
await client.add([{ role: "user", content: "Hello" }], { userId: "u1" });
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
});
@@ -41,26 +39,24 @@ describe("MemoryClient - add()", () => {
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add(messages, { filters: { user_id: "u1" } });
await client.add(messages, { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).messages).toEqual(messages);
});
test("spreads filters into top-level request body (API expects flat entity IDs)", async () => {
test("includes user_id in request body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "test" }], {
filters: { user_id: "user_1" },
user_id: "user_1",
});
const call = findFetchCall(mock, "/v3/memories/", "POST");
const body = getFetchBody(call!) as { user_id: string };
// Entity IDs are spread into top level, not nested under filters
expect(body.user_id).toBe("user_1");
expect(getFetchBody(call!).user_id).toBe("user_1");
});
test("sends empty messages array without crashing", async () => {
@@ -69,7 +65,7 @@ describe("MemoryClient - add()", () => {
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([], { filters: { user_id: "u1" } });
await client.add([], { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).messages).toEqual([]);
@@ -219,7 +215,7 @@ describe("MemoryClient - deleteAll()", () => {
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.deleteAll({ filters: { user_id: "u1" } });
await client.deleteAll({ userId: "u1" });
const call = mock.mock.calls.find(
(c: [string, RequestInit]) =>
@@ -235,7 +231,7 @@ describe("MemoryClient - deleteAll()", () => {
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.deleteAll({ filters: { user_id: "user@email.com" } });
await client.deleteAll({ userId: "user@email.com" });
const call = mock.mock.calls.find(
(c: [string, RequestInit]) =>
+3 -3
View File
@@ -30,7 +30,7 @@ async function runTests(memory: Memory) {
const result1 = await memory.add(
"Hi, my name is John and I am a software engineer.",
{
filters: { user_id: "john" },
userId: "john",
},
);
console.log("Added memory:", result1);
@@ -43,7 +43,7 @@ async function runTests(memory: Memory) {
{ role: "assistant", content: "I love Paris, it is my favorite city." },
],
{
filters: { user_id: "john" },
userId: "john",
},
);
console.log("Added messages:", result2);
@@ -58,7 +58,7 @@ async function runTests(memory: Memory) {
},
],
{
filters: { user_id: "john" },
userId: "john",
},
);
console.log("Updated messages:", result3);
+1 -1
View File
@@ -52,7 +52,7 @@ ${memoriesStr}`;
const assistantResponse = response.message.content || "";
messages.push({ role: "assistant", content: assistantResponse });
await memory.add(messages, { filters: { user_id: userId } });
await memory.add(messages, { userId: userId });
return assistantResponse;
}
+3 -3
View File
@@ -12,7 +12,7 @@ export async function runTests(memory: Memory) {
const result1 = await memory.add(
"Hi, my name is John and I am a software engineer.",
{
filters: { user_id: "john" },
userId: "john",
},
);
console.log("Added memory:", result1);
@@ -25,7 +25,7 @@ export async function runTests(memory: Memory) {
{ role: "assistant", content: "I love Paris, it is my favorite city." },
],
{
filters: { user_id: "john" },
userId: "john",
},
);
console.log("Added messages:", result2);
@@ -40,7 +40,7 @@ export async function runTests(memory: Memory) {
},
],
{
filters: { user_id: "john" },
userId: "john",
},
);
console.log("Updated messages:", result3);
+36 -20
View File
@@ -53,8 +53,15 @@ import {
ScoredResult,
} from "../utils/scoring";
// Entity parameters that must be passed via filters, not top-level (snake_case only - matches API)
const ENTITY_PARAMS = ["user_id", "agent_id", "run_id"];
// Entity params that must be passed via filters - check both snake_case and camelCase
const ENTITY_PARAMS = [
"user_id",
"agent_id",
"run_id",
"userId",
"agentId",
"runId",
];
/**
* Validates that no top-level entity parameters are passed in config.
@@ -70,7 +77,7 @@ function rejectTopLevelEntityParams(
if (invalidKeys.length > 0) {
throw new Error(
`Top-level entity parameters [${invalidKeys.join(", ")}] are not supported in ${methodName}(). ` +
`Use filters: { user_id: "..." } instead.`,
`Use filters: { userId: "..." } instead.`,
);
}
}
@@ -276,21 +283,26 @@ export class Memory {
has_filters: !!config.filters,
infer: config.infer,
});
const { metadata = {}, filters = {}, infer = true } = config;
const {
userId,
agentId,
runId,
metadata = {},
filters = {},
infer = true,
} = config;
// Convert camelCase entity params to snake_case for storage (matches API and search/getAll filters)
if (userId) filters.user_id = metadata.user_id = userId;
if (agentId) filters.agent_id = metadata.agent_id = agentId;
if (runId) filters.run_id = metadata.run_id = runId;
// Validate filters contains at least one entity ID (snake_case)
if (!filters.user_id && !filters.agent_id && !filters.run_id) {
throw new Error(
"filters must contain at least one of: user_id, agent_id, run_id. " +
"Example: { filters: { user_id: 'u1' } }",
"One of the filters: userId, agentId or runId is required!",
);
}
// Copy entity IDs to metadata for storage
if (filters.user_id) metadata.user_id = filters.user_id;
if (filters.agent_id) metadata.agent_id = filters.agent_id;
if (filters.run_id) metadata.run_id = filters.run_id;
const parsedMessages = Array.isArray(messages)
? (messages as Message[])
: [{ role: "user", content: messages }];
@@ -1017,18 +1029,22 @@ export class Memory {
config: DeleteAllMemoryOptions,
): Promise<{ message: string }> {
await this._ensureInitialized();
const { filters = {} } = config;
await this._captureEvent("delete_all", {
has_user_id: !!filters.user_id,
has_agent_id: !!filters.agent_id,
has_run_id: !!filters.run_id,
has_user_id: !!config.userId,
has_agent_id: !!config.agentId,
has_run_id: !!config.runId,
});
const { userId, agentId, runId } = config;
if (!filters.user_id && !filters.agent_id && !filters.run_id) {
// Convert camelCase entity params to snake_case for filters (matches storage and search/getAll)
const filters: SearchFilters = {};
if (userId) filters.user_id = userId;
if (agentId) filters.agent_id = agentId;
if (runId) filters.run_id = runId;
if (!Object.keys(filters).length) {
throw new Error(
"filters must contain at least one of: user_id, agent_id, run_id. " +
"If you want to delete all memories, use the `reset()` method.",
"At least one filter is required to delete all memories. If you want to delete all memories, use the `reset()` method.",
);
}
+8 -4
View File
@@ -1,7 +1,13 @@
import { Message } from "../types";
import { SearchFilters } from "../types";
export interface AddMemoryOptions {
export interface Entity {
userId?: string;
agentId?: string;
runId?: string;
}
export interface AddMemoryOptions extends Entity {
metadata?: Record<string, any>;
filters?: SearchFilters;
infer?: boolean;
@@ -18,6 +24,4 @@ export interface GetAllMemoryOptions {
filters?: SearchFilters;
}
export interface DeleteAllMemoryOptions {
filters?: SearchFilters;
}
export interface DeleteAllMemoryOptions extends Entity {}
+1 -1
View File
@@ -589,7 +589,7 @@ describe("Memory – LM Studio end-to-end flow", () => {
disableHistory: true,
});
await mem.add("I love sushi", { filters: { user_id: "u1" } });
await mem.add("I love sushi", { userId: "u1" });
expect(mockLlm.generateResponse).toHaveBeenCalled();
expect(mockEmbedder.embed).toHaveBeenCalled();
+13 -17
View File
@@ -96,7 +96,7 @@ describe("Memory - add()", () => {
test("returns SearchResult with results array for string input", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
filters: { user_id: userId },
userId,
});
expect(Array.isArray(result.results)).toBe(true);
});
@@ -104,7 +104,7 @@ describe("Memory - add()", () => {
test("returns at least one result with an id", async () => {
const result: SearchResult = await memory.add(
"I enjoy hiking in the mountains",
{ filters: { user_id: userId } },
{ userId },
);
expect(result.results.length).toBeGreaterThan(0);
expect(result.results[0].id).toBeDefined();
@@ -112,7 +112,7 @@ describe("Memory - add()", () => {
test("result item has a memory string field", async () => {
const result: SearchResult = await memory.add("My favorite color is blue", {
filters: { user_id: userId },
userId,
});
expect(typeof result.results[0].memory).toBe("string");
});
@@ -122,35 +122,31 @@ describe("Memory - add()", () => {
{ role: "user", content: "What is your favorite city?" },
{ role: "assistant", content: "I love Paris." },
];
const result: SearchResult = await memory.add(messages, {
filters: { user_id: userId },
});
const result: SearchResult = await memory.add(messages, { userId });
expect(result.results.length).toBeGreaterThan(0);
});
test("works with agent_id filter instead of user_id", async () => {
test("works with agentId instead of userId", async () => {
const result: SearchResult = await memory.add("test", {
filters: { agent_id: "agent_1" },
agentId: "agent_1",
});
expect(result.results.length).toBeGreaterThan(0);
});
test("works with run_id filter instead of user_id", async () => {
const result: SearchResult = await memory.add("test", {
filters: { run_id: "run_1" },
});
test("works with runId instead of userId", async () => {
const result: SearchResult = await memory.add("test", { runId: "run_1" });
expect(result.results.length).toBeGreaterThan(0);
});
test("throws when no user_id/agent_id/run_id provided in filters", async () => {
test("throws when no userId/agentId/runId provided", async () => {
await expect(memory.add("test", {} as any)).rejects.toThrow(
"filters must contain at least one of: user_id, agent_id, run_id",
"One of the filters: userId, agentId or runId is required!",
);
});
test("passes metadata through to stored memory", async () => {
const result: SearchResult = await memory.add("I love TypeScript", {
filters: { user_id: userId },
userId,
metadata: { source: "chat", tag: "programming" },
});
const stored: MemoryItem | null = await memory.get(result.results[0].id);
@@ -162,7 +158,7 @@ describe("Memory - add()", () => {
test("with infer=false skips LLM and stores messages directly", async () => {
const result: SearchResult = await memory.add("Direct storage content", {
filters: { user_id: userId },
userId,
infer: false,
});
expect(result.results.length).toBeGreaterThan(0);
@@ -172,7 +168,7 @@ describe("Memory - add()", () => {
test("with infer=false marks event as ADD in metadata", async () => {
const result: SearchResult = await memory.add("Direct fact", {
filters: { user_id: userId },
userId,
infer: false,
});
expect(result.results[0].metadata).toEqual(
+18 -18
View File
@@ -97,7 +97,7 @@ describe("Memory - get()", () => {
test("returns the memory matching the ID from add()", async () => {
const addResult: SearchResult = await memory.add("I love AI", {
filters: { user_id: userId },
userId,
});
const id = addResult.results[0].id;
const item: MemoryItem | null = await memory.get(id);
@@ -107,7 +107,7 @@ describe("Memory - get()", () => {
test("returns a string for the memory field", async () => {
const addResult: SearchResult = await memory.add("Testing get", {
filters: { user_id: userId },
userId,
});
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
expect(typeof item!.memory).toBe("string");
@@ -120,7 +120,7 @@ describe("Memory - get()", () => {
test("returns hash and createdAt on stored memory", async () => {
const addResult: SearchResult = await memory.add("Hash test", {
filters: { user_id: userId },
userId,
});
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
expect(typeof item!.hash).toBe("string");
@@ -146,7 +146,7 @@ describe("Memory - update()", () => {
// Use infer: false for update tests — bypasses LLM, gives us a stable ID
test("returns success message", async () => {
const addResult: SearchResult = await memory.add("Original", {
filters: { user_id: userId },
userId,
infer: false,
});
const id = addResult.results[0].id;
@@ -156,7 +156,7 @@ describe("Memory - update()", () => {
test("persists the updated text", async () => {
const addResult: SearchResult = await memory.add("Before update", {
filters: { user_id: userId },
userId,
infer: false,
});
const id = addResult.results[0].id;
@@ -167,7 +167,7 @@ describe("Memory - update()", () => {
test("preserves createdAt and sets updatedAt", async () => {
const addResult: SearchResult = await memory.add("Timestamp test", {
filters: { user_id: userId },
userId,
infer: false,
});
const id = addResult.results[0].id;
@@ -182,7 +182,7 @@ describe("Memory - update()", () => {
test("updates the hash", async () => {
const addResult: SearchResult = await memory.add("Hash change", {
filters: { user_id: userId },
userId,
infer: false,
});
const id = addResult.results[0].id;
@@ -209,7 +209,7 @@ describe("Memory - delete()", () => {
test("returns success message", async () => {
const addResult: SearchResult = await memory.add("Delete me", {
filters: { user_id: userId },
userId,
infer: false,
});
const result = await memory.delete(addResult.results[0].id);
@@ -218,7 +218,7 @@ describe("Memory - delete()", () => {
test("get() returns null after deletion", async () => {
const addResult: SearchResult = await memory.add("Temporary", {
filters: { user_id: userId },
userId,
infer: false,
});
const id = addResult.results[0].id;
@@ -242,9 +242,9 @@ describe("Memory - deleteAll()", () => {
});
test("removes all memories for the user and returns success", async () => {
await memory.add("Fact A", { filters: { user_id: userId } });
await memory.add("Fact B", { filters: { user_id: userId } });
const result = await memory.deleteAll({ filters: { user_id: userId } });
await memory.add("Fact A", { userId });
await memory.add("Fact B", { userId });
const result = await memory.deleteAll({ userId });
expect(result.message).toBe("Memories deleted successfully!");
const remaining: SearchResult = await memory.getAll({
filters: { user_id: userId },
@@ -254,7 +254,7 @@ describe("Memory - deleteAll()", () => {
test("throws when no filter is provided", async () => {
await expect(memory.deleteAll({} as any)).rejects.toThrow(
"filters must contain at least one of: user_id, agent_id, run_id",
"At least one filter is required to delete all memories",
);
});
});
@@ -274,8 +274,8 @@ describe("Memory - getAll()", () => {
});
test("returns all stored memories for the user", async () => {
await memory.add("First", { filters: { user_id: userId } });
await memory.add("Second", { filters: { user_id: userId } });
await memory.add("First", { userId });
await memory.add("Second", { userId });
const result: SearchResult = await memory.getAll({
filters: { user_id: userId },
});
@@ -309,7 +309,7 @@ describe("Memory - search()", () => {
beforeAll(async () => {
memory = createMemory();
await memory.add("I love TypeScript", { filters: { user_id: userId } });
await memory.add("I love TypeScript", { userId });
});
afterAll(async () => {
@@ -362,7 +362,7 @@ describe("Memory - history()", () => {
test("records ADD event after add()", async () => {
const addResult: SearchResult = await memory.add("New fact", {
filters: { user_id: userId },
userId,
});
const history = await memory.history(addResult.results[0].id);
expect(Array.isArray(history)).toBe(true);
@@ -371,7 +371,7 @@ describe("Memory - history()", () => {
test("records additional entry after update()", async () => {
const addResult: SearchResult = await memory.add("Before", {
filters: { user_id: userId },
userId,
});
const id = addResult.results[0].id;
await memory.update(id, "After");
+1 -1
View File
@@ -118,7 +118,7 @@ describe("Memory - reset()", () => {
const mem = createMemory();
const userId = `reset_test_${Date.now()}`;
await mem.add("Remember this fact", { filters: { user_id: userId } });
await mem.add("Remember this fact", { userId });
const before: SearchResult = await mem.getAll({
filters: { user_id: userId },
});
@@ -1070,9 +1070,7 @@ describe("Memory class – backward compat with all providers", () => {
expect(deleteResult.message).toBe("Memory deleted successfully!");
// deleteAll
const deleteAllResult = await mem.deleteAll({
filters: { user_id: "u1" },
});
const deleteAllResult = await mem.deleteAll({ userId: "u1" });
expect(deleteAllResult.message).toBe("Memories deleted successfully!");
// history