chore: merge origin/main into mintlify/af82bda4 and resolve conflicts

This commit is contained in:
mintlify[bot]
2026-09-16 11:46:52 +00:00
committed by GitHub
parent b5ae772eae
commit 08d9636491
693 changed files with 55668 additions and 22676 deletions
+91 -39
View File
@@ -81,8 +81,20 @@ class APIError extends Error {
interface ClientOptions {
apiKey: string;
host?: string;
/** Max cached identities per process. Defaults to 50. */
identityCacheMax?: number;
}
interface ClientIdentity {
telemetryId: string;
organizationId: string | number | null;
projectId: string | number | null;
}
// Shares one ping per (host, api key) across clients; FIFO-capped.
const IDENTITY_CACHE_MAX_DEFAULT = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
export default class MemoryClient {
apiKey: string;
host: string;
@@ -91,6 +103,8 @@ export default class MemoryClient {
headers: Record<string, string>;
client: any;
telemetryId: string;
private initialized: Promise<void>;
private identityCacheMax: number;
_validateApiKey(): any {
if (!this.apiKey) {
@@ -109,6 +123,8 @@ export default class MemoryClient {
this.host = options.host || "https://api.mem0.ai";
this.organizationId = null;
this.projectId = null;
this.identityCacheMax =
options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT;
this.headers = {
Authorization: `Token ${this.apiKey}`,
@@ -122,11 +138,44 @@ export default class MemoryClient {
});
this._validateApiKey();
this.telemetryId = "";
this._initializeClient();
// Memory requests never wait on this; telemetry and _awaitIdentity do.
this.initialized = this._resolveIdentity();
}
private async _initializeClient() {
// One ping per credential pair per process, shared via identityByCredentials.
private _resolveIdentity(): Promise<void> {
const credentials = `${this.host}\u0000${this.apiKey}`;
let shared = identityByCredentials.get(credentials);
if (!shared) {
shared = this._initializeClient();
if (identityByCredentials.size >= this.identityCacheMax) {
identityByCredentials.delete(
identityByCredentials.keys().next().value!,
);
}
identityByCredentials.set(credentials, shared);
// A failed ping must not be cached, or the process never recovers.
shared.then((identity) => {
if (!identity.telemetryId) identityByCredentials.delete(credentials);
});
}
return shared.then((identity) => {
this.telemetryId = identity.telemetryId;
if (identity.organizationId != null)
this.organizationId = identity.organizationId;
if (identity.projectId != null) this.projectId = identity.projectId;
});
}
// Blocks until the ping has populated organizationId/projectId.
private async _awaitIdentity(): Promise<void> {
await this.initialized;
}
private async _initializeClient(): Promise<ClientIdentity> {
try {
await this.ping();
@@ -148,6 +197,12 @@ export default class MemoryClient {
stack: error?.stack || "No stack trace",
});
}
return {
telemetryId: this.telemetryId,
organizationId: this.organizationId,
projectId: this.projectId,
};
}
private async _maybeAliasAnonToEmail(): Promise<void> {
@@ -175,13 +230,18 @@ export default class MemoryClient {
}
private _captureEvent(methodName: string, args: any[]) {
captureClientEvent(methodName, this, {
success: true,
args_count: args.length,
keys: args.length > 0 ? args[0] : [],
}).catch((error: any) => {
console.error("Failed to capture event:", error);
});
// Deferred until ping() has resolved telemetryId, off the request path.
this.initialized
.then(() =>
captureClientEvent(methodName, this, {
success: true,
args_count: args.length,
keys: args.length > 0 ? args[0] : [],
}),
)
.catch((error: any) => {
console.error("Failed to capture event:", error);
});
}
async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
@@ -262,8 +322,6 @@ export default class MemoryClient {
throw new Error("Cannot process an empty messages payload.");
}
if (this.telemetryId === "") await this.ping();
const payload = this._preparePayload(messages, options);
const payloadKeys = Object.keys(payload);
this._captureEvent("add", [payloadKeys]);
@@ -304,7 +362,6 @@ export default class MemoryClient {
);
}
if (this.telemetryId === "") await this.ping();
const payload: Record<string, any> = {};
if (text !== undefined) payload.text = text;
if (metadata !== undefined) payload.metadata = metadata;
@@ -326,7 +383,6 @@ export default class MemoryClient {
}
async get(memoryId: string): Promise<Memory> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get", []);
return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
@@ -340,7 +396,6 @@ export default class MemoryClient {
// 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, filters, ...rest } = options ?? {};
@@ -349,10 +404,10 @@ export default class MemoryClient {
...(filters && { filters }),
};
let url = `${this.host}/v3/memories/`;
if (page && pageSize) {
url += `?page=${page}&page_size=${pageSize}`;
}
const queryParams: string[] = [];
if (page !== undefined) queryParams.push(`page=${page}`);
if (pageSize !== undefined) queryParams.push(`page_size=${pageSize}`);
const url = `${this.host}/v3/memories/${queryParams.length ? `?${queryParams.join("&")}` : ""}`;
const response = await this._fetchWithErrorHandling(url, {
method: "POST",
@@ -369,7 +424,6 @@ export default class MemoryClient {
// 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 ?? {};
@@ -395,7 +449,6 @@ export default class MemoryClient {
memoryId: string,
options: DeleteMemoryOptions = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete", [Object.keys(options || {})]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
// @ts-ignore
@@ -412,7 +465,6 @@ export default class MemoryClient {
async deleteAll(
options: DeleteAllMemoryOptions = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("delete_all", [payloadKeys]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
@@ -429,7 +481,6 @@ export default class MemoryClient {
}
async history(memoryId: string): Promise<Array<MemoryHistory>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("history", []);
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
@@ -444,7 +495,6 @@ export default class MemoryClient {
page?: number;
pageSize?: number;
}): Promise<AllUsers> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("users", []);
let url = `${this.host}/v1/entities/`;
const params: string[] = [];
@@ -464,7 +514,6 @@ export default class MemoryClient {
entity_id: number;
entity_type: string;
}): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_user", []);
if (!data.entity_type) {
data.entity_type = "user";
@@ -487,8 +536,6 @@ export default class MemoryClient {
runId?: string;
} = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
let to_delete: Array<{ type: string; name: string }> = [];
const { userId, agentId, appId, runId } = params;
@@ -514,8 +561,14 @@ export default class MemoryClient {
for (const entity of to_delete) {
try {
await this.client.delete(
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
// fetch() reuses the pooled connection; axios here defaulted to
// keepAlive: false, one handshake per entity.
await this._fetchWithErrorHandling(
`${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
{
method: "DELETE",
headers: this.headers,
},
);
} catch (error: any) {
throw new APIError(
@@ -537,7 +590,6 @@ export default class MemoryClient {
}
async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_update", []);
const memoriesBody = memories.map((memory) => ({
memory_id: memory.memoryId,
@@ -555,7 +607,6 @@ export default class MemoryClient {
}
async batchDelete(memories: Array<string>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_delete", []);
const memoriesBody = memories.map((memory) => ({
memory_id: memory,
@@ -572,10 +623,10 @@ export default class MemoryClient {
}
async getProject(options: ProjectOptions): Promise<ProjectResponse> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_project", [payloadKeys]);
const { fields } = options;
await this._awaitIdentity();
if (!(this.organizationId && this.projectId)) {
throw new Error(
@@ -598,8 +649,8 @@ export default class MemoryClient {
async updateProject(
prompts: PromptUpdatePayload,
): Promise<Record<string, any>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_project", []);
await this._awaitIdentity();
if (!(this.organizationId && this.projectId)) {
throw new Error(
"organizationId and projectId must be set to update instructions or categories",
@@ -619,9 +670,12 @@ export default class MemoryClient {
// WebHooks
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_webhooks", []);
if (!data?.projectId) await this._awaitIdentity();
const project_id = data?.projectId || this.projectId;
if (!project_id) {
throw new Error("projectId must be set to access webhooks");
}
const response = await this._fetchWithErrorHandling(
`${this.host}/api/v1/webhooks/projects/${project_id}/`,
{
@@ -632,8 +686,11 @@ export default class MemoryClient {
}
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_webhook", []);
await this._awaitIdentity();
if (!this.projectId) {
throw new Error("projectId must be set to create a webhook");
}
const body = {
name: webhook.name,
url: webhook.url,
@@ -653,7 +710,6 @@ export default class MemoryClient {
async updateWebhook(
webhook: WebhookUpdatePayload,
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_webhook", []);
const body: Record<string, any> = {};
if (webhook.name != null) body.name = webhook.name;
@@ -673,7 +729,6 @@ export default class MemoryClient {
async deleteWebhook(data: {
webhookId: string;
}): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_webhook", []);
const webhook_id = data.webhookId || data;
const response = await this._fetchWithErrorHandling(
@@ -687,7 +742,6 @@ export default class MemoryClient {
}
async feedback(data: FeedbackPayload): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(data || {});
this._captureEvent("feedback", [payloadKeys]);
const response = await this._fetchWithErrorHandling(
@@ -704,7 +758,6 @@ export default class MemoryClient {
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_memory_export", []);
if (!data.filters || !data.schema) {
@@ -734,7 +787,6 @@ export default class MemoryClient {
async getMemoryExport(
data: GetMemoryExportPayload,
): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_memory_export", []);
if (!data.memoryExportId && !data.filters) {
+3
View File
@@ -12,6 +12,7 @@ export interface AddMemoryOptions extends EntityOptions {
infer?: boolean;
customCategories?: custom_categories[];
customInstructions?: string;
agentCustomInstructions?: string;
timestamp?: number;
expirationDate?: string;
structuredDataSchema?: Record<string, any>;
@@ -60,6 +61,7 @@ export interface ProjectOptions {
export interface PromptUpdatePayload {
customInstructions?: string;
agentCustomInstructions?: string;
customCategories?: custom_categories[];
version?: string;
memoryDepth?: string | null;
@@ -174,6 +176,7 @@ export interface PaginatedMemories {
export interface ProjectResponse {
customInstructions?: string;
agentCustomInstructions?: string;
// The API returns category objects (`[{ "<name>": "<description>" }]`),
// not bare strings (see issue #5738).
customCategories?: custom_categories[];
@@ -74,6 +74,35 @@ describe("MemoryClient - add()", () => {
expect(getFetchBody(call!).expiration_date).toBe("2030-01-31");
});
test("serializes agentCustomInstructions as agent_custom_instructions", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "test" }], {
agentId: "a1",
agentCustomInstructions: "Remember tool failures",
});
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!).agent_custom_instructions).toBe(
"Remember tool failures",
);
});
test("omits agent_custom_instructions when it is not passed", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "test" }], { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!)).not.toHaveProperty("agent_custom_instructions");
});
test("throws an error when given an empty messages array", async () => {
setupMockFetch();
@@ -0,0 +1,228 @@
/**
* MemoryClient unit tests — org/project identity resolution ordering.
*
* Each test calls the method under test as the first awaited operation on a
* fresh client. Any earlier await on the instance resolves identity and voids
* the test.
*/
import { MemoryClient } from "../mem0";
import { TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
import { setupMockFetch, installConsoleSuppression } from "./setup";
installConsoleSuppression();
// Distinct key per client keeps each test off the module-scope identity cache.
let keySeq = 0;
const freshClient = () =>
new MemoryClient({ apiKey: `test-api-key-identity-${keySeq++}` });
// Selects by path; the ping and PostHog calls also land in the mock.
const findUrlOrNone = (mock: jest.Mock, needle: string): string | undefined =>
mock.mock.calls
.map((c: [string, RequestInit]) => c[0])
.find((u: string) => u.includes(needle));
const findUrl = (mock: jest.Mock, needle: string): string => {
const url = findUrlOrNone(mock, needle);
expect(url).toBeDefined();
return url as string;
};
describe("MemoryClient - project-scoped URLs on a fresh client", () => {
test("getProject uses the resolved org and project ids", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { custom_instructions: "Be helpful" },
});
const mock = setupMockFetch(extra);
await freshClient().getProject({ fields: ["custom_instructions"] });
const url = findUrl(mock, "/api/v1/orgs/organizations/");
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
});
test("updateProject uses the resolved org and project ids", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { custom_instructions: "Updated" },
});
const mock = setupMockFetch(extra);
await freshClient().updateProject({ customInstructions: "Updated" });
const url = findUrl(mock, "/api/v1/orgs/organizations/");
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
});
test("getWebhooks targets the resolved project, not null", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
await freshClient().getWebhooks();
const url = findUrl(mock, "/api/v1/webhooks/projects/");
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
expect(url).not.toContain("/projects/null/");
expect(url).not.toContain("/projects/undefined/");
});
test("createWebhook targets the resolved project, not null", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_1" },
});
const mock = setupMockFetch(extra);
await freshClient().createWebhook({
name: "hook",
url: "https://example.com/hook",
eventTypes: ["memory_add"],
});
const url = findUrl(mock, "/api/v1/webhooks/projects/");
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
expect(url).not.toContain("/projects/null/");
expect(url).not.toContain("/projects/undefined/");
});
test("an explicit projectId is honored without waiting on identity", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
await freshClient().getWebhooks({ projectId: "proj_explicit_789" });
expect(findUrl(mock, "/api/v1/webhooks/projects/")).toContain(
"/api/v1/webhooks/projects/proj_explicit_789/",
);
});
});
describe("MemoryClient - memory endpoints do not wait on identity", () => {
test("search completes while the ping is still pending", async () => {
// The ping never settles.
const mock = jest.fn((url: string) => {
if (url.includes("/v1/ping/")) return new Promise(() => {});
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ results: [] }),
text: () => Promise.resolve(""),
});
});
global.fetch = mock as unknown as typeof global.fetch;
await expect(
freshClient().search("query", { filters: { user_id: "alice" } }),
).resolves.toBeDefined();
expect(findUrl(mock, "/v3/memories/search/")).toBeDefined();
});
});
describe("MemoryClient - identity cache overflow", () => {
const pingCount = (mock: jest.Mock) =>
mock.mock.calls.filter((c: [string, RequestInit]) =>
c[0].includes("/v1/ping/"),
).length;
// The cache is module scope, so each test needs its own module registry.
const isolatedClient = (): typeof MemoryClient => {
jest.resetModules();
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../mem0").MemoryClient;
};
const projectOk = () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", { status: 200, body: {} });
return setupMockFetch(extra);
};
test("a reused credential pair pings once", async () => {
const mock = projectOk();
const Client = isolatedClient();
const opts = { apiKey: "cache-warm-key", identityCacheMax: 1 };
await new Client(opts).getProject({ fields: [] });
await new Client(opts).getProject({ fields: [] });
expect(pingCount(mock)).toBe(1);
});
test("an evicted credential pair re-pings and still resolves identity", async () => {
const mock = projectOk();
const Client = isolatedClient();
const opts = (apiKey: string) => ({ apiKey, identityCacheMax: 1 });
await new Client(opts("key-a")).getProject({ fields: [] });
// Evicts key-a.
await new Client(opts("key-b")).getProject({ fields: [] });
expect(pingCount(mock)).toBe(2);
await new Client(opts("key-a")).getProject({ fields: [] });
// Eviction costs a ping; it never yields an unresolved identity.
expect(pingCount(mock)).toBe(3);
expect(findUrl(mock, "/api/v1/orgs/organizations/")).toContain(
`/api/v1/orgs/organizations/${TEST_ORG_ID}/`,
);
});
});
describe("MemoryClient - unresolved identity", () => {
const pingFails = () => {
const mock = jest.fn((url: string) => {
if (url.includes("/v1/ping/")) {
return Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve("boom"),
json: () => Promise.resolve({}),
});
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
text: () => Promise.resolve(""),
});
});
global.fetch = mock as unknown as typeof global.fetch;
return mock;
};
test("getProject reports the unset ids", async () => {
pingFails();
await expect(freshClient().getProject({ fields: [] })).rejects.toThrow(
"organizationId and projectId must be set",
);
});
test("getWebhooks reports the unset project instead of requesting null", async () => {
const mock = pingFails();
await expect(freshClient().getWebhooks()).rejects.toThrow(
"projectId must be set",
);
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
});
test("createWebhook reports the unset project instead of requesting null", async () => {
const mock = pingFails();
await expect(
freshClient().createWebhook({
name: "hook",
url: "https://example.com/hook",
eventTypes: ["memory_add"],
}),
).rejects.toThrow("projectId must be set");
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
});
});
@@ -96,6 +96,84 @@ describe("MemoryClient - updateProject()", () => {
"Updated instructions",
);
});
test("camelCases agentCustomInstructions into agent_custom_instructions", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({
agentCustomInstructions: "Remember tool failures",
});
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
expect(getFetchBody(call!).agent_custom_instructions).toBe(
"Remember tool failures",
);
});
test("sends both instruction sets in one PATCH", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({
customInstructions: "Remember user preferences",
agentCustomInstructions: "Remember tool failures",
});
const body = getFetchBody(
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
);
expect(body.custom_instructions).toBe("Remember user preferences");
expect(body.agent_custom_instructions).toBe("Remember tool failures");
});
test("an empty string round-trips, so the field can be cleared", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({ agentCustomInstructions: "" });
const body = getFetchBody(
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
);
expect(body).toHaveProperty("agent_custom_instructions", "");
});
test("omits agent_custom_instructions when it is not passed", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({ customInstructions: "Be helpful" });
const body = getFetchBody(
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
);
expect(body).not.toHaveProperty("agent_custom_instructions");
});
});
// ─── feedback() ─────────────────────────────────────────
@@ -373,3 +373,56 @@ describe("MemoryClient - getAll() entity param rejection", () => {
expect(getFetchBody(call!).show_expired).toBe(true);
});
});
describe("MemoryClient - getAll() page/pageSize query params", () => {
test("sends no query params when neither page nor pageSize is provided", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: { results: [] } });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" } });
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(call![0]).not.toContain("?");
});
test("sends page alone as a query param", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: { results: [] } });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" }, page: 2 });
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(call![0]).toContain("?page=2");
expect(call![0]).not.toContain("page_size");
});
test("sends pageSize alone as a query param", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: { results: [] } });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" }, pageSize: 50 });
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(call![0]).toContain("?page_size=50");
expect(call![0]).not.toContain("?page=50");
});
test("sends both page and pageSize when provided, including falsy 0 values", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: { results: [] } });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" }, page: 0, pageSize: 0 });
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(call![0]).toContain("page=0");
expect(call![0]).toContain("page_size=0");
});
});
@@ -98,16 +98,23 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
describe("MemoryClient - deleteUsers()", () => {
test("URL-encodes entity path segments", async () => {
setupMockFetch();
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/org%2Fteam%3Factive%23frag/", {
status: 200,
body: { message: "Entity deleted successfully!" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
client.telemetryId = "test@example.com";
const deleteMock = jest.fn().mockResolvedValue({});
client.client = { delete: deleteMock };
await client.deleteUsers({ userId: "org/team?active#frag" });
expect(deleteMock).toHaveBeenCalledWith(
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
);
expect(
findFetchCall(
mock,
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"DELETE",
),
).toBeDefined();
});
});
+1
View File
@@ -51,6 +51,7 @@ export * from "./vector_stores/milvus";
export * from "./vector_stores/mongodb";
export * from "./vector_stores/opensearch";
export * from "./vector_stores/weaviate";
export * from "./vector_stores/oracledb";
export * from "./rerankers/base";
export * from "./rerankers/cohere";
export * from "./rerankers/llm";
+13 -2
View File
@@ -29,7 +29,18 @@ const PROVIDERS = [
* Extract the model-family provider from a Bedrock model id
* (e.g. `anthropic.claude-3-sonnet-...` -> `anthropic`).
*/
export function extractProvider(model: string): string {
export function extractProvider(
model: string,
providerOverride?: string,
): string {
if (providerOverride) {
if (!PROVIDERS.includes(providerOverride)) {
throw new Error(
`Unknown providerOverride '${providerOverride}'. Valid providers: ${PROVIDERS.join(", ")}`,
);
}
return providerOverride;
}
for (const provider of PROVIDERS) {
const re = new RegExp(
`\\b${provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
@@ -79,7 +90,7 @@ export class AWSBedrockLLM implements LLM {
this.model =
(typeof config.model === "string" && config.model) ||
"anthropic.claude-3-5-sonnet-20240620-v1:0";
this.provider = extractProvider(this.model);
this.provider = extractProvider(this.model, config.providerOverride);
this.temperature = config.temperature ?? 0.1;
this.maxTokens = config.maxTokens ?? 2000;
this.topP = config.topP;
+37 -45
View File
@@ -105,6 +105,18 @@ const ENTITY_PARAMS = [
// actor_id has no camelCase alias.
const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"];
const PAYLOAD_METADATA_EXCLUDED_KEYS = new Set([
"user_id",
"agent_id",
"run_id",
"hash",
"data",
"createdAt",
"updatedAt",
"textLemmatized",
"attributedTo",
]);
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367 / #6371).
function stripIdentityKeys(
metadata: Record<string, any> = {},
@@ -556,11 +568,18 @@ export class Memory {
}
}
private escapeScopeValue(val: unknown): string {
return String(val)
.replace(/%/g, "%25")
.replace(/&/g, "%26")
.replace(/=/g, "%3D");
}
private buildSessionScope(filters: SearchFilters): string {
const parts: string[] = [];
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
const val = (filters as any)[key];
if (val) parts.push(`${key}=${val}`);
if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`);
}
return parts.join("&");
}
@@ -993,7 +1012,8 @@ export class Memory {
for (const mem of extractedMemories) {
const text = mem.text;
if (!text || !(text in embedMap)) continue;
if (!text || !Object.prototype.hasOwnProperty.call(embedMap, text))
continue;
const memHash = createHash("md5").update(text).digest("hex");
if (existingHashes.has(memHash) || seenHashes.has(memHash)) {
@@ -1290,20 +1310,8 @@ export class Memory {
metadata: {},
};
// Add additional metadata
const excludedKeys = new Set([
"userId",
"agentId",
"runId",
"hash",
"data",
"createdAt",
"updatedAt",
"textLemmatized",
"attributedTo",
]);
for (const [key, value] of Object.entries(memory.payload)) {
if (!excludedKeys.has(key)) {
if (!PAYLOAD_METADATA_EXCLUDED_KEYS.has(key)) {
memoryItem.metadata![key] = value;
}
}
@@ -1559,18 +1567,6 @@ export class Memory {
);
// Step 9: Format results
const excludedKeys = new Set([
"user_id",
"agent_id",
"run_id",
"hash",
"data",
"createdAt",
"updatedAt",
"textLemmatized",
"attributedTo",
]);
const results = scoredResults
.filter((scored) => scored.payload?.data)
.map((scored) => {
@@ -1583,7 +1579,7 @@ export class Memory {
updatedAt: payload.updatedAt,
score: scored.score,
metadata: Object.entries(payload)
.filter(([key]) => !excludedKeys.has(key))
.filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...(payload.user_id && { user_id: payload.user_id }),
...(payload.agent_id && { agent_id: payload.agent_id }),
@@ -1889,17 +1885,6 @@ export class Memory {
? memories
: memories.filter((mem) => !payloadIsExpired(mem.payload));
const excludedKeys = new Set([
"user_id",
"agent_id",
"run_id",
"hash",
"data",
"createdAt",
"updatedAt",
"textLemmatized",
"attributedTo",
]);
const results = visibleMemories.slice(0, topK).map((mem) => ({
id: mem.id,
memory: mem.payload.data,
@@ -1907,7 +1892,7 @@ export class Memory {
createdAt: mem.payload.createdAt,
updatedAt: mem.payload.updatedAt,
metadata: Object.entries(mem.payload)
.filter(([key]) => !excludedKeys.has(key))
.filter(([key]) => !PAYLOAD_METADATA_EXCLUDED_KEYS.has(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...(mem.payload.user_id && { user_id: mem.payload.user_id }),
...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }),
@@ -1936,8 +1921,12 @@ export class Memory {
metadata: Record<string, any>,
): Promise<string> {
const memoryId = uuidv4();
const embedding =
existingEmbeddings[data] || (await this.embedder.embed(data, "add"));
const embedding = Object.prototype.hasOwnProperty.call(
existingEmbeddings,
data,
)
? existingEmbeddings[data]
: await this.embedder.embed(data, "add");
const memoryMetadata = {
...metadata,
@@ -1980,9 +1969,12 @@ export class Memory {
}
const textChanged = newData !== prevValue;
const embedding =
existingEmbeddings[newData] ||
(await this.embedder.embed(newData, "update"));
const embedding = Object.prototype.hasOwnProperty.call(
existingEmbeddings,
newData,
)
? existingEmbeddings[newData]
: await this.embedder.embed(newData, "update");
const sanitizedMetadata = stripIdentityKeys(metadata);
@@ -68,6 +68,29 @@ describe("extractProvider", () => {
/Unknown provider/,
);
});
const ARN =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz";
it("throws for an application inference profile ARN without providerOverride", () => {
expect(() => extractProvider(ARN)).toThrow(/Unknown provider in model/);
});
it("resolves an application inference profile ARN via providerOverride", () => {
expect(extractProvider(ARN, "anthropic")).toBe("anthropic");
});
it("lets providerOverride take precedence over regex detection", () => {
expect(
extractProvider("anthropic.claude-3-5-sonnet-20240620-v1:0", "amazon"),
).toBe("amazon");
});
it("throws on a misspelled providerOverride", () => {
expect(() => extractProvider(ARN, "anthorpic")).toThrow(
/Unknown providerOverride 'anthorpic'/,
);
});
});
describe("AWSBedrockLLM", () => {
@@ -176,6 +199,28 @@ describe("AWSBedrockLLM", () => {
expect(res).toEqual({ content: "hello from bedrock", role: "assistant" });
});
it("uses providerOverride to resolve inference profile ARNs (topP omitted like anthropic)", async () => {
const arn =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz";
const client = new FakeBedrockClient(textResponse);
const llm = makeLLM(client, {
model: arn,
providerOverride: "anthropic",
topP: 0.9,
});
await llm.generateResponse([{ role: "user", content: "hi" }]);
expect(client.lastInput.modelId).toBe(arn);
expect(client.lastInput.inferenceConfig.topP).toBeUndefined();
});
it("throws when constructed with an inference profile ARN and no providerOverride", () => {
const arn =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz";
expect(() => new AWSBedrockLLM({ model: arn })).toThrow(
/Unknown provider in model/,
);
});
it("does not construct the Bedrock client until the first request", () => {
const before = mockClientConstructions;
// No injected client: the old constructor eagerly built a real one.
+1
View File
@@ -78,6 +78,7 @@ export interface LLMConfig {
awsAccessKeyId?: string;
awsSecretAccessKey?: string;
awsSessionToken?: string;
providerOverride?: string;
// Optional pre-constructed client (e.g. BedrockRuntimeClient) for DI/testing.
client?: any;
}
+22
View File
@@ -0,0 +1,22 @@
function toCamelCase(obj: Record<string, any>): Record<string, any> {
if (typeof obj !== "object" || obj === null) return obj;
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
value,
]),
);
}
export function toCamelCasePreservingIds(
payload: Record<string, any>,
): Record<string, any> {
const { agent_id, run_id, user_id, ...rest } = payload;
return {
...toCamelCase(rest),
...(agent_id !== undefined && { agent_id }),
...(run_id !== undefined && { run_id }),
...(user_id !== undefined && { user_id }),
};
}
+3
View File
@@ -71,6 +71,7 @@ import { TurbopufferDB } from "../vector_stores/turbopuffer";
import { Milvus } from "../vector_stores/milvus";
import { MongoDB } from "../vector_stores/mongodb";
import { WeaviateDB } from "../vector_stores/weaviate";
import { OracleAIVectorSearch } from "../vector_stores/oracledb";
export class EmbedderFactory {
static create(provider: string, config: EmbeddingConfig): Embedder {
@@ -205,6 +206,8 @@ export class VectorStoreFactory {
return new MongoDB(config as any);
case "weaviate":
return new WeaviateDB(config as any);
case "oracledb":
return new OracleAIVectorSearch(config as any);
default:
throw new Error(`Unsupported vector store provider: ${provider}`);
}
+64 -29
View File
@@ -1,11 +1,9 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import {
POSTHOG_API_KEY,
captureNoticeEvent,
isTelemetryEnabled,
} from "./telemetry";
import { captureNoticeEvent, isTelemetryEnabled } from "./telemetry";
import bundledNoticeConfig from "./oss_notices_config.json";
import type { TelemetryInstance } from "./telemetry.types";
export const NOTICE_FLAG_KEY = "mem0-oss-notices";
@@ -21,7 +19,57 @@ export const PERFORMANCE_SLOW_QUERY_NOTICE_ID = "performance_slow_query";
export const NOTICE_CAP_LIMIT = 10;
export const NOTICE_CAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export const NOTICE_FLAG_TIMEOUT_MS = 500;
export const POSTHOG_FLAGS_URL = "https://us.i.posthog.com/flags?v=2";
const REMOTE_CONFIG_URL =
"https://raw.githubusercontent.com/mem0ai/mem0/main/mem0/memory/oss_notices_config.json";
const CONFIG_TTL_MS = 3_600_000;
let _cachedConfig: Record<string, any> | null = null;
let _cachedConfigTs = 0;
let _configFetch: Promise<Record<string, any>> | null = null;
async function fetchNoticeConfig(
fetchImpl: typeof fetch,
timeoutMs: number,
): Promise<Record<string, any>> {
const now = Date.now();
if (_cachedConfig && now - _cachedConfigTs < CONFIG_TTL_MS) {
return _cachedConfig;
}
if (_configFetch) return _configFetch;
_configFetch = (async () => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetchImpl(REMOTE_CONFIG_URL, {
signal: controller.signal,
});
if (resp.ok) {
_cachedConfig = await resp.json();
}
} finally {
clearTimeout(timeout);
}
} catch {}
_cachedConfig ??= bundledNoticeConfig as Record<string, any>;
_cachedConfigTs = Date.now();
return _cachedConfig;
})();
try {
return await _configFetch;
} finally {
_configFetch = null;
}
}
function resetNoticeConfigCache(): void {
_cachedConfig = null;
_cachedConfigTs = 0;
_configFetch = null;
}
const DISPLAYED_VARIANT = "displayed";
const HOLDOUT_VARIANT = "holdout";
const LOG_LINE_NOTICE_TYPE = "log_line";
@@ -255,37 +303,23 @@ export async function evaluateNoticeFlag(
const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(POSTHOG_FLAGS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
}),
signal: controller.signal,
});
if (!response.ok) return null;
const data: any = await response.json();
const flag = data?.flags?.[NOTICE_FLAG_KEY];
if (!flag || flag.enabled === false) return null;
const variant = typeof flag.variant === "string" ? flag.variant : null;
if (!variant) return null;
const config = await fetchNoticeConfig(fetchImpl, timeoutMs);
const split =
typeof config.variant_split === "number" ? config.variant_split : 0.5;
const hashKey = `${NOTICE_FLAG_KEY}.${distinctId}variant`;
const hash = crypto.createHash("sha1").update(hashKey).digest("hex");
const bucket = parseInt(hash.substring(0, 15), 16) / 0xfffffffffffffff;
const variant = bucket < split ? DISPLAYED_VARIANT : HOLDOUT_VARIANT;
return {
variant,
payload: parsePayload(flag.metadata?.payload),
flag,
payload: { notices: config.notices ?? {} },
flag: { key: NOTICE_FLAG_KEY, enabled: true, variant },
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
@@ -1408,6 +1442,7 @@ export const __noticeTestHooks = {
appendNoticeCapEvent,
emitNoticeDisplayed,
evaluateNoticeFlag,
resetNoticeConfigCache,
displayFirstRunNotice,
displayDecayUsageNotice,
displayTemporalUsageNotice,
@@ -0,0 +1,44 @@
{
"version": 1,
"variant_split": 0.5,
"notices": {
"first_run": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"temporal_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"temporal_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"decay_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"decay_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"scale_threshold": {
"copies": {
"memory_count": "",
"top_k": ""
},
"enabled": false,
"notice_type": "log_line"
},
"performance_slow_query": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
}
}
}
@@ -0,0 +1,764 @@
import type { BindParameters, Connection, Pool } from "oracledb";
import { v4 as uuidv4 } from "uuid";
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
import { loadPeer } from "../utils/load_peer";
const DISTANCE_METRICS = [
"COSINE",
"EUCLIDEAN",
"EUCLIDEAN_SQUARED",
"DOT",
"HAMMING",
"MANHATTAN",
] as const;
type DistanceMetric = (typeof DISTANCE_METRICS)[number];
type IndexType = "HNSW" | "IVF";
const SCORE_FROM_DISTANCE: Record<DistanceMetric, (d: number) => number> = {
COSINE: (d) => Math.max(0, Math.min(1, 1 - d)),
EUCLIDEAN: (d) => 1 / (1 + Math.max(0, d)),
EUCLIDEAN_SQUARED: (d) => 1 / (1 + Math.sqrt(Math.max(0, d))),
HAMMING: (d) => 1 / (1 + Math.max(0, d)),
MANHATTAN: (d) => 1 / (1 + Math.max(0, d)),
DOT: (d) => -d,
};
const INDEX_PARAMETER_RANGES: Record<
IndexType,
Record<string, [number, number]>
> = {
HNSW: {
neighbors: [2, 2048],
efconstruction: [1, 65535],
},
IVF: {
"neighbor partitions": [1, 10_000_000],
samples_per_partition: [1, Number.MAX_SAFE_INTEGER],
min_vectors_per_partition: [0, Number.MAX_SAFE_INTEGER],
},
};
const IDENTIFIER_RE = /^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$/;
const METADATA_KEY_RE = /^[a-zA-Z0-9_.[\],\s*]+$/;
export function quoteIdentifier(name: string): string {
const trimmed = name.trim();
if (!IDENTIFIER_RE.test(trimmed)) {
throw new Error(`Identifier name ${name} is not valid.`);
}
return [...trimmed.matchAll(/"([^"]+)"|([^".]+)/g)]
.map((m) => `"${m[1] ?? m[2]}"`)
.join(".");
}
function jsonPath(metadataKey: string): string {
if (!METADATA_KEY_RE.test(metadataKey)) {
throw new Error(
`Invalid metadata key '${metadataKey}'. Only letters, numbers, underscores, ` +
`nesting via '.', and array wildcards '[*]' are allowed.`,
);
}
return metadataKey
.split(".")
.map((part) =>
part.endsWith("[*]") ? `."${part.slice(0, -3)}"[*]` : `."${part}"`,
)
.join("");
}
const COMPARISON_OPERATORS: Record<string, string> = {
eq: "==",
ne: "!=",
gt: ">",
gte: ">=",
lt: "<",
lte: "<=",
};
const FIELD_OPERATORS = new Set([
...Object.keys(COMPARISON_OPERATORS),
"in",
"nin",
"contains",
"icontains",
]);
const LOGICAL_OPERATORS: Record<string, "and" | "or" | "not"> = {
$and: "and",
$or: "or",
$not: "not",
AND: "and",
OR: "or",
NOT: "not",
};
function isScalar(value: any): boolean {
return value === null || (typeof value !== "object" && !Array.isArray(value));
}
function bindFilterValue(
value: any,
binds: Record<string, any>,
): [string, string] {
const name = `f_${Object.keys(binds).length}`;
binds[name] = value;
return [`$${name}`, `:${name} AS "${name}"`];
}
function jsonExists(
path: string,
predicate: string,
passings: string[],
): string {
const passingClause =
passings.length > 0 ? ` PASSING ${passings.join(", ")}` : "";
return `JSON_EXISTS(payload, '$${path}?(${predicate})'${passingClause})`;
}
function buildFieldCondition(
metadataKey: string,
value: any,
binds: Record<string, any>,
): string {
const path = jsonPath(metadataKey);
if (value === "*") {
return `JSON_EXISTS(payload, '$${path}')`;
}
if (isScalar(value)) {
if (value === null) {
return jsonExists(path, "@ == null", []);
}
const [variable, passing] = bindFilterValue(value, binds);
return jsonExists(path, `@ == ${variable}`, [passing]);
}
if (Array.isArray(value)) {
throw new Error(
`Oracle filter for field '${metadataKey}' must be a scalar or an operator object`,
);
}
const operators = Object.entries(value);
if (operators.length === 0) {
throw new Error(
`Operator filter for field '${metadataKey}' must not be empty`,
);
}
const unsupported = operators
.map(([op]) => op)
.filter((op) => !FIELD_OPERATORS.has(op));
if (unsupported.length > 0) {
throw new Error(
`Unsupported Oracle filter operator(s) for field '${metadataKey}': ${unsupported.sort().join(", ")}`,
);
}
const predicates: string[] = [];
const passings: string[] = [];
const additionalClauses: string[] = [];
for (const [operator, operand] of operators) {
if (operator in COMPARISON_OPERATORS) {
if (!isScalar(operand)) {
throw new Error(
`Oracle filter operator '${operator}' requires a scalar value`,
);
}
if (operand === null) {
if (operator !== "eq" && operator !== "ne") {
throw new Error(
`Oracle filter operator '${operator}' does not support null`,
);
}
predicates.push(`@ ${COMPARISON_OPERATORS[operator]} null`);
continue;
}
const [variable, passing] = bindFilterValue(operand, binds);
predicates.push(`@ ${COMPARISON_OPERATORS[operator]} ${variable}`);
passings.push(passing);
continue;
}
if (operator === "in" || operator === "nin") {
if (!Array.isArray(operand) || operand.length === 0) {
throw new Error(
`Oracle filter operator '${operator}' requires a non-empty array`,
);
}
const variables: string[] = [];
const listPassings: string[] = [];
for (const item of operand) {
if (!isScalar(item)) {
throw new Error(
`Oracle filter operator '${operator}' requires scalar values`,
);
}
if (item === null) {
variables.push("null");
continue;
}
const [variable, passing] = bindFilterValue(item, binds);
variables.push(variable);
listPassings.push(passing);
}
const membership = jsonExists(
path,
`@ in (${variables.join(", ")})`,
listPassings,
);
additionalClauses.push(
operator === "in" ? membership : `NOT (${membership})`,
);
continue;
}
if (typeof operand !== "string") {
throw new Error(
`Oracle filter operator '${operator}' requires a string value`,
);
}
if (operator === "contains") {
const [variable, passing] = bindFilterValue(operand, binds);
predicates.push(`@ has substring ${variable}`);
passings.push(passing);
} else {
const [variable, passing] = bindFilterValue(operand.toLowerCase(), binds);
predicates.push(`@.lower() has substring ${variable}`);
passings.push(passing);
}
}
const clauses = [...additionalClauses];
if (predicates.length > 0) {
clauses.unshift(jsonExists(path, predicates.join(" && "), passings));
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`;
}
export function buildFilterGroup(
filters: Record<string, any>,
binds: Record<string, any>,
): string {
const entries = Object.entries(filters ?? {});
if (entries.length === 0) {
throw new Error("Oracle filter groups must be non-empty objects");
}
const clauses: string[] = [];
for (const [key, value] of entries) {
const logicalOperator = LOGICAL_OPERATORS[key];
if (logicalOperator) {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Logical filter operator '${key}' requires a non-empty array`,
);
}
const nested = value.map((condition) =>
buildFilterGroup(condition, binds),
);
if (logicalOperator === "not") {
clauses.push(`NOT (${nested.join(" OR ")})`);
} else {
clauses.push(
`(${nested.join(logicalOperator === "and" ? " AND " : " OR ")})`,
);
}
continue;
}
if (key.startsWith("$")) {
throw new Error(`Unsupported Oracle logical filter operator: ${key}`);
}
clauses.push(buildFieldCondition(key, value, binds));
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`;
}
export function buildWhereClause(
filters?: SearchFilters,
): [string, Record<string, any>] {
if (!filters || Object.keys(filters).length === 0) {
return ["", {}];
}
const binds: Record<string, any> = {};
return [`WHERE ${buildFilterGroup(filters, binds)}`, binds];
}
interface OracleDBConfig extends VectorStoreConfig {
connectionParams?: Record<string, any>;
useConnectionPool?: boolean;
client?: Connection | Pool;
collectionName?: string;
embeddingModelDims?: number;
distanceMetric?: DistanceMetric;
doCreateIndex?: boolean;
indexType?: IndexType;
indexName?: string;
indexParameters?: Record<string, number>;
indexAccuracy?: number;
}
export class OracleAIVectorSearch implements VectorStore {
private readonly collectionName: string;
private readonly indexName: string;
private readonly embeddingModelDims: number;
private readonly distanceMetric: DistanceMetric;
private readonly indexType: IndexType;
private readonly indexParameters: Record<string, number>;
private readonly indexAccuracy?: number;
private readonly doCreateIndex: boolean;
private readonly config: OracleDBConfig;
private oracledb: any;
private client?: Connection | Pool;
private ownsClient = false;
private _initPromise?: Promise<void>;
constructor(config: OracleDBConfig) {
if (!config.connectionParams && !config.client) {
throw new Error(
"Must provide at least one of `connectionParams` and `client`",
);
}
this.collectionName = quoteIdentifier(config.collectionName || "mem0");
this.indexName = quoteIdentifier(
config.indexName || `${config.collectionName || "mem0"}_VEC_IDX`,
);
this.embeddingModelDims = config.embeddingModelDims ?? 1536;
if (
!Number.isInteger(this.embeddingModelDims) ||
this.embeddingModelDims <= 0
) {
throw new Error("`embeddingModelDims` must be a positive integer");
}
const distanceMetric = (config.distanceMetric ??
"COSINE") as string as DistanceMetric;
this.distanceMetric = distanceMetric.toUpperCase() as DistanceMetric;
if (!DISTANCE_METRICS.includes(this.distanceMetric)) {
throw new Error(`Unsupported distance metric: ${config.distanceMetric}`);
}
const indexType = (config.indexType ?? "HNSW") as string;
this.indexType = indexType.toUpperCase() as IndexType;
if (this.indexType !== "HNSW" && this.indexType !== "IVF") {
throw new Error(`Unsupported index type: ${config.indexType}`);
}
this.indexAccuracy = config.indexAccuracy;
if (
this.indexAccuracy !== undefined &&
(!Number.isInteger(this.indexAccuracy) ||
this.indexAccuracy <= 0 ||
this.indexAccuracy > 100)
) {
throw new Error("`indexAccuracy` must be an integer between 1 and 100");
}
this.indexParameters = this.validateIndexParameters(config.indexParameters);
this.doCreateIndex = config.doCreateIndex ?? true;
this.config = config;
}
private validateIndexParameters(
parameters?: Record<string, number>,
): Record<string, number> {
if (!parameters) return {};
const allowed = INDEX_PARAMETER_RANGES[this.indexType];
const validated: Record<string, number> = {};
for (const [key, value] of Object.entries(parameters)) {
const range = allowed[key];
if (!range) {
throw new Error(
`Unsupported ${this.indexType} index parameter '${key}'. ` +
`Allowed: ${Object.keys(allowed).join(", ")}`,
);
}
if (!Number.isInteger(value) || value < range[0] || value > range[1]) {
throw new Error(
`Index parameter '${key}' must be an integer between ${range[0]} and ${range[1]}`,
);
}
validated[key] = value;
}
return validated;
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize().catch(async (error) => {
if (this.ownsClient && this.client) {
await Promise.resolve(this.client.close()).catch(() => {});
this.client = undefined;
this.ownsClient = false;
}
this._initPromise = undefined;
throw error;
});
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
const sdk = await loadPeer(
"oracledb",
"Oracle AI Vector Search",
() => import("oracledb"),
);
this.oracledb = sdk.default ?? sdk;
if (this.config.client) {
this.client = this.config.client;
} else if (this.config.useConnectionPool ?? true) {
this.client = await this.oracledb.createPool({
poolMin: 1,
poolMax: 4,
...this.config.connectionParams,
});
this.ownsClient = true;
} else {
this.client = await this.oracledb.getConnection(
this.config.connectionParams,
);
this.ownsClient = true;
}
await this.assertVectorSupport();
await this.createCol();
}
private isPool(client: Connection | Pool): client is Pool {
return typeof (client as Pool).getConnection === "function";
}
private async withConnection<T>(
fn: (connection: Connection) => Promise<T>,
commit = false,
): Promise<T> {
const client = this.client!;
if (!this.isPool(client)) {
const connection = client as Connection;
try {
const result = await fn(connection);
if (commit) await connection.commit();
return result;
} catch (err) {
await connection.rollback();
throw err;
}
}
const connection = await client.getConnection();
try {
const result = await fn(connection);
if (commit) await connection.commit();
return result;
} catch (err) {
await connection.rollback();
throw err;
} finally {
await connection.close();
}
}
private async assertVectorSupport(): Promise<void> {
if (!this.oracledb.thin) {
const [major, minor] = [
Math.floor(this.oracledb.oracleClientVersion / 100000000),
Math.floor(this.oracledb.oracleClientVersion / 100000) % 100,
];
if (major < 23 || (major === 23 && minor < 4)) {
throw new Error(
`Oracle DB client driver version ${this.oracledb.oracleClientVersionString} ` +
"not supported, must be >=23.4 for vector support",
);
}
}
const version = await this.withConnection(
async (connection) => connection.oracleServerVersionString,
);
const [major, minor] = version.split(".").map(Number);
if (major < 23 || (major === 23 && minor < 4)) {
throw new Error(
`Oracle DB version ${version} not supported, must be >=23.4 for vector support`,
);
}
}
private createIndexDdl(): string {
const accuracy = this.indexAccuracy
? `WITH TARGET ACCURACY ${this.indexAccuracy}`
: "";
const parameterEntries = Object.entries(this.indexParameters);
const parameters =
parameterEntries.length > 0
? `PARAMETERS (${[
`type ${this.indexType}`,
...parameterEntries.map(([key, value]) => `${key} ${value}`),
].join(", ")})`
: "";
const organization =
this.indexType === "HNSW"
? "INMEMORY NEIGHBOR GRAPH"
: "NEIGHBOR PARTITIONS";
return (
`CREATE VECTOR INDEX IF NOT EXISTS ${this.indexName} ON ${this.collectionName} (vector) ` +
`ORGANIZATION ${organization} DISTANCE ${this.distanceMetric} ${accuracy} ${parameters}`
);
}
private async createCol(): Promise<void> {
await this.withConnection(async (connection) => {
await connection.execute(`
CREATE TABLE IF NOT EXISTS ${this.collectionName} (
id VARCHAR2(36) PRIMARY KEY,
vector VECTOR(${this.embeddingModelDims}),
payload JSON
)
`);
await connection.execute(`
CREATE TABLE IF NOT EXISTS memory_migrations (
id NUMBER PRIMARY KEY,
user_id VARCHAR2(255) NOT NULL
)
`);
if (this.doCreateIndex) {
await connection.execute(this.createIndexDdl());
}
}, true);
}
private loadPayload(value: any): Record<string, any> {
if (value === null || value === undefined) return {};
if (typeof value === "string") return JSON.parse(value);
if (Buffer.isBuffer(value)) return JSON.parse(value.toString("utf-8"));
return value;
}
private vectorBind(vector: number[]) {
return {
type: this.oracledb.DB_TYPE_VECTOR,
val: new Float32Array(vector),
};
}
private payloadBind(payload: Record<string, any>) {
return { type: this.oracledb.DB_TYPE_JSON, val: payload };
}
async insert(
vectors: number[][],
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
if (ids.length !== vectors.length) {
throw new Error("ids and vectors must have the same length");
}
if (payloads.length !== vectors.length) {
throw new Error("payloads and vectors must have the same length");
}
if (vectors.length === 0) return;
await this.initialize();
await this.withConnection(async (connection) => {
await connection.executeMany(
`INSERT INTO ${this.collectionName} (id, vector, payload) VALUES (:id, :vector, :payload)`,
vectors.map((vector, i) => ({
id: ids[i],
vector: new Float32Array(vector),
payload: payloads[i] ?? {},
})) as BindParameters[],
{
bindDefs: {
id: { type: this.oracledb.DB_TYPE_VARCHAR, maxSize: 36 },
vector: { type: this.oracledb.DB_TYPE_VECTOR },
payload: { type: this.oracledb.DB_TYPE_JSON },
},
},
);
}, true);
}
async search(
query: number[],
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[]> {
await this.initialize();
const [whereClause, filterBinds] = buildWhereClause(filters);
const hasFilter = whereClause.length > 0;
const selectClause = hasFilter
? "SELECT"
: `SELECT /*+ VECTOR_INDEX_TRANSFORM(${this.collectionName}) */`;
const sql =
`${selectClause} id, payload, VECTOR_DISTANCE(vector, :query_vec, ${this.distanceMetric}) distance ` +
`FROM ${this.collectionName} ${whereClause} ORDER BY distance FETCH APPROX FIRST :max_rows ROWS ONLY`;
const rows = await this.withConnection(async (connection) => {
const result = await connection.execute<any[]>(sql, {
query_vec: this.vectorBind(query),
max_rows: topK,
...filterBinds,
});
return result.rows ?? [];
});
return rows.map((row) => ({
id: row[0],
payload: this.loadPayload(row[1]),
score: SCORE_FROM_DISTANCE[this.distanceMetric](Number(row[2])),
}));
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
await this.initialize();
const rows = await this.withConnection(async (connection) => {
const result = await connection.execute<any[]>(
`SELECT id, payload FROM ${this.collectionName} WHERE id = :vector_id`,
{ vector_id: vectorId },
);
return result.rows ?? [];
});
if (rows.length === 0) return null;
return { id: rows[0][0], payload: this.loadPayload(rows[0][1]) };
}
async update(
vectorId: string,
vector: number[],
payload: Record<string, any>,
): Promise<void> {
await this.initialize();
const assignments: string[] = [];
const binds: Record<string, any> = { vector_id: vectorId };
if (vector) {
assignments.push("vector = :vector");
binds.vector = this.vectorBind(vector);
}
if (payload) {
assignments.push("payload = :payload");
binds.payload = this.payloadBind(payload);
}
if (assignments.length === 0) return;
await this.withConnection(
(connection) =>
connection.execute(
`UPDATE ${this.collectionName} SET ${assignments.join(", ")} WHERE id = :vector_id`,
binds,
),
true,
);
}
async delete(vectorId: string): Promise<void> {
await this.initialize();
await this.withConnection(
(connection) =>
connection.execute(
`DELETE FROM ${this.collectionName} WHERE id = :vector_id`,
{ vector_id: vectorId },
),
true,
);
}
async deleteCol(): Promise<void> {
await this.initialize();
await this.withConnection(
(connection) =>
connection.execute(`DROP TABLE ${this.collectionName} PURGE`),
true,
);
}
async list(
filters?: SearchFilters,
topK: number = 100,
): Promise<[VectorStoreResult[], number]> {
await this.initialize();
const [whereClause, filterBinds] = buildWhereClause(filters);
return this.withConnection(async (connection) => {
const listResult = await connection.execute<any[]>(
`SELECT id, payload, COUNT(*) OVER () total FROM ${this.collectionName} ${whereClause} FETCH FIRST :max_rows ROWS ONLY`,
{ ...filterBinds, max_rows: topK },
);
const rows = listResult.rows ?? [];
const results = rows.map((row) => ({
id: row[0],
payload: this.loadPayload(row[1]),
}));
return [results, Number(rows[0]?.[2] ?? 0)];
});
}
async getUserId(): Promise<string> {
await this.initialize();
const rows = await this.withConnection(async (connection) => {
const result = await connection.execute<any[]>(
"SELECT user_id FROM memory_migrations WHERE id = 1",
);
return result.rows ?? [];
});
if (rows.length > 0) return rows[0][0];
const generatedUserId = uuidv4();
await this.setUserId(generatedUserId);
return generatedUserId;
}
async setUserId(userId: string): Promise<void> {
await this.initialize();
await this.withConnection(async (connection) => {
await connection.execute("DELETE FROM memory_migrations WHERE id = 1");
await connection.execute(
"INSERT INTO memory_migrations (id, user_id) VALUES (1, :user_id)",
{ user_id: userId },
);
}, true);
}
async close(): Promise<void> {
if (this.client && this.ownsClient) {
await this.client.close();
}
}
}
+4 -15
View File
@@ -8,6 +8,7 @@ import type {
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
import { loadPeer } from "../utils/load_peer";
import { toCamelCasePreservingIds } from "../utils/casing";
/**
* Escape RediSearch TAG filter special characters. Any punctuation in the
@@ -145,18 +146,6 @@ export function buildRedisFilterExpr(filters?: SearchFilters): string {
return conditions.length > 0 ? conditions.join(" ") : "*";
}
// Utility function to convert object keys to camelCase
function toCamelCase(obj: Record<string, any>): Record<string, any> {
if (typeof obj !== "object" || obj === null) return obj;
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
value,
]),
);
}
export class RedisDB implements VectorStore {
private client!: RedisClientType<
RedisDefaultModules & RedisModules & RedisFunctions & RedisScripts
@@ -468,7 +457,7 @@ export class RedisDB implements VectorStore {
return {
id: doc.value.memory_id,
payload: toCamelCase(resultPayload),
payload: toCamelCasePreservingIds(resultPayload),
score: Math.max(0, 1 - (Number(doc.value.__vector_score) ?? 0)),
};
});
@@ -573,7 +562,7 @@ export class RedisDB implements VectorStore {
return {
id: vectorId,
payload: toCamelCase(payload),
payload: toCamelCasePreservingIds(payload),
};
} catch (error) {
console.error("Error getting vector:", error);
@@ -680,7 +669,7 @@ export class RedisDB implements VectorStore {
const items = results.documents.map((doc) => ({
id: doc.value.memory_id,
payload: toCamelCase({
payload: toCamelCasePreservingIds({
hash: doc.value.hash,
data: doc.value.memory,
created_at: new Date(parseInt(doc.value.created_at)).toISOString(),
+2 -11
View File
@@ -2,6 +2,7 @@ import { VectorStore } from "./base";
import { SearchFilters, VectorStoreResult } from "../types";
import { ValkeyConfig } from "../types/valkey";
import { loadPeer } from "../utils/load_peer";
import { toCamelCasePreservingIds } from "../utils/casing";
interface ValkeyClient {
call: (...args: (string | number | Buffer)[]) => Promise<unknown>;
@@ -45,16 +46,6 @@ function toSnakeCase(obj: Record<string, any>): Record<string, any> {
);
}
function toCamelCase(obj: Record<string, any>): Record<string, any> {
if (typeof obj !== "object" || obj === null) return obj;
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
value,
]),
);
}
interface ValkeySearchDoc {
memory_id?: string;
hash?: string;
@@ -388,7 +379,7 @@ export class ValkeyDB implements VectorStore {
return {
id: doc.memory_id ?? "",
payload: toCamelCase(resultPayload),
payload: toCamelCasePreservingIds(resultPayload),
score,
};
}
+2 -8
View File
@@ -269,9 +269,7 @@ describe("Memory - add()", () => {
"runId",
"actor_id",
]) {
if (key !== canonicalKey) {
expect(stored!.metadata).not.toHaveProperty(key);
}
expect(stored!.metadata).not.toHaveProperty(key);
}
},
);
@@ -307,10 +305,7 @@ describe("Memory - add()", () => {
}
}
expect(stored!.metadata).toEqual(
expect.objectContaining({
[filterKey]: filterValue,
ordinary: "preserved",
}),
expect.objectContaining({ ordinary: "preserved" }),
);
for (const key of [
"user_id",
@@ -321,7 +316,6 @@ describe("Memory - add()", () => {
"runId",
"actor_id",
]) {
if (key === filterKey) continue;
expect(stored!.metadata).not.toHaveProperty(key);
}
},
+10
View File
@@ -128,6 +128,16 @@ describe("Memory - get()", () => {
expect(item!.createdAt).toBeDefined();
expect(new Date(item!.createdAt!).toString()).not.toBe("Invalid Date");
});
test("does not duplicate the entity id inside metadata", async () => {
const addResult: SearchResult = await memory.add("Metadata leak test", {
userId,
});
const item: any = await memory.get(addResult.results[0].id);
expect(item.user_id).toBe(userId);
expect(item.metadata).not.toHaveProperty("user_id");
expect(item.metadata).not.toHaveProperty("userId");
});
});
// ─── update() ────────────────────────────────────────────
@@ -0,0 +1,125 @@
/// <reference types="jest" />
/** add() with infer=false must embed Object.prototype-colliding text instead of resolving it off the prototype chain. */
import { Memory } from "../src/memory";
import { MemoryVectorStore } from "../src/vector_stores/memory";
import type { SearchResult } from "../src/types";
const mockEmbedding = new Array(1536).fill(0.1);
const mockEmbed = jest.fn().mockResolvedValue(mockEmbedding);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: mockEmbed,
embedBatch: jest.fn(),
embeddingDims: 1536,
})),
}));
function createMemory(): Memory {
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-proto-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
describe("add() with infer=false and Object.prototype-colliding text", () => {
let memory: Memory;
let insertSpy: jest.SpyInstance;
beforeEach(() => {
memory = createMemory();
mockEmbed.mockClear();
insertSpy = jest.spyOn(MemoryVectorStore.prototype, "insert");
});
afterEach(async () => {
insertSpy.mockRestore();
await memory.reset();
});
test.each([
"constructor",
"toString",
"valueOf",
"hasOwnProperty",
"__proto__",
])(
'embeds "%s" instead of resolving it off Object.prototype',
async (text) => {
const result: SearchResult = await memory.add(text, {
userId: "u1",
infer: false,
});
expect(mockEmbed).toHaveBeenCalledWith(text, "add");
const [storedVectors] =
insertSpy.mock.calls[insertSpy.mock.calls.length - 1];
expect(Array.isArray(storedVectors[0])).toBe(true);
expect(storedVectors[0]).toEqual(mockEmbedding);
expect(
storedVectors[0].every((n: unknown) => typeof n === "number"),
).toBe(true);
expect(result.results[0].memory).toBe(text);
},
);
});
describe("update() metadata-only on Object.prototype-colliding text (#6323)", () => {
let memory: Memory;
let updateSpy: jest.SpyInstance;
beforeEach(() => {
memory = createMemory();
updateSpy = jest.spyOn(MemoryVectorStore.prototype, "update");
});
afterEach(async () => {
updateSpy.mockRestore();
await memory.reset();
});
test.each([
"constructor",
"toString",
"valueOf",
"hasOwnProperty",
"__proto__",
])(
're-embeds "%s" on a metadata-only update instead of resolving it off Object.prototype',
async (text) => {
const added: SearchResult = await memory.add(text, {
userId: "u1",
infer: false,
});
const memoryId = added.results[0].id!;
mockEmbed.mockClear();
await memory.update(memoryId, { metadata: { pinned: true } });
expect(mockEmbed).toHaveBeenCalledWith(text, "update");
const [, storedVector] =
updateSpy.mock.calls[updateSpy.mock.calls.length - 1];
expect(Array.isArray(storedVector)).toBe(true);
expect(storedVector).toEqual(mockEmbedding);
},
);
});
@@ -57,34 +57,25 @@ function decayPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
payload?: Record<string, any>;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? decayPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(decayPayload())
: options.payload,
},
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -222,32 +213,10 @@ describe("Node OSS decay feature error notice", () => {
expect(notices[0].properties.bypass_reason).toBeUndefined();
});
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: DECAY_COPY,
}),
);
});
it("uses plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: JSON.stringify(decayPayload({ enabled: false })),
payload: decayPayload({ enabled: false }),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -270,17 +239,8 @@ describe("Node OSS decay feature error notice", () => {
});
it.each([
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(decayPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", decayPayload({ copy: "" }), "missing_copy"],
])("uses plain error for %s", async (_label, payload, bypassReason) => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
@@ -304,11 +264,8 @@ describe("Node OSS decay feature error notice", () => {
);
});
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -316,19 +273,14 @@ describe("Node OSS decay feature error notice", () => {
PLAIN_DECAY_ERROR,
);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and emits no event when PostHog fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
displayed: false,
}),
);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
@@ -361,7 +313,9 @@ describe("Node OSS decay feature error notice", () => {
);
expect(
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
),
).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0);
});
@@ -81,33 +81,25 @@ function decayUsagePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
payload?: Record<string, any>;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? decayUsagePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
const payload =
options.payload === undefined
? JSON.stringify(decayUsagePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -134,9 +126,10 @@ function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
}
async function createMemory() {
@@ -226,7 +219,7 @@ describe("Node OSS decay usage notice", () => {
await memory.delete(id);
}
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
expect(stderrSpy).not.toHaveBeenCalledWith(
@@ -279,7 +272,7 @@ describe("Node OSS decay usage notice", () => {
await expect(memory.delete("memory-id")).rejects.toThrow("delete failed");
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
@@ -317,7 +310,7 @@ describe("Node OSS decay usage notice", () => {
await memory.deleteAll({ userId: "empty-delete-all-user" });
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
@@ -332,7 +325,7 @@ describe("Node OSS decay usage notice", () => {
"At least one filter is required",
);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
@@ -365,27 +358,21 @@ describe("Node OSS decay usage notice", () => {
it.each([
[
"disabled payload",
JSON.stringify(decayUsagePayload({ enabled: false })),
decayUsagePayload({ enabled: false }),
"payload_disabled",
"payload_disabled",
],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
undefined,
],
["missing config", { notices: {} }, "missing_notice_config", undefined],
[
"missing copy",
JSON.stringify({
{
notices: {
decay_usage: { enabled: true, notice_type: "log_line" },
},
}),
},
"missing_copy",
undefined,
],
["malformed payload", "{not-json", "missing_notice_config", undefined],
])(
"stays silent and emits safe bypass for %s",
async (_label, payload, bypassReason, disabledReason) => {
@@ -416,33 +403,23 @@ describe("Node OSS decay usage notice", () => {
},
);
it("does not emit or consume cap when the blunt flag is disabled", async () => {
it("emits not-displayed event when config fetch fails (bundled config fallback)", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-flag-disabled-user", 3);
await addMemories(memory, "decay-config-failure-user", 3);
await memory.deleteAll({ userId: "decay-flag-disabled-user" });
await memory.deleteAll({ userId: "decay-config-failure-user" });
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("does not emit or consume cap when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-posthog-failure-user", 3);
await memory.deleteAll({ userId: "decay-posthog-failure-user" });
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_usage",
displayed: false,
}),
);
});
it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => {
@@ -484,7 +461,7 @@ describe("Node OSS decay usage notice", () => {
await memory.deleteAll({ userId: "decay-cap-user" });
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state.decay_usage.events).toHaveLength(10);
});
@@ -493,7 +470,7 @@ describe("Node OSS decay usage notice", () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify({
payload: {
notices: {
first_run: {
enabled: true,
@@ -506,7 +483,7 @@ describe("Node OSS decay usage notice", () => {
copy: DECAY_USAGE_COPY,
},
},
}),
},
});
global.fetch = fetchMock as any;
const memory = await createMemory();
+23 -19
View File
@@ -57,29 +57,24 @@ function firstRunPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failFlags?: boolean;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? firstRunPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: true,
variant: options.variant ?? "displayed",
metadata: {
payload: JSON.stringify(options.payload ?? firstRunPayload()),
},
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -376,23 +371,32 @@ describe("Node OSS first-run notice", () => {
);
expect(
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
),
).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("does not break the successful operation when flag evaluation fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
it("does not break the successful operation when config fetch fails", async () => {
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
const result = await memory.add("Flag failure content", {
const result = await memory.add("Config failure content", {
userId: "first-run-failure",
infer: false,
});
expect(result.results).toHaveLength(1);
expect(noticeEvents(calls)).toHaveLength(0);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
displayed: false,
}),
);
});
it("can trigger from get() when get is the first successful public call", async () => {
@@ -138,16 +138,49 @@ describe("Node OSS notice foundation", () => {
expect(fs.existsSync(notices.getMem0ConfigPath())).toBe(false);
});
it("returns null when PostHog flag evaluation fails", async () => {
it("falls back to bundled config when remote fetch fails", async () => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockRejectedValue(new Error("network down"));
await expect(
notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }),
).resolves.toBeNull();
const firstResult = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock,
});
const secondResult = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock,
});
expect(firstResult).not.toBeNull();
expect(secondResult?.variant).toBe(firstResult?.variant);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("returns null when PostHog flag evaluation times out", async () => {
it("coalesces concurrent remote config fetches", async () => {
const notices = await import("../src/utils/notices");
const resolvers: Array<(response: any) => void> = [];
const fetchMock = jest.fn(
() => new Promise((resolve) => resolvers.push(resolve)),
);
const evaluations = [
notices.evaluateNoticeFlag("user-0", { fetchImpl: fetchMock as any }),
notices.evaluateNoticeFlag("user-1", { fetchImpl: fetchMock as any }),
];
resolvers.forEach((resolve) =>
resolve({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 0.5,
notices: {},
}),
}),
);
await Promise.all(evaluations);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("falls back to bundled config when remote fetch times out", async () => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn(
(_url: string | URL | Request, init?: RequestInit) =>
@@ -158,34 +191,28 @@ describe("Node OSS notice foundation", () => {
}),
);
await expect(
notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock as any,
timeoutMs: 1,
}),
).resolves.toBeNull();
const startedAt = Date.now();
const result = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock as any,
timeoutMs: 10,
});
expect(result).not.toBeNull();
expect(result?.variant).toMatch(/^(displayed|holdout)$/);
expect(Date.now() - startedAt).toBeLessThan(500);
});
it("parses displayed variant and JSON payload from PostHog flags response", async () => {
it("parses variant and notices from static config response", async () => {
const notices = await import("../src/utils/notices");
const payload = {
notices: {
foundation_notice: {
enabled: true,
notice_type: "log_line",
copy: "Foundation notice",
},
},
};
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
version: 1,
variant_split: 1.0,
notices: {
foundation_notice: {
enabled: true,
variant: "displayed",
metadata: { payload: JSON.stringify(payload) },
notice_type: "log_line",
copy: "Foundation notice",
},
},
}),
@@ -203,6 +230,27 @@ describe("Node OSS notice foundation", () => {
expect(parsed.config?.copy).toBe("Foundation notice");
});
it.each([
["user-0", "holdout"],
["user-1", "displayed"],
])("preserves the PostHog cohort for %s", async (distinctId, expected) => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 0.5,
notices: {},
}),
});
const result = await notices.evaluateNoticeFlag(distinctId, {
fetchImpl: fetchMock,
});
expect(result?.variant).toBe(expected);
});
it("emits mem0.notice_displayed with sample_rate=1", async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
@@ -81,33 +81,25 @@ function performancePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
payload?: Record<string, any>;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? performancePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
const payload =
options.payload === undefined
? JSON.stringify(performancePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -140,9 +132,10 @@ function performanceEvents(calls: any[]) {
);
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
}
function mockSearchElapsed(elapsedMs: number) {
@@ -294,20 +287,11 @@ describe("Node OSS performance slow query notice", () => {
it.each([
[
"disabled payload",
JSON.stringify(performancePayload({ enabled: false })),
performancePayload({ enabled: false }),
"payload_disabled",
],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(performancePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", performancePayload({ copy: "" }), "missing_copy"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
@@ -347,7 +331,7 @@ describe("Node OSS performance slow query notice", () => {
topK: 3,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
@@ -366,17 +350,14 @@ describe("Node OSS performance slow query notice", () => {
}),
).rejects.toThrow("filters must contain");
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does not consume cap or emit when the blunt flag is disabled", async () => {
it("emits not-displayed when config fetch fails (bundled config fallback)", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
@@ -387,25 +368,14 @@ describe("Node OSS performance slow query notice", () => {
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does not consume cap or emit when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
const notices = performanceEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "performance_slow_query",
displayed: false,
}),
);
});
it("does nothing when telemetry is off", async () => {
@@ -444,7 +414,7 @@ describe("Node OSS performance slow query notice", () => {
}
expect(performanceEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(
readConfig().notice_state.performance_slow_query.events,
@@ -87,33 +87,25 @@ function scalePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
payload?: Record<string, any>;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? scalePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
const payload =
options.payload === undefined
? JSON.stringify(scalePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -146,9 +138,10 @@ function scaleEvents(calls: any[]) {
);
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
}
async function createMemory() {
@@ -290,14 +283,14 @@ describe("Node OSS scale threshold notice", () => {
topK: 49,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.scale_threshold).toBeUndefined();
});
it("marks memory-count threshold evaluated before PostHog display", async () => {
it("marks memory-count threshold evaluated even when config fetch fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
(memory as any).vectorStore.count = jest
@@ -314,7 +307,14 @@ describe("Node OSS scale threshold notice", () => {
readConfig().notice_state.scale_threshold
.memory_count_threshold_evaluated,
).toBe(true);
expect(scaleEvents(calls)).toHaveLength(0);
const notices = scaleEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "scale_threshold",
displayed: false,
}),
);
});
it("does not count provider memories once threshold was evaluated", async () => {
@@ -332,7 +332,7 @@ describe("Node OSS scale threshold notice", () => {
});
expect((memory as any).vectorStore.count).not.toHaveBeenCalled();
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
});
it("throttles under-threshold provider counts", async () => {
@@ -354,14 +354,14 @@ describe("Node OSS scale threshold notice", () => {
});
expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
});
it("records holdout and disabled variants silently", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: JSON.stringify(scalePayload({ enabled: false })),
payload: scalePayload({ enabled: false }),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -389,7 +389,7 @@ describe("Node OSS scale threshold notice", () => {
delete (payload.notices.scale_threshold as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify(payload),
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -425,7 +425,7 @@ describe("Node OSS scale threshold notice", () => {
}
expect(scaleEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10);
});
@@ -456,7 +456,7 @@ describe("Node OSS scale threshold notice", () => {
topK: 50,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state).toBeUndefined();
});
@@ -61,34 +61,25 @@ function temporalPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
payload?: Record<string, any>;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? temporalPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(temporalPayload())
: options.payload,
},
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -265,37 +256,12 @@ describe("Node OSS temporal feature error notice", () => {
},
);
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: TEMPORAL_COPY,
}),
);
});
it("treats missing enabled as enabled for feature-error payloads", async () => {
const payload = temporalPayload();
delete (payload.notices.temporal_stub as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify(payload),
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -319,7 +285,7 @@ describe("Node OSS temporal feature error notice", () => {
it("uses timestamp plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify(temporalPayload({ enabled: false })),
payload: temporalPayload({ enabled: false }),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -347,17 +313,8 @@ describe("Node OSS temporal feature error notice", () => {
});
it.each([
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", temporalPayload({ copy: "" }), "missing_copy"],
])(
"uses referenceDate plain error for %s",
async (_label, payload, bypassReason) => {
@@ -389,11 +346,8 @@ describe("Node OSS temporal feature error notice", () => {
},
);
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -404,22 +358,14 @@ describe("Node OSS temporal feature error notice", () => {
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and emits no event when PostHog fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: "2026-05-06",
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
displayed: false,
}),
).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR);
expect(noticeEvents(calls)).toHaveLength(0);
);
});
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
@@ -81,33 +81,25 @@ function temporalUsagePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
payload?: Record<string, any>;
failConfig?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? temporalUsagePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
}
const payload =
options.payload === undefined
? JSON.stringify(temporalUsagePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
version: 1,
variant_split: variantSplit,
...payload,
}),
};
}
@@ -140,9 +132,10 @@ function temporalUsageEvents(calls: any[]) {
);
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
}
async function createMemory() {
@@ -320,7 +313,7 @@ describe("Node OSS temporal usage notice", () => {
topK: 3,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(configRequestCount(fetchMock)).toBe(0);
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
@@ -377,20 +370,11 @@ describe("Node OSS temporal usage notice", () => {
it.each([
[
"disabled payload",
JSON.stringify(temporalUsagePayload({ enabled: false })),
temporalUsagePayload({ enabled: false }),
"payload_disabled",
],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalUsagePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", temporalUsagePayload({ copy: "" }), "missing_copy"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
@@ -418,12 +402,9 @@ describe("Node OSS temporal usage notice", () => {
);
});
it("does not consume cap or emit when the blunt flag is disabled", async () => {
it("emits not-displayed when config fetch fails (bundled config fallback)", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -433,24 +414,14 @@ describe("Node OSS temporal usage notice", () => {
metadata: { event_date: "2025-04-09" },
});
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("does not consume cap or emit when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
displayed: false,
}),
);
});
it("does nothing when telemetry is off", async () => {
@@ -487,7 +458,7 @@ describe("Node OSS temporal usage notice", () => {
}
expect(temporalUsageEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(readConfig().notice_state.temporal_usage.events).toHaveLength(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
});
+475
View File
@@ -0,0 +1,475 @@
/// <reference types="jest" />
/** Oracle AI Vector Search filter, config and SQL tests. The driver is mocked, so no database is needed. */
const DB_TYPE_VECTOR = { name: "DB_TYPE_VECTOR" };
const DB_TYPE_JSON = { name: "DB_TYPE_JSON" };
const DB_TYPE_VARCHAR = { name: "DB_TYPE_VARCHAR" };
const mockCreatePool = jest.fn();
jest.mock(
"oracledb",
() => ({
thin: true,
DB_TYPE_VECTOR,
DB_TYPE_JSON,
DB_TYPE_VARCHAR,
createPool: mockCreatePool,
}),
{ virtual: true },
);
import {
OracleAIVectorSearch,
buildWhereClause,
quoteIdentifier,
} from "../src/vector_stores/oracledb";
type Call = { sql: string; binds: any; options?: any };
function fakeConnection(calls: Call[], resultsBySql: Array<any[][]>) {
let selectIndex = 0;
return {
oracleServerVersionString: "23.4.0.24.05",
async execute(sql: string, binds: any = {}) {
calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds });
if (/^\s*SELECT/i.test(sql)) {
return { rows: resultsBySql[selectIndex++] ?? [] };
}
return { rows: [] };
},
async executeMany(sql: string, binds: any[], options: any = {}) {
calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds, options });
return { rows: [] };
},
async commit() {},
async rollback() {},
async close() {},
};
}
function makeStore(
calls: Call[],
results: Array<any[][]> = [],
overrides = {},
) {
return new OracleAIVectorSearch({
client: fakeConnection(calls, results) as any,
collectionName: "mem0",
embeddingModelDims: 3,
...overrides,
} as any);
}
describe("quoteIdentifier", () => {
it("quotes a bare name", () => {
expect(quoteIdentifier("mem0")).toBe('"mem0"');
});
it("quotes each segment of a schema-qualified name", () => {
expect(quoteIdentifier("app.mem0")).toBe('"app"."mem0"');
});
it("preserves already-quoted segments", () => {
expect(quoteIdentifier('"App"."Mem0"')).toBe('"App"."Mem0"');
});
it("rejects a name that would break out of the quoting", () => {
expect(() => quoteIdentifier('mem0" (x); DROP TABLE t--')).toThrow(
/is not valid/,
);
});
});
describe("buildWhereClause", () => {
it("returns no clause for empty filters", () => {
expect(buildWhereClause(undefined)).toEqual(["", {}]);
expect(buildWhereClause({})).toEqual(["", {}]);
});
it("binds a scalar equality instead of inlining it", () => {
const [clause, binds] = buildWhereClause({ user_id: "alice" });
expect(clause).toBe(
`WHERE JSON_EXISTS(payload, '$."user_id"?(@ == $f_0)' PASSING :f_0 AS "f_0")`,
);
expect(binds).toEqual({ f_0: "alice" });
});
it("ANDs multiple fields", () => {
const [clause, binds] = buildWhereClause({
user_id: "alice",
agent_id: "bot",
});
expect(clause.startsWith("WHERE (")).toBe(true);
expect(clause).toContain(" AND ");
expect(binds).toEqual({ f_0: "alice", f_1: "bot" });
});
it("applies every operator in a compound range filter", () => {
const [clause, binds] = buildWhereClause({ age: { gte: 10, lte: 20 } });
expect(clause).toContain("@ >= $f_0 && @ <= $f_1");
expect(binds).toEqual({ f_0: 10, f_1: 20 });
});
it("builds an existence check for the wildcard filter", () => {
const [clause, binds] = buildWhereClause({ user_id: "*" });
expect(clause).toBe(`WHERE JSON_EXISTS(payload, '$."user_id"')`);
expect(binds).toEqual({});
});
it("builds membership for in and negates it for nin", () => {
const [inClause] = buildWhereClause({ user_id: { in: ["a", "b"] } });
expect(inClause).toContain("@ in ($f_0, $f_1)");
expect(inClause).not.toContain("NOT (");
const [ninClause] = buildWhereClause({ user_id: { nin: ["a"] } });
expect(ninClause).toContain("NOT (");
});
it("lowercases the operand for icontains", () => {
const [clause, binds] = buildWhereClause({ data: { icontains: "SciFi" } });
expect(clause).toContain("@.lower() has substring $f_0");
expect(binds).toEqual({ f_0: "scifi" });
});
it("ORs the branches of a $or group", () => {
const [clause, binds] = buildWhereClause({
$or: [{ user_id: "alice" }, { agent_id: "bot" }],
});
expect(clause).toContain(" OR ");
expect(binds).toEqual({ f_0: "alice", f_1: "bot" });
});
it("negates a $not group", () => {
const [clause] = buildWhereClause({ $not: [{ user_id: "alice" }] });
expect(clause.startsWith("WHERE NOT (")).toBe(true);
});
it("nests logical groups", () => {
const [clause, binds] = buildWhereClause({
user_id: "alice",
$or: [{ agent_id: "bot" }, { run_id: "r1" }],
});
expect(clause).toContain(" AND ");
expect(clause).toContain(" OR ");
expect(Object.keys(binds)).toEqual(["f_0", "f_1", "f_2"]);
});
it("compares against JSON null without a bind", () => {
const [clause, binds] = buildWhereClause({ agent_id: null });
expect(clause).toBe(
`WHERE JSON_EXISTS(payload, '$."agent_id"?(@ == null)')`,
);
expect(binds).toEqual({});
});
it("rejects a metadata key that could escape the JSON path", () => {
expect(() => buildWhereClause({ 'a"?(1==1))--': "x" })).toThrow(
/Invalid metadata key/,
);
});
it("rejects an unsupported field operator", () => {
expect(() => buildWhereClause({ age: { regex: "^a" } })).toThrow(
/Unsupported Oracle filter operator/,
);
});
it("rejects an unsupported logical operator", () => {
expect(() => buildWhereClause({ $nor: [{ a: 1 }] })).toThrow(
/Unsupported Oracle logical filter operator/,
);
});
it("rejects an empty in list", () => {
expect(() => buildWhereClause({ user_id: { in: [] } })).toThrow(
/non-empty array/,
);
});
it("rejects a non-scalar comparison operand", () => {
expect(() => buildWhereClause({ age: { gt: [1] } })).toThrow(
/requires a scalar value/,
);
});
});
describe("OracleAIVectorSearch config validation", () => {
it("requires connectionParams or client", () => {
expect(() => new OracleAIVectorSearch({} as any)).toThrow(
/connectionParams.*client/,
);
});
it("rejects an unsupported distance metric", () => {
expect(() => makeStore([], [], { distanceMetric: "JACCARD" })).toThrow(
/Unsupported distance metric/,
);
});
it("rejects a non-positive embedding dimension", () => {
expect(() => makeStore([], [], { embeddingModelDims: 0 })).toThrow(
/positive integer/,
);
});
it("rejects an out-of-range index accuracy", () => {
expect(() => makeStore([], [], { indexAccuracy: 101 })).toThrow(
/between 1 and 100/,
);
});
it("rejects an index parameter that does not belong to the index type", () => {
expect(() =>
makeStore([], [], {
indexType: "HNSW",
indexParameters: { samples_per_partition: 10 },
}),
).toThrow(/Unsupported HNSW index parameter/);
});
it("rejects an index parameter outside its allowed range", () => {
expect(() =>
makeStore([], [], { indexParameters: { neighbors: 1 } }),
).toThrow(/between 2 and 2048/);
});
});
describe("OracleAIVectorSearch SQL", () => {
it("creates the table and a vector index on initialize", async () => {
const calls: Call[] = [];
await makeStore(calls, [], {
indexParameters: { neighbors: 32, efconstruction: 200 },
indexAccuracy: 95,
}).initialize();
const ddl = calls.map((c) => c.sql).join("\n");
expect(ddl).toContain(
'CREATE TABLE IF NOT EXISTS "mem0" ( id VARCHAR2(36) PRIMARY KEY, vector VECTOR(3), payload JSON )',
);
expect(ddl).toContain(
'CREATE VECTOR INDEX IF NOT EXISTS "mem0_VEC_IDX" ON "mem0" (vector) ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE WITH TARGET ACCURACY 95 PARAMETERS (type HNSW, neighbors 32, efconstruction 200)',
);
});
it("skips index creation when doCreateIndex is false", async () => {
const calls: Call[] = [];
await makeStore(calls, [], { doCreateIndex: false }).initialize();
expect(calls.map((c) => c.sql).join("\n")).not.toContain(
"CREATE VECTOR INDEX",
);
});
it("binds vectors as DB_TYPE_VECTOR and payloads as DB_TYPE_JSON on insert", async () => {
const calls: Call[] = [];
await makeStore(calls).insert([[1, 2, 3]], ["id-1"], [{ data: "hello" }]);
const insert = calls.find((c) => c.sql.startsWith("INSERT INTO"))!;
expect(insert.binds).toEqual([
{
id: "id-1",
vector: new Float32Array([1, 2, 3]),
payload: { data: "hello" },
},
]);
expect(insert.options.bindDefs.id).toEqual({
type: DB_TYPE_VARCHAR,
maxSize: 36,
});
expect(insert.options.bindDefs.vector.type).toBe(DB_TYPE_VECTOR);
expect(insert.options.bindDefs.payload.type).toBe(DB_TYPE_JSON);
});
it("issues a single executeMany call with one bind row per vector on a multi-row insert", async () => {
const calls: Call[] = [];
await makeStore(calls).insert(
[
[1, 2, 3],
[4, 5, 6],
],
["id-1", "id-2"],
[{ a: 1 }, { b: 2 }],
);
const inserts = calls.filter((c) => c.sql.startsWith("INSERT INTO"));
expect(inserts).toHaveLength(1);
expect(inserts[0].binds).toHaveLength(2);
expect(inserts[0].binds[0].id).toBe("id-1");
expect(inserts[0].binds[1].id).toBe("id-2");
});
it("issues no insert statement when inserting an empty batch", async () => {
const calls: Call[] = [];
await makeStore(calls).insert([], [], []);
expect(calls.filter((c) => c.sql.startsWith("INSERT INTO"))).toHaveLength(
0,
);
});
it("rejects insert batches whose IDs or payloads do not match vectors", async () => {
const store = makeStore([]);
await expect(store.insert([[1, 2, 3]], [], [{}])).rejects.toThrow(
"ids and vectors must have the same length",
);
await expect(store.insert([[1, 2, 3]], ["id-1"], [])).rejects.toThrow(
"payloads and vectors must have the same length",
);
});
it("converts cosine distance to a similarity score", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", { data: "hello" }, 0.25]]]);
const results = await store.search([1, 2, 3], 5);
expect(results).toEqual([
{ id: "id-1", payload: { data: "hello" }, score: 0.75 },
]);
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
expect(select.sql).toContain(
"VECTOR_DISTANCE(vector, :query_vec, COSINE) distance",
);
expect(select.sql).toContain("FETCH APPROX FIRST :max_rows ROWS ONLY");
expect(select.binds.max_rows).toBe(5);
});
it("inverts the sign of a DOT distance", async () => {
const store = makeStore([], [[["id-1", {}, -0.4]]], {
distanceMetric: "DOT",
});
const [result] = await store.search([1, 2, 3]);
expect(result.score).toBeCloseTo(0.4);
});
it("adds the VECTOR_INDEX_TRANSFORM hint when searching without filters", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", {}, 0.1]]]);
await store.search([1, 2, 3], 5);
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
expect(select.sql).toContain('/*+ VECTOR_INDEX_TRANSFORM("mem0") */');
});
it("omits the VECTOR_INDEX_TRANSFORM hint when searching with filters", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", {}, 0.1]]]);
await store.search([1, 2, 3], 5, { user_id: "alice" });
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
expect(select.sql).not.toContain("VECTOR_INDEX_TRANSFORM");
});
it("parses a payload returned as a JSON string", async () => {
const store = makeStore([], [[["id-1", '{"data":"hello"}']]]);
expect(await store.get("id-1")).toEqual({
id: "id-1",
payload: { data: "hello" },
});
});
it("returns null when get finds no row", async () => {
expect(await makeStore([], [[]]).get("missing")).toBeNull();
});
it("generates and persists a UUID user id when none is stored", async () => {
const calls: Call[] = [];
const userId = await makeStore(calls, [[]]).getUserId();
expect(userId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const insert = calls.find((c) =>
c.sql.startsWith("INSERT INTO memory_migrations"),
)!;
expect(insert.binds).toEqual({ user_id: userId });
});
it("returns the stored user id when one exists", async () => {
expect(await makeStore([], [[["alice"]]]).getUserId()).toBe("alice");
});
it("returns rows and the total count from list", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", { data: "hello" }, 7]]]);
const [results, count] = await store.list({ user_id: "alice" }, 10);
expect(results).toEqual([{ id: "id-1", payload: { data: "hello" } }]);
expect(count).toBe(7);
const selects = calls.filter((c) => /^SELECT/i.test(c.sql));
expect(selects).toHaveLength(1);
const [list] = selects;
expect(list.sql).toContain(
"SELECT id, payload, COUNT(*) OVER () total FROM",
);
expect(list.sql).toContain("WHERE JSON_EXISTS(payload,");
expect(list.binds).toEqual({ f_0: "alice", max_rows: 10 });
});
it("returns a total of 0 from list when no rows match", async () => {
const store = makeStore([], [[]]);
const [results, count] = await store.list();
expect(results).toEqual([]);
expect(count).toBe(0);
});
});
describe("OracleAIVectorSearch initialization failure", () => {
beforeEach(() => mockCreatePool.mockReset());
function fakePool(serverVersion: string) {
const connection = {
...fakeConnection([], []),
oracleServerVersionString: serverVersion,
};
return {
close: jest.fn(async () => {}),
getConnection: jest.fn(async () => connection),
};
}
function makePoolStore() {
return new OracleAIVectorSearch({
connectionParams: { user: "u", password: "p", connectString: "d" },
collectionName: "mem0",
embeddingModelDims: 3,
} as any);
}
it("closes the pool it owns when initialization fails", async () => {
const pool = fakePool("23.3.0.24.05");
mockCreatePool.mockResolvedValue(pool);
await expect(makePoolStore().initialize()).rejects.toThrow(
"Oracle DB version 23.3.0.24.05 not supported",
);
expect(pool.close).toHaveBeenCalledTimes(1);
});
it("does not cache the rejection, so a later attempt can succeed", async () => {
const failing = fakePool("23.3.0.24.05");
const working = fakePool("23.4.0.24.05");
mockCreatePool
.mockResolvedValueOnce(failing)
.mockResolvedValueOnce(working);
const store = makePoolStore();
await expect(store.initialize()).rejects.toThrow("not supported");
await expect(store.initialize()).resolves.toBeUndefined();
expect(mockCreatePool).toHaveBeenCalledTimes(2);
});
it("leaves a caller-supplied client open when initialization fails", async () => {
const client = fakeConnection([], []);
client.oracleServerVersionString = "23.3.0.24.05";
const close = jest.spyOn(client, "close");
const store = new OracleAIVectorSearch({
client: client as any,
collectionName: "mem0",
embeddingModelDims: 3,
} as any);
await expect(store.initialize()).rejects.toThrow("not supported");
expect(close).not.toHaveBeenCalled();
});
});
+84
View File
@@ -125,4 +125,88 @@ describe("RedisDB – entity payload handling", () => {
expect(entry.created_at).toBeGreaterThan(0);
expect(Number.isNaN(entry.created_at)).toBe(false);
});
test("search returns entity ids as snake_case", async () => {
mockClient.ft.search.mockResolvedValue({
total: 1,
documents: [
{
id: "mem0:test:mem-1",
value: {
memory_id: "mem-1",
hash: "h1",
memory: "likes coffee",
created_at: "1700000000000",
agent_id: "agent-1",
run_id: "run-1",
user_id: "user-1",
metadata: "{}",
__vector_score: 0.1,
},
},
],
});
const results = await store.search([0.1, 0.2, 0.3, 0.4], 5);
expect(results[0].payload.user_id).toBe("user-1");
expect(results[0].payload.agent_id).toBe("agent-1");
expect(results[0].payload.run_id).toBe("run-1");
expect(results[0].payload).not.toHaveProperty("userId");
expect(results[0].payload).not.toHaveProperty("agentId");
expect(results[0].payload).not.toHaveProperty("runId");
});
test("get returns entity ids as snake_case", async () => {
mockClient.exists.mockResolvedValue(1);
mockClient.hGetAll.mockResolvedValue({
memory_id: "mem-1",
hash: "h1",
memory: "likes coffee",
created_at: "1700000000000",
agent_id: "agent-1",
run_id: "run-1",
user_id: "user-1",
metadata: "{}",
});
const result = await store.get("mem-1");
expect(result?.payload.user_id).toBe("user-1");
expect(result?.payload.agent_id).toBe("agent-1");
expect(result?.payload.run_id).toBe("run-1");
expect(result?.payload).not.toHaveProperty("userId");
expect(result?.payload).not.toHaveProperty("agentId");
expect(result?.payload).not.toHaveProperty("runId");
});
test("list returns entity ids as snake_case", async () => {
mockClient.ft.search.mockResolvedValue({
total: 1,
documents: [
{
id: "mem0:test:mem-1",
value: {
memory_id: "mem-1",
hash: "h1",
memory: "likes coffee",
created_at: "1700000000000",
agent_id: "agent-1",
run_id: "run-1",
user_id: "user-1",
metadata: "{}",
},
},
],
});
const [items] = await store.list();
expect(items[0].payload.user_id).toBe("user-1");
expect(items[0].payload.agent_id).toBe("agent-1");
expect(items[0].payload.run_id).toBe("run-1");
expect(items[0].payload).not.toHaveProperty("userId");
expect(items[0].payload).not.toHaveProperty("agentId");
expect(items[0].payload).not.toHaveProperty("runId");
});
});
@@ -0,0 +1,152 @@
/**
* Unit tests for Memory.buildSessionScope. The recent-conversation buffer key
* derived from user_id/agent_id/run_id must be unique per id combination.
*/
/// <reference types="jest" />
jest.mock("../src/utils/factory", () => {
const { MemoryVectorStore } = jest.requireActual(
"../src/vector_stores/memory",
);
const { SQLiteManager } = jest.requireActual("../src/storage/SQLiteManager");
const testEmbedding = new Array(1536).fill(0.1);
class MockEmbedder {
embeddingDims = 1536;
async embed(): Promise<number[]> {
return testEmbedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
return texts.map(() => testEmbedding);
}
}
class MockLLM {
async generateResponse() {
return JSON.stringify({ memory: [] });
}
}
return {
__esModule: true,
EmbedderFactory: { create: jest.fn(() => new MockEmbedder()) },
LLMFactory: { create: jest.fn(() => new MockLLM()) },
VectorStoreFactory: {
create: jest.fn(
() => new MemoryVectorStore({ collectionName: "t", dimension: 1536 }),
),
},
HistoryManagerFactory: {
create: jest.fn(() => new SQLiteManager(":memory:")),
},
};
});
import { Memory } from "../src/memory";
import { SearchFilters } from "../src/types";
function scopeOf(memory: Memory, filters: SearchFilters): string {
return (memory as any).buildSessionScope(filters);
}
function cartesian(values: string[], length: number): string[][] {
if (length === 0) return [[]];
const rest = cartesian(values, length - 1);
const result: string[][] = [];
for (const value of values) {
for (const tail of rest) {
result.push([value, ...tail]);
}
}
return result;
}
describe("Memory session scope key", () => {
let memory: Memory;
beforeAll(async () => {
memory = new Memory();
await (memory as any)._initPromise;
});
it("keeps the unchanged key format for ids without delimiter characters", () => {
expect(
scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }),
).toBe("user_id=550e8400-e29b-41d4-a716-446655440000");
expect(scopeOf(memory, { agent_id: "agent.assistant:v2" })).toBe(
"agent_id=agent.assistant:v2",
);
expect(scopeOf(memory, { run_id: "12345" })).toBe("run_id=12345");
expect(
scopeOf(memory, { user_id: "user@example.com", agent_id: "support-bot" }),
).toBe("agent_id=support-bot&user_id=user@example.com");
});
it("no longer collides an id embedding the join syntax with the equivalent split filters", () => {
const collapsedRun = scopeOf(memory, { run_id: "proj-x&user_id=u1" });
const splitRun = scopeOf(memory, { user_id: "u1", run_id: "proj-x" });
expect(collapsedRun).not.toBe(splitRun);
const collapsedAgent = scopeOf(memory, { run_id: "proj-y&agent_id=a1" });
const splitAgent = scopeOf(memory, { agent_id: "a1", run_id: "proj-y" });
expect(collapsedAgent).not.toBe(splitAgent);
});
it("maps every distinct filter combination of delimiter-heavy ids to a distinct key", () => {
const keys: string[] = ["user_id", "agent_id", "run_id"];
const values = [
"u1",
"r1",
"a1",
"%",
"&",
"=",
"a==",
"a1&run_id=r1",
"a1&user_id=u1",
"r1&user_id=u1",
"a1&run_id=r1&user_id=u1",
];
const seen = new Map<string, SearchFilters>();
for (let mask = 1; mask < 1 << keys.length; mask++) {
const subset = keys.filter((_key, i) => mask & (1 << i));
for (const combo of cartesian(values, subset.length)) {
const filters: SearchFilters = {};
subset.forEach((key, i) => (filters[key] = combo[i]));
const scope = scopeOf(memory, filters);
if (seen.has(scope)) {
expect(seen.get(scope)).toEqual(filters);
} else {
seen.set(scope, filters);
}
}
}
});
it("gives ids containing delimiter characters a new key format", () => {
expect(scopeOf(memory, { user_id: "dXNlcl9pZDE=" })).toBe(
"user_id=dXNlcl9pZDE%3D",
);
expect(scopeOf(memory, { agent_id: "x&y" })).toBe("agent_id=x%26y");
expect(scopeOf(memory, { run_id: "50% off" })).toBe("run_id=50%25 off");
});
it("routes the add pipeline through the builder", async () => {
const db = (memory as any).db;
const spy = jest.spyOn(db, "getLastMessages");
await memory.add([{ role: "user", content: "hello" }], {
runId: "proj-x&user_id=u1",
});
expect(spy).toHaveBeenCalledWith("run_id=proj-x%26user_id%3Du1", 10);
spy.mockRestore();
});
it("pins the exact key strings shared with the Python test suite", () => {
expect(
scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }),
).toBe("user_id=550e8400-e29b-41d4-a716-446655440000");
expect(
scopeOf(memory, { user_id: "u1", agent_id: "a1", run_id: "r1" }),
).toBe("agent_id=a1&run_id=r1&user_id=u1");
expect(scopeOf(memory, { run_id: "proj-x&user_id=u1" })).toBe(
"run_id=proj-x%26user_id%3Du1",
);
});
});
+86 -1
View File
@@ -166,7 +166,7 @@ describe("Valkey – mocked iovalkey client", () => {
const result = await store.get("mem-1");
expect(result?.id).toBe("mem-1");
expect(result?.payload.data).toBe("hello valkey");
expect(result?.payload.userId).toBe("alice");
expect(result?.payload.user_id).toBe("alice");
// created_at is persisted as unix seconds and rendered back to its ISO instant.
expect(result?.payload.createdAt).toBe("2024-01-01T00:00:00.000Z");
});
@@ -244,6 +244,91 @@ describe("Valkey – mocked iovalkey client", () => {
expect(searchCall[2]).toContain("@user_id:{a\\|b\\ c}");
});
it("get returns entity ids as snake_case", async () => {
const store = new ValkeyDB({
collectionName: "test",
embeddingModelDims: 4,
valkeyUrl: "valkey://localhost:6379",
});
await store.initialize();
await store.insert(
[[0.1, 0.2, 0.3, 0.4]],
["mem-ids"],
[
{
data: "likes tea",
user_id: "user-1",
agent_id: "agent-1",
run_id: "run-1",
},
],
);
const result = await store.get("mem-ids");
expect(result?.payload.user_id).toBe("user-1");
expect(result?.payload.agent_id).toBe("agent-1");
expect(result?.payload.run_id).toBe("run-1");
expect(result?.payload).not.toHaveProperty("userId");
expect(result?.payload).not.toHaveProperty("agentId");
expect(result?.payload).not.toHaveProperty("runId");
});
it("search and list return entity ids as snake_case", async () => {
const store = new ValkeyDB({
collectionName: "test",
embeddingModelDims: 4,
valkeyUrl: "valkey://localhost:6379",
});
await store.initialize();
const iovalkey = require("iovalkey");
const mockClient = iovalkey.__mockClient;
mockClient.call.mockImplementation(async (...args: any[]) => {
if (args[0] === "FT.SEARCH") {
return [
1,
"mem0:test:mem-search",
[
"memory_id",
"mem-search",
"hash",
"h1",
"memory",
"likes tea",
"created_at",
"1700000000",
"agent_id",
"agent-1",
"run_id",
"run-1",
"user_id",
"user-1",
"metadata",
"{}",
"vector_score",
"0.1",
],
];
}
return "OK";
});
const results = await store.search([0.1, 0.2, 0.3, 0.4], 5);
expect(results[0].payload.user_id).toBe("user-1");
expect(results[0].payload.agent_id).toBe("agent-1");
expect(results[0].payload.run_id).toBe("run-1");
expect(results[0].payload).not.toHaveProperty("userId");
expect(results[0].payload).not.toHaveProperty("agentId");
expect(results[0].payload).not.toHaveProperty("runId");
const [listed] = await store.list();
expect(listed[0].payload.user_id).toBe("user-1");
expect(listed[0].payload.agent_id).toBe("agent-1");
expect(listed[0].payload.run_id).toBe("run-1");
expect(listed[0].payload).not.toHaveProperty("userId");
});
it("does not raise an unhandled rejection when initialization fails", async () => {
const iovalkey = require("iovalkey");
iovalkey.__mockClient.call.mockImplementationOnce(async () => {