Merge branch 'main' into fix/deflake-ts-integration-seeding
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# TypeScript SDK (`mem0-ts/`)
|
||||
|
||||
The `mem0ai` package on npm. Hosted client plus self-hosted OSS memory.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build # tsup (CJS + ESM)
|
||||
pnpm run test # jest, all tests
|
||||
pnpm run test:unit # jest --coverage
|
||||
pnpm run test:integration # jest, needs MEM0_API_KEY
|
||||
pnpm run test:ci # jest --coverage --ci
|
||||
pnpm run test:watch
|
||||
pnpm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
pnpm only. Never npm, never yarn.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Node 20 and 22** are the CI-tested versions.
|
||||
- **Build:** tsup, dual CJS + ESM output.
|
||||
- **Formatter:** Prettier. No linter is configured here; `cli/node/` uses Biome and `integrations/vercel-ai-sdk/` uses ESLint, so do not assume a shared setup.
|
||||
- **Tests:** jest. `cli/node/` and `integrations/openclaw/` use vitest instead.
|
||||
- **TypeScript strict mode.**
|
||||
- ES module `import` syntax only. Never `require()`.
|
||||
- Source files are `snake_case.ts`, tests are `<module>.test.ts`.
|
||||
|
||||
Run `pnpm run typecheck` after every change.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
mem0-ts/src/
|
||||
├── client/ MemoryClient (hosted platform)
|
||||
└── oss/ Memory (self-hosted)
|
||||
├── llms/
|
||||
├── embeddings/
|
||||
├── vector_stores/
|
||||
└── graphs/
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
| Export | Purpose | Import |
|
||||
| -------------- | ---------------------- | ---------------------------------------------- |
|
||||
| `MemoryClient` | Hosted platform client | `import { MemoryClient } from 'mem0ai'` |
|
||||
| `Memory` | Self-hosted OSS memory | `import { Memory } from 'mem0ai/oss'` |
|
||||
| Providers | OSS building blocks | `import { OpenAIEmbedding } from 'mem0ai/oss'` |
|
||||
|
||||
The method surface mirrors the Python SDK: `add`, `search`, `get`, `getAll`, `update`, `delete`, `deleteAll`, `history`. Changing any public signature means updating `docs/` in the same PR.
|
||||
|
||||
## Releasing
|
||||
|
||||
Tag prefix `ts-v*` triggers `ts-sdk-cd.yml`, which publishes to npm over OIDC. Bump the version in `package.json` first.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -404,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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {},
|
||||
@@ -1000,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)) {
|
||||
@@ -1297,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;
|
||||
}
|
||||
}
|
||||
@@ -1566,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) => {
|
||||
@@ -1590,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 }),
|
||||
@@ -1896,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,
|
||||
@@ -1914,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 }),
|
||||
@@ -1943,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,
|
||||
@@ -1987,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 }),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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