chore: merge origin/main into mintlify/af82bda4 and resolve conflicts
This commit is contained in:
+91
-39
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user