feat(oss): port v3 pipeline with hybrid search, entity extraction, and additive scoring (#4805)

Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
Co-authored-by: chaithanyak42 <chaithanya.kumar42a@gmail.com>
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
soumil-rathi
2026-04-14 18:00:58 +05:30
committed by GitHub
co-authored by Soumil Rathi Saket Aryan chaithanyak42 kartik-mem0
parent 57f944e18a
commit a488e19044
120 changed files with 10107 additions and 17135 deletions
-1
View File
@@ -3,7 +3,6 @@ import type * as MemoryTypes from "./mem0.types";
// Re-export all types from mem0.types
export type {
EntityOptions,
AddMemoryOptions,
SearchMemoryOptions,
GetAllMemoryOptions,
+56 -10
View File
@@ -23,6 +23,37 @@ import { captureClientEvent, generateHash } from "./telemetry";
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<string, any> | 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.`,
);
}
}
class APIError extends Error {
constructor(message: string) {
super(message);
@@ -190,7 +221,7 @@ export default class MemoryClient {
this._captureEvent("add", [payloadKeys]);
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/`,
`${this.host}/v3/memories/`,
{
method: "POST",
headers: this.headers,
@@ -254,13 +285,17 @@ export default class MemoryClient {
}
async getAll(options?: GetAllMemoryOptions): Promise<Array<Memory>> {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, ...rest } = options ?? {};
const { page, pageSize, filters, ...rest } = options ?? {};
const body: Record<string, any> = {
output_format: "v1.1",
...camelToSnakeKeys(rest),
...(filters && { filters }),
};
let url = `${this.host}/v2/memories/`;
@@ -273,33 +308,36 @@ export default class MemoryClient {
headers: this.headers,
body: JSON.stringify(body),
});
// Unwrap v1.1 format: { results: [...] } → [...]
return Array.isArray(response) ? response : (response?.results ?? response);
}
async search(
query: string,
options?: SearchMemoryOptions,
): Promise<Array<Memory>> {
): Promise<{ results: Array<Memory> }> {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "search");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]);
const { filters, ...rest } = options ?? {};
const payload: Record<string, any> = {
query,
output_format: "v1.1",
...camelToSnakeKeys(options ?? {}),
...camelToSnakeKeys(rest),
...(filters && { filters }),
};
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/memories/search/`,
`${this.host}/v3/memories/search/`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(payload),
},
);
// Unwrap v1.1 format: { results: [...] } → [...]
return Array.isArray(response) ? response : (response?.results ?? response);
return response;
}
async delete(memoryId: string): Promise<{ message: string }> {
@@ -614,12 +652,16 @@ export default class MemoryClient {
throw new Error("Missing filters or schema");
}
const { filters, ...rest } = data;
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/exports/`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(camelToSnakeKeys(data)),
body: JSON.stringify({
...camelToSnakeKeys(rest),
filters,
}),
},
);
@@ -636,12 +678,16 @@ export default class MemoryClient {
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(data)),
body: JSON.stringify({
...camelToSnakeKeys(rest),
...(filters && { filters }),
}),
},
);
return response;
@@ -51,17 +51,13 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
},
];
const result = await client.add(messages, { userId: TEST_USER_ID });
const result = await client.add(messages, {
userId: TEST_USER_ID,
});
// API processes memories asynchronously — returns PENDING
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// Validate response shape
for (const item of result) {
expect(item).toHaveProperty("status");
expect(item).toHaveProperty("eventId");
}
// v3 API processes memories asynchronously — returns PENDING
expect(result).toHaveProperty("eventId");
expect(result).toHaveProperty("status");
});
test("adds a second batch of messages", async () => {
@@ -76,8 +72,10 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
},
];
const result = await client.add(messages, { userId: TEST_USER_ID });
expect(Array.isArray(result)).toBe(true);
const result = await client.add(messages, {
userId: TEST_USER_ID,
});
expect(result).toHaveProperty("eventId");
});
test("memories become available after async processing", async () => {
@@ -123,7 +121,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
describe("get all memories", () => {
test("returns all memories for test user", async () => {
const memories = await client.getAll({
filters: { userId: TEST_USER_ID },
filters: { user_id: TEST_USER_ID },
});
expect(Array.isArray(memories)).toBe(true);
@@ -137,7 +135,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
test("returns paginated results with page and page_size", async () => {
const page1 = await client.getAll({
filters: { userId: TEST_USER_ID },
filters: { user_id: TEST_USER_ID },
page: 1,
pageSize: 1,
});
@@ -196,13 +194,12 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
},
);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result).toHaveProperty("eventId");
});
test("getAll for non-existent user returns empty array", async () => {
const memories = await client.getAll({
filters: { userId: `nonexistent-user-${randomUUID()}` },
filters: { user_id: `nonexistent-user-${randomUUID()}` },
});
expect(Array.isArray(memories)).toBe(true);
@@ -241,7 +238,9 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
// ─── Delete all + delete user ─────────────────────────────
describe("cleanup operations", () => {
test("deletes all memories for test user", async () => {
const result = await client.deleteAll({ userId: TEST_USER_ID });
const result = await client.deleteAll({
userId: TEST_USER_ID,
});
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
});
@@ -64,7 +64,7 @@ export async function waitForMemories(
): Promise<Memory[]> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const memories = await withRetry(() =>
client.getAll({ filters: { userId } }),
client.getAll({ filters: { user_id: userId } }),
);
if (Array.isArray(memories) && memories.length >= minCount) {
return memories;
@@ -92,8 +92,9 @@ export async function waitForSearchResults(
maxRetries = 4,
): Promise<Memory[]> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const results = await withRetry(() => client.search(query, options));
if (Array.isArray(results) && results.length > 0) {
const response = await withRetry(() => client.search(query, options));
const results = response?.results ?? [];
if (results.length > 0) {
return results;
}
if (attempt < maxRetries) {
@@ -43,7 +43,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
const results = await waitForSearchResults(
client,
"What is my favorite color?",
{ filters: { userId: TEST_USER_ID } },
{ filters: { user_id: TEST_USER_ID } },
);
expect(Array.isArray(results)).toBe(true);
@@ -64,7 +64,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
client,
"What do you know about me?",
{
filters: { OR: [{ userId: TEST_USER_ID }] },
filters: { OR: [{ user_id: TEST_USER_ID }] },
},
);
@@ -108,24 +108,24 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
// ─── Edge cases ─────────────────────────────────────────
describe("edge cases", () => {
test("search for non-existent user returns empty results", async () => {
const results = await client.search("anything", {
filters: { userId: `nonexistent-user-${randomUUID()}` },
const response = await client.search("test search query", {
filters: { user_id: `nonexistent-user-${randomUUID()}` },
});
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
expect(response).toHaveProperty("results");
expect(response.results).toHaveLength(0);
});
test("search with top_k param does not throw", async () => {
const results = await client.search(
const response = await client.search(
"Tell me about integration test user",
{
filters: { userId: TEST_USER_ID },
filters: { user_id: TEST_USER_ID },
topK: 1,
},
);
expect(Array.isArray(results)).toBe(true);
expect(response).toHaveProperty("results");
});
});
});
@@ -21,33 +21,33 @@ installConsoleSuppression();
// ─── add() ───────────────────────────────────────────────
describe("MemoryClient - add()", () => {
test("sends POST to /v1/memories/", async () => {
test("sends POST to /v3/memories/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
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: "Hello" }], { userId: "u1" });
expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined();
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
});
test("includes messages in request body", async () => {
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add(messages, { userId: "u1" });
const call = findFetchCall(mock, "/v1/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).messages).toEqual(messages);
});
test("includes user_id in request body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
@@ -55,19 +55,19 @@ describe("MemoryClient - add()", () => {
user_id: "user_1",
});
const call = findFetchCall(mock, "/v1/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).user_id).toBe("user_1");
});
test("sends empty messages array without crashing", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [] });
extra.set("/v3/memories/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([], { userId: "u1" });
const call = findFetchCall(mock, "/v1/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).messages).toEqual([]);
});
});
@@ -1,5 +1,5 @@
/**
* MemoryClient unit tests — search (v2 default, filters).
* MemoryClient unit tests — search (v3 endpoint, filters).
* Tests verify request construction, not mock response echo.
*/
import { MemoryClient } from "../mem0";
@@ -15,48 +15,60 @@ import {
installConsoleSuppression();
describe("MemoryClient - search()", () => {
test("sends POST to /v2/memories/search/ by default", async () => {
test("sends POST to /v3/memories/search/ by default", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("What is my name?", {
filters: { userId: "u1" },
filters: { user_id: "u1" },
});
expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined();
expect(findFetchCall(mock, "/v3/memories/search/", "POST")).toBeDefined();
});
test("includes query in request body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("What is my name?", {
filters: { userId: "u1" },
filters: { user_id: "u1" },
});
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).query).toBe("What is my name?");
});
test("passes filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("test", { filters: { userId: "u1" } });
await client.search("test", { filters: { user_id: "u1" } });
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" });
});
test("passes complex OR filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
@@ -64,32 +76,192 @@ describe("MemoryClient - search()", () => {
filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] },
});
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual({
OR: [{ user_id: "u1" }, { agent_id: "a1" }],
});
});
test("passes complex AND filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("query", {
filters: {
AND: [
{ user_id: "u1" },
{ created_at: { gte: "2024-01-01T00:00:00Z" } },
],
},
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual({
AND: [{ user_id: "u1" }, { created_at: { gte: "2024-01-01T00:00:00Z" } }],
});
});
test("passes NOT filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("query", {
filters: {
AND: [
{ user_id: "u1" },
{ NOT: { categories: { in: ["spam", "test"] } } },
],
},
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual({
AND: [
{ user_id: "u1" },
{ NOT: { categories: { in: ["spam", "test"] } } },
],
});
});
test("passes complex nested AND/OR/NOT filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const complexFilter = {
AND: [
{ user_id: "u1" },
{ created_at: { gte: "2024-01-01T00:00:00Z" } },
{
NOT: {
OR: [
{ categories: { in: ["spam"] } },
{ categories: { in: ["test"] } },
],
},
},
],
};
await client.search("query", { filters: complexFilter });
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual(complexFilter);
});
test("does not crash when called without options", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result: Memory[] = await client.search("query");
expect(Array.isArray(result)).toBe(true);
const result = await client.search("query");
expect(result).toHaveProperty("results");
});
test("handles empty results array", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result: Memory[] = await client.search("nonexistent query", {
filters: { userId: "u1" },
const result = await client.search("nonexistent query", {
filters: { AND: [{ user_id: "u1" }] },
});
expect(result).toHaveLength(0);
expect(result.results).toHaveLength(0);
});
});
describe("MemoryClient - search() entity param rejection", () => {
test("rejects user_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(
client.search("query", { user_id: "u1" } as any),
).rejects.toThrow(/filters/);
});
test("rejects agent_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(
client.search("query", { agent_id: "a1" } as any),
).rejects.toThrow(/filters/);
});
test("rejects app_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(
client.search("query", { app_id: "app1" } as any),
).rejects.toThrow(/filters/);
});
test("accepts filters with user_id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
// Should not throw
await client.search("query", { filters: { AND: [{ user_id: "u1" }] } });
expect(findFetchCall(mock, "/v3/memories/search/", "POST")).toBeDefined();
});
});
describe("MemoryClient - getAll() entity param rejection", () => {
test("rejects user_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(client.getAll({ user_id: "u1" } as any)).rejects.toThrow(
/filters/,
);
});
test("rejects agent_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(client.getAll({ agent_id: "a1" } as any)).rejects.toThrow(
/filters/,
);
});
test("accepts filters with user_id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" } });
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
});
});