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

Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
Co-authored-by: chaithanyak42 <chaithanya.kumar42a@gmail.com>
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
soumil-rathi
2026-04-14 05:30:58 -07:00
committed by GitHub
parent 57f944e18a
commit a488e19044
120 changed files with 10107 additions and 17135 deletions
-1
View File
@@ -3,7 +3,6 @@ import type * as MemoryTypes from "./mem0.types";
// Re-export all types from mem0.types
export type {
EntityOptions,
AddMemoryOptions,
SearchMemoryOptions,
GetAllMemoryOptions,
+56 -10
View File
@@ -23,6 +23,37 @@ import { captureClientEvent, generateHash } from "./telemetry";
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
// Entity params that must be passed via filters - check both snake_case and camelCase
const ENTITY_PARAMS = [
"user_id",
"agent_id",
"app_id",
"run_id",
"userId",
"agentId",
"appId",
"runId",
];
/**
* Validates that no top-level entity parameters are passed.
* @throws Error if entity params are found at top level
*/
function rejectTopLevelEntityParams(
options: Record<string, any> | undefined,
methodName: string,
): void {
const invalidKeys = Object.keys(options ?? {}).filter((k) =>
ENTITY_PARAMS.includes(k),
);
if (invalidKeys.length > 0) {
throw new Error(
`Top-level entity parameters [${invalidKeys.join(", ")}] are not supported in ${methodName}(). ` +
`Use filters: { user_id: "..." } instead.`,
);
}
}
class APIError extends Error {
constructor(message: string) {
super(message);
@@ -190,7 +221,7 @@ export default class MemoryClient {
this._captureEvent("add", [payloadKeys]);
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/`,
`${this.host}/v3/memories/`,
{
method: "POST",
headers: this.headers,
@@ -254,13 +285,17 @@ export default class MemoryClient {
}
async getAll(options?: GetAllMemoryOptions): Promise<Array<Memory>> {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, ...rest } = options ?? {};
const { page, pageSize, filters, ...rest } = options ?? {};
const body: Record<string, any> = {
output_format: "v1.1",
...camelToSnakeKeys(rest),
...(filters && { filters }),
};
let url = `${this.host}/v2/memories/`;
@@ -273,33 +308,36 @@ export default class MemoryClient {
headers: this.headers,
body: JSON.stringify(body),
});
// Unwrap v1.1 format: { results: [...] } → [...]
return Array.isArray(response) ? response : (response?.results ?? response);
}
async search(
query: string,
options?: SearchMemoryOptions,
): Promise<Array<Memory>> {
): Promise<{ results: Array<Memory> }> {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "search");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]);
const { filters, ...rest } = options ?? {};
const payload: Record<string, any> = {
query,
output_format: "v1.1",
...camelToSnakeKeys(options ?? {}),
...camelToSnakeKeys(rest),
...(filters && { filters }),
};
const response = await this._fetchWithErrorHandling(
`${this.host}/v2/memories/search/`,
`${this.host}/v3/memories/search/`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(payload),
},
);
// Unwrap v1.1 format: { results: [...] } → [...]
return Array.isArray(response) ? response : (response?.results ?? response);
return response;
}
async delete(memoryId: string): Promise<{ message: string }> {
@@ -614,12 +652,16 @@ export default class MemoryClient {
throw new Error("Missing filters or schema");
}
const { filters, ...rest } = data;
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/exports/`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(camelToSnakeKeys(data)),
body: JSON.stringify({
...camelToSnakeKeys(rest),
filters,
}),
},
);
@@ -636,12 +678,16 @@ export default class MemoryClient {
throw new Error("Missing memoryExportId or filters");
}
const { filters, ...rest } = data;
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/exports/get/`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(camelToSnakeKeys(data)),
body: JSON.stringify({
...camelToSnakeKeys(rest),
...(filters && { filters }),
}),
},
);
return response;
@@ -51,17 +51,13 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
},
];
const result = await client.add(messages, { userId: TEST_USER_ID });
const result = await client.add(messages, {
userId: TEST_USER_ID,
});
// API processes memories asynchronously — returns PENDING
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// Validate response shape
for (const item of result) {
expect(item).toHaveProperty("status");
expect(item).toHaveProperty("eventId");
}
// v3 API processes memories asynchronously — returns PENDING
expect(result).toHaveProperty("eventId");
expect(result).toHaveProperty("status");
});
test("adds a second batch of messages", async () => {
@@ -76,8 +72,10 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
},
];
const result = await client.add(messages, { userId: TEST_USER_ID });
expect(Array.isArray(result)).toBe(true);
const result = await client.add(messages, {
userId: TEST_USER_ID,
});
expect(result).toHaveProperty("eventId");
});
test("memories become available after async processing", async () => {
@@ -123,7 +121,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
describe("get all memories", () => {
test("returns all memories for test user", async () => {
const memories = await client.getAll({
filters: { userId: TEST_USER_ID },
filters: { user_id: TEST_USER_ID },
});
expect(Array.isArray(memories)).toBe(true);
@@ -137,7 +135,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
test("returns paginated results with page and page_size", async () => {
const page1 = await client.getAll({
filters: { userId: TEST_USER_ID },
filters: { user_id: TEST_USER_ID },
page: 1,
pageSize: 1,
});
@@ -196,13 +194,12 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
},
);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result).toHaveProperty("eventId");
});
test("getAll for non-existent user returns empty array", async () => {
const memories = await client.getAll({
filters: { userId: `nonexistent-user-${randomUUID()}` },
filters: { user_id: `nonexistent-user-${randomUUID()}` },
});
expect(Array.isArray(memories)).toBe(true);
@@ -241,7 +238,9 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
// ─── Delete all + delete user ─────────────────────────────
describe("cleanup operations", () => {
test("deletes all memories for test user", async () => {
const result = await client.deleteAll({ userId: TEST_USER_ID });
const result = await client.deleteAll({
userId: TEST_USER_ID,
});
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
});
@@ -64,7 +64,7 @@ export async function waitForMemories(
): Promise<Memory[]> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const memories = await withRetry(() =>
client.getAll({ filters: { userId } }),
client.getAll({ filters: { user_id: userId } }),
);
if (Array.isArray(memories) && memories.length >= minCount) {
return memories;
@@ -92,8 +92,9 @@ export async function waitForSearchResults(
maxRetries = 4,
): Promise<Memory[]> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const results = await withRetry(() => client.search(query, options));
if (Array.isArray(results) && results.length > 0) {
const response = await withRetry(() => client.search(query, options));
const results = response?.results ?? [];
if (results.length > 0) {
return results;
}
if (attempt < maxRetries) {
@@ -43,7 +43,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
const results = await waitForSearchResults(
client,
"What is my favorite color?",
{ filters: { userId: TEST_USER_ID } },
{ filters: { user_id: TEST_USER_ID } },
);
expect(Array.isArray(results)).toBe(true);
@@ -64,7 +64,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
client,
"What do you know about me?",
{
filters: { OR: [{ userId: TEST_USER_ID }] },
filters: { OR: [{ user_id: TEST_USER_ID }] },
},
);
@@ -108,24 +108,24 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
// ─── Edge cases ─────────────────────────────────────────
describe("edge cases", () => {
test("search for non-existent user returns empty results", async () => {
const results = await client.search("anything", {
filters: { userId: `nonexistent-user-${randomUUID()}` },
const response = await client.search("test search query", {
filters: { user_id: `nonexistent-user-${randomUUID()}` },
});
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
expect(response).toHaveProperty("results");
expect(response.results).toHaveLength(0);
});
test("search with top_k param does not throw", async () => {
const results = await client.search(
const response = await client.search(
"Tell me about integration test user",
{
filters: { userId: TEST_USER_ID },
filters: { user_id: TEST_USER_ID },
topK: 1,
},
);
expect(Array.isArray(results)).toBe(true);
expect(response).toHaveProperty("results");
});
});
});
@@ -21,33 +21,33 @@ installConsoleSuppression();
// ─── add() ───────────────────────────────────────────────
describe("MemoryClient - add()", () => {
test("sends POST to /v1/memories/", async () => {
test("sends POST to /v3/memories/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "Hello" }], { userId: "u1" });
expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined();
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
});
test("includes messages in request body", async () => {
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add(messages, { userId: "u1" });
const call = findFetchCall(mock, "/v1/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).messages).toEqual(messages);
});
test("includes user_id in request body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
@@ -55,19 +55,19 @@ describe("MemoryClient - add()", () => {
user_id: "user_1",
});
const call = findFetchCall(mock, "/v1/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).user_id).toBe("user_1");
});
test("sends empty messages array without crashing", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v1/memories/", { status: 200, body: [] });
extra.set("/v3/memories/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([], { userId: "u1" });
const call = findFetchCall(mock, "/v1/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).messages).toEqual([]);
});
});
@@ -1,5 +1,5 @@
/**
* MemoryClient unit tests — search (v2 default, filters).
* MemoryClient unit tests — search (v3 endpoint, filters).
* Tests verify request construction, not mock response echo.
*/
import { MemoryClient } from "../mem0";
@@ -15,48 +15,60 @@ import {
installConsoleSuppression();
describe("MemoryClient - search()", () => {
test("sends POST to /v2/memories/search/ by default", async () => {
test("sends POST to /v3/memories/search/ by default", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("What is my name?", {
filters: { userId: "u1" },
filters: { user_id: "u1" },
});
expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined();
expect(findFetchCall(mock, "/v3/memories/search/", "POST")).toBeDefined();
});
test("includes query in request body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("What is my name?", {
filters: { userId: "u1" },
filters: { user_id: "u1" },
});
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).query).toBe("What is my name?");
});
test("passes filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("test", { filters: { userId: "u1" } });
await client.search("test", { filters: { user_id: "u1" } });
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" });
});
test("passes complex OR filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
@@ -64,32 +76,192 @@ describe("MemoryClient - search()", () => {
filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] },
});
const call = findFetchCall(mock, "/v2/memories/search/", "POST");
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual({
OR: [{ user_id: "u1" }, { agent_id: "a1" }],
});
});
test("passes complex AND filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("query", {
filters: {
AND: [
{ user_id: "u1" },
{ created_at: { gte: "2024-01-01T00:00:00Z" } },
],
},
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual({
AND: [{ user_id: "u1" }, { created_at: { gte: "2024-01-01T00:00:00Z" } }],
});
});
test("passes NOT filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("query", {
filters: {
AND: [
{ user_id: "u1" },
{ NOT: { categories: { in: ["spam", "test"] } } },
],
},
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual({
AND: [
{ user_id: "u1" },
{ NOT: { categories: { in: ["spam", "test"] } } },
],
});
});
test("passes complex nested AND/OR/NOT filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const complexFilter = {
AND: [
{ user_id: "u1" },
{ created_at: { gte: "2024-01-01T00:00:00Z" } },
{
NOT: {
OR: [
{ categories: { in: ["spam"] } },
{ categories: { in: ["test"] } },
],
},
},
],
};
await client.search("query", { filters: complexFilter });
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
const body = getFetchBody(call!);
expect(body.filters).toEqual(complexFilter);
});
test("does not crash when called without options", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result: Memory[] = await client.search("query");
expect(Array.isArray(result)).toBe(true);
const result = await client.search("query");
expect(result).toHaveProperty("results");
});
test("handles empty results array", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/search/", { status: 200, body: [] });
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const result: Memory[] = await client.search("nonexistent query", {
filters: { userId: "u1" },
const result = await client.search("nonexistent query", {
filters: { AND: [{ user_id: "u1" }] },
});
expect(result).toHaveLength(0);
expect(result.results).toHaveLength(0);
});
});
describe("MemoryClient - search() entity param rejection", () => {
test("rejects user_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(
client.search("query", { user_id: "u1" } as any),
).rejects.toThrow(/filters/);
});
test("rejects agent_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(
client.search("query", { agent_id: "a1" } as any),
).rejects.toThrow(/filters/);
});
test("rejects app_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(
client.search("query", { app_id: "app1" } as any),
).rejects.toThrow(/filters/);
});
test("accepts filters with user_id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
// Should not throw
await client.search("query", { filters: { AND: [{ user_id: "u1" }] } });
expect(findFetchCall(mock, "/v3/memories/search/", "POST")).toBeDefined();
});
});
describe("MemoryClient - getAll() entity param rejection", () => {
test("rejects user_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(client.getAll({ user_id: "u1" } as any)).rejects.toThrow(
/filters/,
);
});
test("rejects agent_id at top level", async () => {
setupMockFetch();
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(client.getAll({ agent_id: "a1" } as any)).rejects.toThrow(
/filters/,
);
});
test("accepts filters with user_id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" } });
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
});
});
+2 -95
View File
@@ -82,14 +82,14 @@ async function runTests(memory: Memory) {
// Get all memories
console.log("\nGetting all memories...");
const allMemories = await memory.getAll({
userId: "john",
filters: { user_id: "john" },
});
console.log("All memories:", allMemories);
// Search for memories
console.log("\nSearching memories...");
const searchResult = await memory.search("What do you know about Paris?", {
userId: "john",
filters: { user_id: "john" },
});
console.log("Search results:", searchResult);
@@ -292,86 +292,6 @@ async function demoRedis() {
await runTests(memory);
}
async function demoGraphMemory() {
console.log("\n=== Testing Graph Memory Store ===\n");
const memory = new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: {
apiKey: process.env.OPENAI_API_KEY || "",
model: "text-embedding-3-small",
},
},
vectorStore: {
provider: "memory",
config: {
collectionName: "memories",
dimension: 1536,
},
},
llm: {
provider: "openai",
config: {
apiKey: process.env.OPENAI_API_KEY || "",
model: "gpt-4-turbo-preview",
},
},
graphStore: {
provider: "neo4j",
config: {
url: process.env.NEO4J_URL || "neo4j://localhost:7687",
username: process.env.NEO4J_USERNAME || "neo4j",
password: process.env.NEO4J_PASSWORD || "password",
},
llm: {
provider: "openai",
config: {
model: "gpt-4-turbo-preview",
},
},
},
historyDbPath: "memory.db",
});
try {
// Reset all memories
await memory.reset();
// Add memories with relationships
const result = await memory.add(
[
{
role: "user",
content: "Alice is Bob's sister and works as a doctor.",
},
{
role: "assistant",
content:
"I understand that Alice and Bob are siblings and Alice is a medical professional.",
},
{ role: "user", content: "Bob is married to Carol who is a teacher." },
],
{
userId: "john",
},
);
console.log("Added memories with relationships:", result);
// Search for connected information
const searchResult = await memory.search(
"Tell me about Bob's family connections",
{
userId: "john",
},
);
console.log("Search results with graph relationships:", searchResult);
} catch (error) {
console.error("Error in graph memory demo:", error);
}
}
async function main() {
// Test in-memory store
await demoMemoryStore();
@@ -379,19 +299,6 @@ async function main() {
// Test in-memory store with Ollama
await demoLocalMemory();
// Test graph memory if Neo4j environment variables are set
if (
process.env.NEO4J_URL &&
process.env.NEO4J_USERNAME &&
process.env.NEO4J_PASSWORD
) {
await demoGraphMemory();
} else {
console.log(
"\nSkipping Graph Memory test - Neo4j environment variables not set",
);
}
// Test PGVector store if environment variables are set
if (process.env.PGVECTOR_DB) {
await demoPGVector();
+3 -1
View File
@@ -26,7 +26,9 @@ const memory = new Memory({
});
async function chatWithMemories(message: string, userId = "default_user") {
const relevantMemories = await memory.search(message, { userId: userId });
const relevantMemories = await memory.search(message, {
filters: { user_id: userId },
});
const memoriesStr = relevantMemories.results
.map((entry) => `- ${entry.memory}`)
+2 -2
View File
@@ -64,14 +64,14 @@ export async function runTests(memory: Memory) {
// Get all memories
console.log("\nGetting all memories...");
const allMemories = await memory.getAll({
userId: "john",
filters: { user_id: "john" },
});
console.log("All memories:", allMemories);
// Search for memories
console.log("\nSearching memories...");
const searchResult = await memory.search("What do you know about Paris?", {
userId: "john",
filters: { user_id: "john" },
});
console.log("Search results:", searchResult);
+1 -1
View File
@@ -22,7 +22,7 @@ export const DEFAULT_MEMORY_CONFIG: MemoryConfig = {
config: {
baseURL: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY || "",
model: "gpt-4-turbo-preview",
model: "gpt-4.1-nano-2025-04-14",
modelProperties: undefined,
},
},
-3
View File
@@ -133,9 +133,6 @@ export class ConfigManager {
userConfig.historyStore?.config?.historyDbPath ||
DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath,
customInstructions: userConfig.customInstructions,
graphStore: userConfig.graphStore
? { ...userConfig.graphStore }
: undefined,
historyStore: (() => {
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
const historyProvider =
+18 -8
View File
@@ -35,13 +35,23 @@ export class AzureOpenAIEmbedder implements Embedder {
}
async embedBatch(texts: string[]): Promise<number[][]> {
const response = await this.client.embeddings.create({
model: this.model,
input: texts,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
return response.data.map((item) => item.embedding);
const MAX_BATCH = 100;
const allEmbeddings: number[][] = [];
for (let i = 0; i < texts.length; i += MAX_BATCH) {
const chunk = texts.slice(i, i + MAX_BATCH);
const response = await this.client.embeddings.create({
model: this.model,
input: chunk,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
allEmbeddings.push(
...response.data
.sort((a, b) => a.index - b.index)
.map((item) => item.embedding),
);
}
return allEmbeddings;
}
}
+18 -8
View File
@@ -28,13 +28,23 @@ export class OpenAIEmbedder implements Embedder {
}
async embedBatch(texts: string[]): Promise<number[][]> {
const response = await this.openai.embeddings.create({
model: this.model,
input: texts,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
return response.data.map((item) => item.embedding);
const MAX_BATCH = 100;
const allEmbeddings: number[][] = [];
for (let i = 0; i < texts.length; i += MAX_BATCH) {
const chunk = texts.slice(i, i + MAX_BATCH);
const response = await this.openai.embeddings.create({
model: this.model,
input: chunk,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
allEmbeddings.push(
...response.data
.sort((a, b) => a.index - b.index)
.map((item) => item.embedding),
);
}
return allEmbeddings;
}
}
-30
View File
@@ -1,30 +0,0 @@
import { LLMConfig } from "../types";
export interface Neo4jConfig {
url: string | null;
username: string | null;
password: string | null;
}
export interface GraphStoreConfig {
provider: string;
config: Neo4jConfig;
llm?: LLMConfig;
customInstructions?: string;
}
export function validateNeo4jConfig(config: Neo4jConfig): void {
const { url, username, password } = config;
if (!url || !username || !password) {
throw new Error("Please provide 'url', 'username' and 'password'.");
}
}
export function validateGraphStoreConfig(config: GraphStoreConfig): void {
const { provider } = config;
if (provider === "neo4j") {
validateNeo4jConfig(config.config);
} else {
throw new Error(`Unsupported graph store provider: ${provider}`);
}
}
-267
View File
@@ -1,267 +0,0 @@
import { z } from "zod";
export interface GraphToolParameters {
source: string;
destination: string;
relationship: string;
source_type?: string;
destination_type?: string;
}
export interface GraphEntitiesParameters {
entities: Array<{
entity: string;
entity_type: string;
}>;
}
export interface GraphRelationsParameters {
entities: Array<{
source: string;
relationship: string;
destination: string;
}>;
}
// --- Zod Schemas for Tool Arguments ---
// Schema for simple relationship arguments (Update, Delete)
export const GraphSimpleRelationshipArgsSchema = z.object({
source: z
.string()
.describe("The identifier of the source node in the relationship."),
relationship: z
.string()
.describe("The relationship between the source and destination nodes."),
destination: z
.string()
.describe("The identifier of the destination node in the relationship."),
});
// Schema for adding a relationship (includes types)
export const GraphAddRelationshipArgsSchema =
GraphSimpleRelationshipArgsSchema.extend({
source_type: z
.string()
.describe("The type or category of the source node."),
destination_type: z
.string()
.describe("The type or category of the destination node."),
});
// Schema for extracting entities
export const GraphExtractEntitiesArgsSchema = z.object({
entities: z
.array(
z.object({
entity: z.string().describe("The name or identifier of the entity."),
entity_type: z.string().describe("The type or category of the entity."),
}),
)
.describe("An array of entities with their types."),
});
// Schema for establishing relationships
export const GraphRelationsArgsSchema = z.object({
entities: z
.array(GraphSimpleRelationshipArgsSchema)
.describe("An array of relationships (source, relationship, destination)."),
});
// --- Tool Definitions (using JSON schema, keep as is) ---
// Note: The tool definitions themselves still use JSON schema format
// as expected by the LLM APIs. The Zod schemas above are for internal
// validation and potentially for use with Langchain's .withStructuredOutput
// if we adapt it to handle tool calls via schema.
export const UPDATE_MEMORY_TOOL_GRAPH = {
type: "function",
function: {
name: "update_graph_memory",
description:
"Update the relationship key of an existing graph memory based on new information.",
parameters: {
type: "object",
properties: {
source: {
type: "string",
description:
"The identifier of the source node in the relationship to be updated.",
},
destination: {
type: "string",
description:
"The identifier of the destination node in the relationship to be updated.",
},
relationship: {
type: "string",
description:
"The new or updated relationship between the source and destination nodes.",
},
},
required: ["source", "destination", "relationship"],
additionalProperties: false,
},
},
};
export const ADD_MEMORY_TOOL_GRAPH = {
type: "function",
function: {
name: "add_graph_memory",
description: "Add a new graph memory to the knowledge graph.",
parameters: {
type: "object",
properties: {
source: {
type: "string",
description:
"The identifier of the source node in the new relationship.",
},
destination: {
type: "string",
description:
"The identifier of the destination node in the new relationship.",
},
relationship: {
type: "string",
description:
"The type of relationship between the source and destination nodes.",
},
source_type: {
type: "string",
description: "The type or category of the source node.",
},
destination_type: {
type: "string",
description: "The type or category of the destination node.",
},
},
required: [
"source",
"destination",
"relationship",
"source_type",
"destination_type",
],
additionalProperties: false,
},
},
};
export const NOOP_TOOL = {
type: "function",
function: {
name: "noop",
description: "No operation should be performed to the graph entities.",
parameters: {
type: "object",
properties: {},
required: [],
additionalProperties: false,
},
},
};
export const RELATIONS_TOOL = {
type: "function",
function: {
name: "establish_relationships",
description:
"Establish relationships among the entities based on the provided text.",
parameters: {
type: "object",
properties: {
entities: {
type: "array",
items: {
type: "object",
properties: {
source: {
type: "string",
description: "The source entity of the relationship.",
},
relationship: {
type: "string",
description:
"The relationship between the source and destination entities.",
},
destination: {
type: "string",
description: "The destination entity of the relationship.",
},
},
required: ["source", "relationship", "destination"],
additionalProperties: false,
},
},
},
required: ["entities"],
additionalProperties: false,
},
},
};
export const EXTRACT_ENTITIES_TOOL = {
type: "function",
function: {
name: "extract_entities",
description: "Extract entities and their types from the text.",
parameters: {
type: "object",
properties: {
entities: {
type: "array",
items: {
type: "object",
properties: {
entity: {
type: "string",
description: "The name or identifier of the entity.",
},
entity_type: {
type: "string",
description: "The type or category of the entity.",
},
},
required: ["entity", "entity_type"],
additionalProperties: false,
},
description: "An array of entities with their types.",
},
},
required: ["entities"],
additionalProperties: false,
},
},
};
export const DELETE_MEMORY_TOOL_GRAPH = {
type: "function",
function: {
name: "delete_graph_memory",
description: "Delete the relationship between two nodes.",
parameters: {
type: "object",
properties: {
source: {
type: "string",
description: "The identifier of the source node in the relationship.",
},
relationship: {
type: "string",
description:
"The existing relationship between the source and destination nodes that needs to be deleted.",
},
destination: {
type: "string",
description:
"The identifier of the destination node in the relationship.",
},
},
required: ["source", "relationship", "destination"],
additionalProperties: false,
},
},
};
-116
View File
@@ -1,116 +0,0 @@
export const UPDATE_GRAPH_PROMPT = `
You are an AI expert specializing in graph memory management and optimization. Your task is to analyze existing graph memories alongside new information, and update the relationships in the memory list to ensure the most accurate, current, and coherent representation of knowledge.
Input:
1. Existing Graph Memories: A list of current graph memories, each containing source, target, and relationship information.
2. New Graph Memory: Fresh information to be integrated into the existing graph structure.
Guidelines:
1. Identification: Use the source and target as primary identifiers when matching existing memories with new information.
2. Conflict Resolution:
- If new information contradicts an existing memory:
a) For matching source and target but differing content, update the relationship of the existing memory.
b) If the new memory provides more recent or accurate information, update the existing memory accordingly.
3. Comprehensive Review: Thoroughly examine each existing graph memory against the new information, updating relationships as necessary. Multiple updates may be required.
4. Consistency: Maintain a uniform and clear style across all memories. Each entry should be concise yet comprehensive.
5. Semantic Coherence: Ensure that updates maintain or improve the overall semantic structure of the graph.
6. Temporal Awareness: If timestamps are available, consider the recency of information when making updates.
7. Relationship Refinement: Look for opportunities to refine relationship descriptions for greater precision or clarity.
8. Redundancy Elimination: Identify and merge any redundant or highly similar relationships that may result from the update.
Memory Format:
source -- RELATIONSHIP -- destination
Task Details:
======= Existing Graph Memories:=======
{existing_memories}
======= New Graph Memory:=======
{new_memories}
Output:
Provide a list of update instructions, each specifying the source, target, and the new relationship to be set. Only include memories that require updates.
`;
export const EXTRACT_RELATIONS_PROMPT = `
You are an advanced algorithm designed to extract structured information from text to construct knowledge graphs. Your goal is to capture comprehensive and accurate information. Follow these key principles:
1. Extract only explicitly stated information from the text.
2. Establish relationships among the entities provided.
3. Use "USER_ID" as the source entity for any self-references (e.g., "I," "me," "my," etc.) in user messages.
CUSTOM_PROMPT
Relationships:
- Use consistent, general, and timeless relationship types.
- Example: Prefer "professor" over "became_professor."
- Relationships should only be established among the entities explicitly mentioned in the user message.
Entity Consistency:
- Ensure that relationships are coherent and logically align with the context of the message.
- Maintain consistent naming for entities across the extracted data.
Strive to construct a coherent and easily understandable knowledge graph by eshtablishing all the relationships among the entities and adherence to the user's context.
Adhere strictly to these guidelines to ensure high-quality knowledge graph extraction.
`;
export const DELETE_RELATIONS_SYSTEM_PROMPT = `
You are a graph memory manager specializing in identifying, managing, and optimizing relationships within graph-based memories. Your primary task is to analyze a list of existing relationships and determine which ones should be deleted based on the new information provided.
Input:
1. Existing Graph Memories: A list of current graph memories, each containing source, relationship, and destination information.
2. New Text: The new information to be integrated into the existing graph structure.
3. Use "USER_ID" as node for any self-references (e.g., "I," "me," "my," etc.) in user messages.
Guidelines:
1. Identification: Use the new information to evaluate existing relationships in the memory graph.
2. Deletion Criteria: Delete a relationship only if it meets at least one of these conditions:
- Outdated or Inaccurate: The new information is more recent or accurate.
- Contradictory: The new information conflicts with or negates the existing information.
3. DO NOT DELETE if their is a possibility of same type of relationship but different destination nodes.
4. Comprehensive Analysis:
- Thoroughly examine each existing relationship against the new information and delete as necessary.
- Multiple deletions may be required based on the new information.
5. Semantic Integrity:
- Ensure that deletions maintain or improve the overall semantic structure of the graph.
- Avoid deleting relationships that are NOT contradictory/outdated to the new information.
6. Temporal Awareness: Prioritize recency when timestamps are available.
7. Necessity Principle: Only DELETE relationships that must be deleted and are contradictory/outdated to the new information to maintain an accurate and coherent memory graph.
Note: DO NOT DELETE if their is a possibility of same type of relationship but different destination nodes.
For example:
Existing Memory: alice -- loves_to_eat -- pizza
New Information: Alice also loves to eat burger.
Do not delete in the above example because there is a possibility that Alice loves to eat both pizza and burger.
Memory Format:
source -- relationship -- destination
Provide a list of deletion instructions, each specifying the relationship to be deleted.
Respond in JSON format.
`;
export function getDeleteMessages(
existingMemoriesString: string,
data: string,
userId: string,
): [string, string] {
return [
DELETE_RELATIONS_SYSTEM_PROMPT.replace("USER_ID", userId),
`Here are the existing memories: ${existingMemoriesString} \n\n New Information: ${data}`,
];
}
export function formatEntities(
entities: Array<{
source: string;
relationship: string;
destination: string;
}>,
): string {
return entities
.map((e) => `${e.source} -- ${e.relationship} -- ${e.destination}`)
.join("\n");
}
+14 -70
View File
@@ -10,12 +10,6 @@ import { LLM, LLMResponse } from "./base";
import { LLMConfig, Message } from "../types/index";
// Import the schemas directly into LangchainLLM
import { FactRetrievalSchema, MemoryUpdateSchema } from "../prompts";
// Import graph tool argument schemas
import {
GraphExtractEntitiesArgsSchema,
GraphRelationsArgsSchema,
GraphSimpleRelationshipArgsSchema, // Used for delete tool
} from "../graphs/tools";
const convertToLangchainMessages = (messages: Message[]): BaseMessage[] => {
return messages.map((msg) => {
@@ -73,28 +67,14 @@ export class LangchainLLM implements LLM {
const invokeOptions: Record<string, any> = {};
let isStructuredOutput = false;
let selectedSchema: z.ZodSchema<any> | null = null;
let isToolCallResponse = false;
// --- Internal Schema Selection Logic (runs regardless of response_format) ---
const systemPromptContent =
(messages.find((m) => m.role === "system")?.content as string) || "";
const userPromptContent =
(messages.find((m) => m.role === "user")?.content as string) || "";
const toolNames = tools?.map((t) => t.function.name) || [];
// Prioritize tool call argument schemas
if (toolNames.includes("extract_entities")) {
selectedSchema = GraphExtractEntitiesArgsSchema;
isToolCallResponse = true;
} else if (toolNames.includes("establish_relationships")) {
selectedSchema = GraphRelationsArgsSchema;
isToolCallResponse = true;
} else if (toolNames.includes("delete_graph_memory")) {
selectedSchema = GraphSimpleRelationshipArgsSchema;
isToolCallResponse = true;
}
// Check for memory prompts if no tool schema matched
else if (
// Check for memory prompts
if (
systemPromptContent.includes("Personal Information Organizer") &&
systemPromptContent.includes("extract relevant pieces of information")
) {
@@ -111,26 +91,18 @@ export class LangchainLLM implements LLM {
selectedSchema &&
typeof (this.llmInstance as any).withStructuredOutput === "function"
) {
// Apply if a schema was selected (for memory or single tool calls)
if (
!isToolCallResponse ||
(isToolCallResponse && tools && tools.length === 1)
) {
try {
runnable = (this.llmInstance as any).withStructuredOutput(
selectedSchema,
{ name: tools?.[0]?.function.name },
);
isStructuredOutput = true;
} catch (e) {
isStructuredOutput = false; // Ensure flag is false on error
// No fallback to response_format here unless explicitly passed
if (response_format?.type === "json_object") {
invokeOptions.response_format = { type: "json_object" };
}
try {
runnable = (this.llmInstance as any).withStructuredOutput(
selectedSchema,
{ name: tools?.[0]?.function.name },
);
isStructuredOutput = true;
} catch (e) {
isStructuredOutput = false; // Ensure flag is false on error
// No fallback to response_format here unless explicitly passed
if (response_format?.type === "json_object") {
invokeOptions.response_format = { type: "json_object" };
}
} else if (isToolCallResponse) {
// If multiple tools, don't apply structured output, handle via tool binding below
}
} else if (selectedSchema && response_format?.type === "json_object") {
// Schema selected, but no .withStructuredOutput. Try basic response_format only if explicitly requested.
@@ -164,37 +136,9 @@ export class LangchainLLM implements LLM {
try {
const response = await runnable.invoke(langchainMessages, invokeOptions);
if (isStructuredOutput && !isToolCallResponse) {
if (isStructuredOutput) {
// Memory prompt with structured output
return JSON.stringify(response);
} else if (isStructuredOutput && isToolCallResponse) {
// Tool call with structured arguments
if (response?.tool_calls && Array.isArray(response.tool_calls)) {
const mappedToolCalls = response.tool_calls.map((call: any) => ({
name: call.name || tools?.[0]?.function.name || "unknown_tool",
arguments:
typeof call.args === "string"
? call.args
: JSON.stringify(call.args),
}));
return {
content: response.content || "",
role: "assistant",
toolCalls: mappedToolCalls,
};
} else {
// Direct object response for tool args
return {
content: "",
role: "assistant",
toolCalls: [
{
name: tools?.[0]?.function.name || "unknown_tool",
arguments: JSON.stringify(response),
},
],
};
}
} else if (
response &&
response.tool_calls &&
-675
View File
@@ -1,675 +0,0 @@
import neo4j, { Driver } from "neo4j-driver";
import { BM25 } from "../utils/bm25";
import { GraphStoreConfig } from "../graphs/configs";
import { MemoryConfig } from "../types";
import { EmbedderFactory, LLMFactory } from "../utils/factory";
import { Embedder } from "../embeddings/base";
import { LLM } from "../llms/base";
import {
DELETE_MEMORY_TOOL_GRAPH,
EXTRACT_ENTITIES_TOOL,
RELATIONS_TOOL,
} from "../graphs/tools";
import { EXTRACT_RELATIONS_PROMPT, getDeleteMessages } from "../graphs/utils";
import { logger } from "../utils/logger";
interface SearchOutput {
source: string;
source_id: string;
relationship: string;
relation_id: string;
destination: string;
destination_id: string;
similarity: number;
}
interface ToolCall {
name: string;
arguments: string;
}
interface LLMResponse {
toolCalls?: ToolCall[];
}
interface Tool {
type: string;
function: {
name: string;
description: string;
parameters: Record<string, any>;
};
}
interface GraphMemoryResult {
deleted_entities: any[];
added_entities: any[];
relations?: any[];
}
export class MemoryGraph {
private config: MemoryConfig;
private graph: Driver;
private embeddingModel: Embedder;
private llm: LLM;
private structuredLlm: LLM;
private llmProvider: string;
private threshold: number;
constructor(config: MemoryConfig) {
this.config = config;
if (
!config.graphStore?.config?.url ||
!config.graphStore?.config?.username ||
!config.graphStore?.config?.password
) {
throw new Error("Neo4j configuration is incomplete");
}
this.graph = neo4j.driver(
config.graphStore.config.url,
neo4j.auth.basic(
config.graphStore.config.username,
config.graphStore.config.password,
),
);
this.embeddingModel = EmbedderFactory.create(
this.config.embedder.provider,
this.config.embedder.config,
);
this.llmProvider = "openai";
let llmConfig = this.config.llm.config;
if (this.config.llm?.provider) {
this.llmProvider = this.config.llm.provider;
}
if (this.config.graphStore?.llm?.provider) {
this.llmProvider = this.config.graphStore.llm.provider;
llmConfig = this.config.graphStore.llm.config ?? llmConfig;
}
this.llm = LLMFactory.create(this.llmProvider, llmConfig);
this.structuredLlm = LLMFactory.create(this.llmProvider, llmConfig);
this.threshold = 0.7;
}
async add(
data: string,
filters: Record<string, any>,
): Promise<GraphMemoryResult> {
const entityTypeMap = await this._retrieveNodesFromData(data, filters);
const toBeAdded = await this._establishNodesRelationsFromData(
data,
filters,
entityTypeMap,
);
const searchOutput = await this._searchGraphDb(
Object.keys(entityTypeMap),
filters,
);
const toBeDeleted = await this._getDeleteEntitiesFromSearchOutput(
searchOutput,
data,
filters,
);
const deletedEntities = await this._deleteEntities(
toBeDeleted,
filters["userId"],
);
const addedEntities = await this._addEntities(
toBeAdded,
filters["userId"],
entityTypeMap,
);
return {
deleted_entities: deletedEntities,
added_entities: addedEntities,
relations: toBeAdded,
};
}
async search(query: string, filters: Record<string, any>, topK = 100) {
const entityTypeMap = await this._retrieveNodesFromData(query, filters);
const searchOutput = await this._searchGraphDb(
Object.keys(entityTypeMap),
filters,
);
if (!searchOutput.length) {
return [];
}
const searchOutputsSequence = searchOutput.map((item) => [
item.source,
item.relationship,
item.destination,
]);
const bm25 = new BM25(searchOutputsSequence);
const tokenizedQuery = query.split(" ");
const rerankedResults = bm25.search(tokenizedQuery).slice(0, 5);
const searchResults = rerankedResults.map((item) => ({
source: item[0],
relationship: item[1],
destination: item[2],
}));
logger.info(`Returned ${searchResults.length} search results`);
return searchResults;
}
async deleteAll(filters: Record<string, any>) {
const session = this.graph.session();
try {
await session.run("MATCH (n {user_id: $user_id}) DETACH DELETE n", {
user_id: filters["userId"],
});
} finally {
await session.close();
}
}
async getAll(filters: Record<string, any>, topK = 100) {
const session = this.graph.session();
try {
const result = await session.run(
`
MATCH (n {user_id: $user_id})-[r]->(m {user_id: $user_id})
RETURN n.name AS source, type(r) AS relationship, m.name AS target
LIMIT toInteger($limit)
`,
{ user_id: filters["userId"], limit: Math.floor(Number(topK)) },
);
const finalResults = result.records.map((record) => ({
source: record.get("source"),
relationship: record.get("relationship"),
target: record.get("target"),
}));
logger.info(`Retrieved ${finalResults.length} relationships`);
return finalResults;
} finally {
await session.close();
}
}
private async _retrieveNodesFromData(
data: string,
filters: Record<string, any>,
) {
const tools = [EXTRACT_ENTITIES_TOOL] as Tool[];
const searchResults = await this.structuredLlm.generateResponse(
[
{
role: "system",
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`,
},
{ role: "user", content: data },
],
{ type: "json_object" },
tools,
);
let entityTypeMap: Record<string, string> = {};
try {
if (typeof searchResults !== "string" && searchResults.toolCalls) {
for (const call of searchResults.toolCalls) {
if (call.name === "extract_entities") {
const args = JSON.parse(call.arguments);
for (const item of args.entities) {
entityTypeMap[item.entity] = item.entity_type;
}
}
}
}
} catch (e) {
logger.error(`Error in search tool: ${e}`);
}
entityTypeMap = Object.fromEntries(
Object.entries(entityTypeMap).map(([k, v]) => [
k.toLowerCase().replace(/ /g, "_"),
v.toLowerCase().replace(/ /g, "_"),
]),
);
logger.debug(`Entity type map: ${JSON.stringify(entityTypeMap)}`);
return entityTypeMap;
}
private async _establishNodesRelationsFromData(
data: string,
filters: Record<string, any>,
entityTypeMap: Record<string, string>,
) {
let messages;
if (this.config.graphStore?.customInstructions) {
messages = [
{
role: "system",
content:
EXTRACT_RELATIONS_PROMPT.replace(
"USER_ID",
filters["userId"],
).replace(
"CUSTOM_PROMPT",
`4. ${this.config.graphStore.customInstructions}`,
) + "\nPlease provide your response in JSON format.",
},
{ role: "user", content: data },
];
} else {
messages = [
{
role: "system",
content:
EXTRACT_RELATIONS_PROMPT.replace("USER_ID", filters["userId"]) +
"\nPlease provide your response in JSON format.",
},
{
role: "user",
content: `List of entities: ${Object.keys(entityTypeMap)}. \n\nText: ${data}`,
},
];
}
const tools = [RELATIONS_TOOL] as Tool[];
const extractedEntities = await this.structuredLlm.generateResponse(
messages,
{ type: "json_object" },
tools,
);
let entities: any[] = [];
if (typeof extractedEntities !== "string" && extractedEntities.toolCalls) {
const toolCall = extractedEntities.toolCalls[0];
if (toolCall && toolCall.arguments) {
const args = JSON.parse(toolCall.arguments);
entities = args.entities || [];
}
}
entities = this._removeSpacesFromEntities(entities);
logger.debug(`Extracted entities: ${JSON.stringify(entities)}`);
return entities;
}
private async _searchGraphDb(
nodeList: string[],
filters: Record<string, any>,
topK = 100,
): Promise<SearchOutput[]> {
const resultRelations: SearchOutput[] = [];
const session = this.graph.session();
try {
for (const node of nodeList) {
const nEmbedding = await this.embeddingModel.embed(node);
const cypher = `
MATCH (n)
WHERE n.embedding IS NOT NULL AND n.user_id = $user_id
WITH n,
round(reduce(dot = 0.0, i IN range(0, size(n.embedding)-1) | dot + n.embedding[i] * $n_embedding[i]) /
(sqrt(reduce(l2 = 0.0, i IN range(0, size(n.embedding)-1) | l2 + n.embedding[i] * n.embedding[i])) *
sqrt(reduce(l2 = 0.0, i IN range(0, size($n_embedding)-1) | l2 + $n_embedding[i] * $n_embedding[i]))), 4) AS similarity
WHERE similarity >= $threshold
MATCH (n)-[r]->(m)
RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id, similarity
UNION
MATCH (n)
WHERE n.embedding IS NOT NULL AND n.user_id = $user_id
WITH n,
round(reduce(dot = 0.0, i IN range(0, size(n.embedding)-1) | dot + n.embedding[i] * $n_embedding[i]) /
(sqrt(reduce(l2 = 0.0, i IN range(0, size(n.embedding)-1) | l2 + n.embedding[i] * n.embedding[i])) *
sqrt(reduce(l2 = 0.0, i IN range(0, size($n_embedding)-1) | l2 + $n_embedding[i] * $n_embedding[i]))), 4) AS similarity
WHERE similarity >= $threshold
MATCH (m)-[r]->(n)
RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id, similarity
ORDER BY similarity DESC
LIMIT toInteger($limit)
`;
const result = await session.run(cypher, {
n_embedding: nEmbedding,
threshold: this.threshold,
user_id: filters["userId"],
limit: Math.floor(Number(topK)),
});
resultRelations.push(
...result.records.map((record) => ({
source: record.get("source"),
source_id: record.get("source_id").toString(),
relationship: record.get("relationship"),
relation_id: record.get("relation_id").toString(),
destination: record.get("destination"),
destination_id: record.get("destination_id").toString(),
similarity: record.get("similarity"),
})),
);
}
} finally {
await session.close();
}
return resultRelations;
}
private async _getDeleteEntitiesFromSearchOutput(
searchOutput: SearchOutput[],
data: string,
filters: Record<string, any>,
) {
const searchOutputString = searchOutput
.map(
(item) =>
`${item.source} -- ${item.relationship} -- ${item.destination}`,
)
.join("\n");
const [systemPrompt, userPrompt] = getDeleteMessages(
searchOutputString,
data,
filters["userId"],
);
const tools = [DELETE_MEMORY_TOOL_GRAPH] as Tool[];
const memoryUpdates = await this.structuredLlm.generateResponse(
[
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
{ type: "json_object" },
tools,
);
const toBeDeleted: any[] = [];
if (typeof memoryUpdates !== "string" && memoryUpdates.toolCalls) {
for (const item of memoryUpdates.toolCalls) {
if (item.name === "delete_graph_memory") {
toBeDeleted.push(JSON.parse(item.arguments));
}
}
}
const cleanedToBeDeleted = this._removeSpacesFromEntities(toBeDeleted);
logger.debug(
`Deleted relationships: ${JSON.stringify(cleanedToBeDeleted)}`,
);
return cleanedToBeDeleted;
}
private async _deleteEntities(toBeDeleted: any[], userId: string) {
const results: any[] = [];
const session = this.graph.session();
try {
for (const item of toBeDeleted) {
const { source, destination, relationship } = item;
const cypher = `
MATCH (n {name: $source_name, user_id: $user_id})
-[r:${relationship}]->
(m {name: $dest_name, user_id: $user_id})
DELETE r
RETURN
n.name AS source,
m.name AS target,
type(r) AS relationship
`;
const result = await session.run(cypher, {
source_name: source,
dest_name: destination,
user_id: userId,
});
results.push(result.records);
}
} finally {
await session.close();
}
return results;
}
private async _addEntities(
toBeAdded: any[],
userId: string,
entityTypeMap: Record<string, string>,
) {
const results: any[] = [];
const session = this.graph.session();
try {
for (const item of toBeAdded) {
const { source, destination, relationship } = item;
const sourceType = entityTypeMap[source] || "unknown";
const destinationType = entityTypeMap[destination] || "unknown";
const sourceEmbedding = await this.embeddingModel.embed(source);
const destEmbedding = await this.embeddingModel.embed(destination);
const sourceNodeSearchResult = await this._searchSourceNode(
sourceEmbedding,
userId,
);
const destinationNodeSearchResult = await this._searchDestinationNode(
destEmbedding,
userId,
);
let cypher: string;
let params: Record<string, any>;
if (
destinationNodeSearchResult.length === 0 &&
sourceNodeSearchResult.length > 0
) {
cypher = `
MATCH (source)
WHERE elementId(source) = $source_id
MERGE (destination:${destinationType} {name: $destination_name, user_id: $user_id})
ON CREATE SET
destination.created = timestamp(),
destination.embedding = $destination_embedding
MERGE (source)-[r:${relationship}]->(destination)
ON CREATE SET
r.created = timestamp()
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
`;
params = {
source_id: sourceNodeSearchResult[0].elementId,
destination_name: destination,
destination_embedding: destEmbedding,
user_id: userId,
};
} else if (
destinationNodeSearchResult.length > 0 &&
sourceNodeSearchResult.length === 0
) {
cypher = `
MATCH (destination)
WHERE elementId(destination) = $destination_id
MERGE (source:${sourceType} {name: $source_name, user_id: $user_id})
ON CREATE SET
source.created = timestamp(),
source.embedding = $source_embedding
MERGE (source)-[r:${relationship}]->(destination)
ON CREATE SET
r.created = timestamp()
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
`;
params = {
destination_id: destinationNodeSearchResult[0].elementId,
source_name: source,
source_embedding: sourceEmbedding,
user_id: userId,
};
} else if (
sourceNodeSearchResult.length > 0 &&
destinationNodeSearchResult.length > 0
) {
cypher = `
MATCH (source)
WHERE elementId(source) = $source_id
MATCH (destination)
WHERE elementId(destination) = $destination_id
MERGE (source)-[r:${relationship}]->(destination)
ON CREATE SET
r.created_at = timestamp(),
r.updated_at = timestamp()
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
`;
params = {
source_id: sourceNodeSearchResult[0]?.elementId,
destination_id: destinationNodeSearchResult[0]?.elementId,
user_id: userId,
};
} else {
cypher = `
MERGE (n:${sourceType} {name: $source_name, user_id: $user_id})
ON CREATE SET n.created = timestamp(), n.embedding = $source_embedding
ON MATCH SET n.embedding = $source_embedding
MERGE (m:${destinationType} {name: $dest_name, user_id: $user_id})
ON CREATE SET m.created = timestamp(), m.embedding = $dest_embedding
ON MATCH SET m.embedding = $dest_embedding
MERGE (n)-[rel:${relationship}]->(m)
ON CREATE SET rel.created = timestamp()
RETURN n.name AS source, type(rel) AS relationship, m.name AS target
`;
params = {
source_name: source,
dest_name: destination,
source_embedding: sourceEmbedding,
dest_embedding: destEmbedding,
user_id: userId,
};
}
const result = await session.run(cypher, params);
results.push(result.records);
}
} finally {
await session.close();
}
return results;
}
private _removeSpacesFromEntities(entityList: any[]) {
return entityList.map((item) => ({
...item,
source: item.source.toLowerCase().replace(/ /g, "_"),
relationship: item.relationship.toLowerCase().replace(/ /g, "_"),
destination: item.destination.toLowerCase().replace(/ /g, "_"),
}));
}
private async _searchSourceNode(
sourceEmbedding: number[],
userId: string,
threshold = 0.9,
) {
const session = this.graph.session();
try {
const cypher = `
MATCH (source_candidate)
WHERE source_candidate.embedding IS NOT NULL
AND source_candidate.user_id = $user_id
WITH source_candidate,
round(
reduce(dot = 0.0, i IN range(0, size(source_candidate.embedding)-1) |
dot + source_candidate.embedding[i] * $source_embedding[i]) /
(sqrt(reduce(l2 = 0.0, i IN range(0, size(source_candidate.embedding)-1) |
l2 + source_candidate.embedding[i] * source_candidate.embedding[i])) *
sqrt(reduce(l2 = 0.0, i IN range(0, size($source_embedding)-1) |
l2 + $source_embedding[i] * $source_embedding[i])))
, 4) AS source_similarity
WHERE source_similarity >= $threshold
WITH source_candidate, source_similarity
ORDER BY source_similarity DESC
LIMIT 1
RETURN elementId(source_candidate) as element_id
`;
const params = {
source_embedding: sourceEmbedding,
user_id: userId,
threshold,
};
const result = await session.run(cypher, params);
return result.records.map((record) => ({
elementId: record.get("element_id").toString(),
}));
} finally {
await session.close();
}
}
private async _searchDestinationNode(
destinationEmbedding: number[],
userId: string,
threshold = 0.9,
) {
const session = this.graph.session();
try {
const cypher = `
MATCH (destination_candidate)
WHERE destination_candidate.embedding IS NOT NULL
AND destination_candidate.user_id = $user_id
WITH destination_candidate,
round(
reduce(dot = 0.0, i IN range(0, size(destination_candidate.embedding)-1) |
dot + destination_candidate.embedding[i] * $destination_embedding[i]) /
(sqrt(reduce(l2 = 0.0, i IN range(0, size(destination_candidate.embedding)-1) |
l2 + destination_candidate.embedding[i] * destination_candidate.embedding[i])) *
sqrt(reduce(l2 = 0.0, i IN range(0, size($destination_embedding)-1) |
l2 + $destination_embedding[i] * $destination_embedding[i])))
, 4) AS destination_similarity
WHERE destination_similarity >= $threshold
WITH destination_candidate, destination_similarity
ORDER BY destination_similarity DESC
LIMIT 1
RETURN elementId(destination_candidate) as element_id
`;
const params = {
destination_embedding: destinationEmbedding,
user_id: userId,
threshold,
};
const result = await session.run(cypher, params);
return result.records.map((record) => ({
elementId: record.get("element_id").toString(),
}));
} finally {
await session.close();
}
}
}
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -13,13 +13,15 @@ export interface AddMemoryOptions extends Entity {
infer?: boolean;
}
export interface SearchMemoryOptions extends Entity {
export interface SearchMemoryOptions {
topK?: number;
filters?: SearchFilters;
threshold?: number;
}
export interface GetAllMemoryOptions {
topK?: number;
filters?: SearchFilters;
}
export interface GetAllMemoryOptions extends Entity {
topK?: number;
}
export interface DeleteAllMemoryOptions extends Entity {}
+592
View File
@@ -274,6 +274,598 @@ export function getUpdateMemoryMessages(
Do not return anything except the JSON format.`;
}
// ---------------------------------------------------------------------------
// V3 Additive Extraction Prompt
// Ported from mem0/configs/prompts.py — ADDITIVE_EXTRACTION_PROMPT
// ---------------------------------------------------------------------------
export const ADDITIVE_EXTRACTION_PROMPT = `
# ROLE
You are a Memory Extractor — a precise, evidence-bound processor responsible for extracting rich, contextual memories from conversations. Your sole operation is ADD: identify every piece of memorable information and produce self-contained, contextually rich factual statements.
You extract from BOTH user and assistant messages. User messages reveal personal facts, preferences, plans, and experiences. Assistant messages contain recommendations, plans, suggestions, and actionable information the user may later reference.
Accuracy and completeness are critical. Every piece of memorable information must be captured — a missed extraction means lost context that degrades future personalization. When a conversation covers multiple topics, extract each one separately. Do not let a dominant topic cause you to miss secondary information.
# INPUTS
## New Messages
The current conversation turn(s) with "role" (user/assistant) and "content".
Both roles contain extractable information:
- **User messages**: Personal facts, preferences, plans, experiences, things done / never done before, opinions, requests, implicit preferences revealed through questions
- **Assistant messages**: Specific recommendations given, plans or schedules created, information researched, solutions provided, agreements reached
Attribute correctly: use "User" for user-stated facts. For assistant-generated content, frame in terms of the user's context (e.g., "User was recommended X" or "User's plan includes X as discussed in conversation").
Do NOT extract:
- Vague assistant characterizations ("you seem passionate", "that sounds stressful") unless the user explicitly confirms them
- Generic assistant acknowledgments ("Sure!", "Great question!")
- Assistant meta-commentary about its own capabilities
## Summary
A narrative summary of the user's profile from prior conversations. May be empty for new users. Use it to enrich extractions — it holds established context like names, locations, and relationships.
## Recently Extracted Memories
Memories already captured from recent messages in this session (up to 20). This is your primary deduplication reference — do not re-extract information already captured here.
## Existing Memories
Memories currently in the system relevant to this conversation. Formatted as:
[{"id": "uuid-string", "text": "..."}, ...]
Use these ONLY for deduplication and linking — do NOT extract new memories from Existing Memories. Your extractions must come exclusively from New Messages. If new information in New Messages is semantically equivalent to an Existing Memory with no meaningful new context, skip it.
When a new memory is related to an Existing Memory — same topic, overlapping entities, updated/shifted preference, follow-up event, or continuation of a narrative — include the Existing Memory's ID in the new memory's "linked_memory_ids" array. Your ADD output IDs remain sequential ("0", "1", ...) but linked_memory_ids uses the UUIDs from this list.
IMPORTANT: An existing memory about an entity (e.g., "User has a dog named Max") does NOT mean all information about that entity has been captured. New events, activities, experiences, or details about a known entity MUST still be extracted as separate memories and linked back. Only skip extraction when the specific fact or event itself is already captured — not merely because the entity appears in an existing memory. "User has a dog named Max" and "User went on a camping trip with Max where they hiked and swam" are two distinct memories, not duplicates.
## Last k Messages
Recent messages (up to 20) preceding New Messages. Use to resolve references and pronouns in New Messages.
## Observation Date
When the conversation actually took place (e.g., "2023-05-24"). This is your ONLY temporal anchor for resolving time references.
Resolve ALL relative references against Observation Date:
- "yesterday" → day before Observation Date
- "last week" → week preceding Observation Date
- "next month" → month following Observation Date
- "recently" → shortly before Observation Date
- "just finished", "today" → on or near Observation Date
CRITICAL: "User went to Paris last week" is useless 6 months later. "User went to Paris the week of May 15, 2023" is meaningful forever. Always ground relative references to specific dates.
## Current Date
Today's system date. May be years after Observation Date. Do NOT use this to resolve temporal references in messages — only Observation Date grounds user and assistant statements.
## Optional Inputs
- **includes**: Topics to focus on
- **excludes**: Topics to skip
- **custom_instructions**: User-defined rules (highest priority)
- **feedback_str**: Adjust extraction based on this feedback
# GUIDELINES
## What to Extract
Extract ALL memorable information from both user and assistant messages. Think broadly:
**From user messages:**
- Personal details, preferences, plans, relationships, professional context
- Health/wellness, opinions, hobbies, emotional states
- Entity attributes (breed, model, color, make, size)
- Implicit preferences revealed through requests
- **Shared content and reference material** — when a user shares documents, case studies, articles, data, specifications, stat blocks, code, or any structured information, extract the key factual data FROM that content. The user shared it because they want it remembered.
- Firsts and milestones — 'first call-out', 'just started', 'recently joined', etc.
- Specific foods, meals, and who was present (e.g. 'dinner with mom — salads, sandwiches, homemade desserts').
- Inspiration and motivation — what inspired someone to start something, who encouraged them.
**From assistant messages (ONLY when genuinely new):**
- Specific recommendations given (books, restaurants, products, services)
- Plans or schedules created for the user
- Information researched or provided (facts, instructions, solutions)
- Agreements reached during conversation
- **Personal facts, experiences, and details shared by named speakers** — in multi-speaker conversations, the "assistant" role may represent a real person sharing their own life (e.g., "Maria: I just got a new cat named Bailey"). Extract their personal information with the same rigor as user-stated facts, attributed to the speaker by name.
Do NOT extract from assistant messages that merely restate, summarize, or confirm what the user already said. The user's own words are the primary source — if the user said it and the assistant echoed it, extract only once from the user's version. Note: a single assistant message may contain BOTH an echo AND new personal facts — skip the echo portion but still extract the new facts.
Do NOT extract: greetings, filler, vague acknowledgments, or content too generic to be useful.
**When in doubt, extract.** A slightly redundant memory is far less costly than a missing one. The deduplication system downstream will handle true duplicates — your job is to ensure nothing meaningful is lost.
### Casual Topics Are Still Extractable
Conversations about pets, hobbies, childhood memories, funny anecdotes, and personal preferences are NOT "chitchat" to be skipped. In a personal memory system, these casual revelations are often the MOST valuable — someone's pet's name, a childhood activity with a parent, a funny incident, a new hobby. Only skip messages that are PURELY phatic ("Hi!", "Sounds good!", "Thanks!") with zero informational content.
### Extract Incidental Facts, Not Just Requests
When a user asks a question or makes a request, their message often contains INCIDENTAL PERSONAL FACTS stated as context. These facts are just as extractable as the request itself:
- "I've harvested cherry tomatoes from my garden — any companion plant suggestions?" → Extract BOTH "User grows cherry tomatoes in their garden"
- "I just started 'The Nightingale' by Kristin Hannah — can you recommend similar books?" → Extract BOTH "User started reading 'The Nightingale' by Kristin Hannah on [date]"
- "As an aspiring stand-up comedian, can you suggest Netflix comedy specials?" → Extract BOTH the career aspiration
- "My daughter Sara loves painting — where can I find kids' art classes?" → Extract "User has a daughter named Sara who loves painting"
Do NOT let the request overshadow the facts. A question about companion plants is transient; the fact that the user grows cherry tomatoes is a persistent personal detail worth remembering.
**IMPORTANT — Extract ALL dimensions of a conversation.** A single session may contain career facts, entertainment preferences, scheduled plans, and personal opinions. Extract each dimension as a separate memory. Do not let one dominant topic cause you to miss secondary information.
### Shared Photos and Images
When a message contains a photo description (e.g., "[Shared photo: ...]" or describes sharing/showing an image), extract factual information from BOTH the surrounding conversation text AND the photo description. The photo description provides visual context that may contain important details:
- A photo of a group at a park → extract the activity (e.g., "had a picnic at the park")
- A photo showing a specific object, place, or person → extract what is depicted
- A photo with visible text (signs, posters, book covers) → extract the text content
## Memory Quality Standards
### Contextually Rich, Not Atomic
Capture the full picture — fact AND surrounding context — in a single unified memory, not scattered fragments.
Bad: "User has a dog" | Good: "User has a dog named Poppy and their morning walks together are the highlight of their day"
This applies especially to **transitions and changes**. When the user describes changing, switching, replacing, stopping, or trying something new in place of something else, the memory MUST capture the transition — what the new state is AND what it replaces or changes from. The relationship between old and new is critical context. Without it, the system has an isolated new fact with no understanding of what changed.
Bad: "User prefers oat milk lattes"
Good: "User switched from almond milk to oat milk lattes after developing an almond sensitivity"
Bad: "User is taking online Spanish classes on Wednesdays"
Good: "User switched from in-person French classes to online Spanish classes on Wednesdays after relocating"
When the change is explicitly temporary or a trial, capture that too — "for a month", "trying out", "testing" — these signal the old arrangement may resume.
### Clean Factual Statements
Preserve the FULL meaning including emotional reactions, motivations, and subjective experiences. Remove filler words and conversation mechanics (greetings, "like", "you know"), but KEEP:
- Emotional states: "scared but reassured", "happy and thankful", "liberated and empowered"
- Motivations and reasons: "motivated by her own journey and the support she received"
- Subjective descriptions: "resilient", "therapeutic", "nerve-wracking"
### Self-Contained
Every memory must be understandable on its own. Replace all pronouns with specific names or "User."
### Concise but Complete (15-80 words, up to 100 for detail-rich content)
1-2 sentences per memory (up to 3 for content with multiple proper nouns, specific quantities, or enumerated items). When a topic has too many details, split into multiple focused memories rather than compressing details away. NEVER sacrifice a proper noun, title, date, or specific detail to meet a word count — completeness beats brevity.
### Temporally Grounded
Preserve exact dates, durations, and temporal relationships. Convert relative → absolute using Observation Date (NOT Current Date). NEVER convert absolute → vague. "18 days" stays "18 days", not "some time."
### Numerically Precise
Preserve exact quantities as stated. "416 pages" stays "416 pages", not "about 400 pages."
### Preserve Specific Details — Never Generalize Concrete Information
When information contains specific details — whether quantities, identifiers, descriptions, visual details, quoted text, named objects, proper nouns, or any concrete information — those specifics MUST survive extraction. Replacing a specific detail with a vague category is a critical error.
#### Proper Nouns and Titles Should be Preserved
Book titles, movie titles, game names, song titles, restaurant names, neighborhood names, brand names, character names, and named places are the HIGHEST-VALUE details in a memory. Users search by name — a memory without the name is unfindable. ALWAYS preserve exact proper nouns:
- "watched 'Eternal Sunshine of the Spotless Mind'" → KEEP the full title
- "went to Woodhaven for a road trip" → KEEP "Woodhaven"
- "tried the new restaurant Osteria Francescana" → KEEP "Osteria Francescana", NOT "a new restaurant"
- "reading 'A Court of Thorns and Roses'" → KEEP the title in quotes, NOT "a fantasy book"
- "his favorite character is Aragorn from Lord of the Rings" → KEEP "Aragorn" and "Lord of the Rings"
#### Qualifiers and Specific Attributes Are Essential
Never generalize specific qualifiers. The qualifier is almost always the detail that matters most for recall:
- "promoted to assistant manager" → KEEP "assistant manager", NOT "manager"
- "ordered grilled salmon and roasted vegetables" → KEEP "grilled salmon and roasted vegetables", NOT "healthy meal"
- "started doing aerial yoga" → KEEP "aerial yoga", NOT "yoga" or "a workout class"
- "painted a forest scene in watercolors" → KEEP "a forest scene in watercolors", NOT "started painting"
- "drove a Ferrari 488 GTB" → KEEP "Ferrari 488 GTB", NOT "sports car"
- "scored 3 goals in the semifinal" → KEEP "3 goals in the semifinal", NOT "scored several goals"
- "walks her dogs multiple times a day" → KEEP "multiple times a day", NOT "regularly" or "daily"
If the input is specific, the memory must be equally specific. The concrete details are precisely what distinguishes a useful memory from a useless one. NEVER replace a specific noun, number, title, or description with a vague category or paraphrase — this destroys the information the user actually shared.
### Meaning-Preserving
Capture the EXACT meaning of what was said. Read carefully:
- "Didn't get to bed until 2 AM" = went TO BED at 2 AM (late bedtime), NOT "slept until 2 AM" (late wakeup)
- "Can't stop eating chocolate" = eats a lot of chocolate, NOT has stopped eating chocolate
- "I used to love hiking" = no longer loves hiking, NOT currently loves hiking
Misinterpreting the user's words is worse than not extracting at all.
## Integrity Rules
- **No Fabrication**: Every detail must trace to the inputs. If you can't point to where it came from, don't include it.
- **No Implicit Attribute Inference**: Don't infer gender, age, ethnicity, etc. from names or context. Only record explicitly stated attributes.
- **Correct Attribution**: Distinguish user-stated facts from assistant-provided information. Frame assistant content appropriately.
- **No Echo Extraction**: When an assistant message restates, summarizes, or confirms information the user already provided in the same conversation, do NOT extract it again from the assistant's message. Only extract from assistant messages when they contribute genuinely NEW information not already present in the user's messages — specific recommendations, newly created plans or schedules, researched facts, or solutions the assistant provided that the user did not state themselves. If the user says "I want daily check-ins at 7:30 AM" and the assistant responds "I've set up daily check-ins at 7:30 AM", that is already captured from the user's message — do not extract a second memory from the assistant's echo.
- **No Within-Response Duplication**: Each piece of information must appear exactly ONCE in your output, regardless of how many messages mention it. Before finalizing your output, review your extractions and remove any that are semantically equivalent to another extraction in the same response. Two memories about the same fact phrased differently are redundant — keep the richer one and drop the other.
- **No Meta-Extraction**: Extract the CONTENT of what was shared, not a description of the user's action. When a user shares a document, data, or reference material, extract the actual facts FROM that material.
- WRONG: "User asked for the introductory paragraph to be shortened" / "User shared a case summary for optimization"
- RIGHT: "The Bajimaya v Reward Homes case involved construction starting in 2014, contract signed in 2015, with completion due by October 2015" / "The tribunal found Reward Homes breached its contract through poor workmanship, waterproofing defects, and non-compliance with the Building Code of Australia"
- WRONG: "Assistant created a D&D adventure with enemies"
- RIGHT: "The Lost Temple of the Djinn adventure includes 4 Mummies (AC 11, 45 HP), 2 Construct Guardians (AC 17, 110 HP), and 6 Skeletal Warriors (AC 12, 22 HP)"
- **No Detail Contamination from Context**: When extracting from New Messages, do NOT import or merge details from Existing Memories or Recent Memories into the new extraction UNLESS the new message explicitly references those details. If the New Message says "I had a great meal" and an Existing Memory says "User's favorite restaurant is Olive Garden," do NOT produce "User had a great meal at Olive Garden" — the new message never mentioned the restaurant. Each extraction must be faithful to its source message only.
## Memory Linking
When extracting a new memory, check if it relates to any Existing Memory. Add related Existing Memory IDs to "linked_memory_ids". Link when:
- **Same entity/topic**: New fact about a person, place, or thing already mentioned
- **Updated preference**: A changed or evolved opinion on something previously captured
- **Continuation**: Follow-up event or next step in a previously captured narrative
- **Contradiction**: New information that conflicts with an existing memory
Do NOT link memories that merely share a vague theme. Links should be specific and meaningful — the linked memories should be about the same specific entity, event, or topic. If no existing memories are related, omit linked_memory_ids or pass an empty array.
# EXAMPLES
## Example 1: Multi-Topic Extraction
Summary: ""
Recently Extracted: []
Existing Memories: []
New Messages:
[{"role": "user", "content": "Hey! I'm Marcus. I just got promoted to Senior Engineer at Shopify last week - been grinding for two years for this. My wife Elena and I celebrated with dinner at Osteria Francescana, it's our go-to spot for special occasions. We're also expecting our first baby in March!"},
{"role": "assistant", "content": "Congratulations on everything, Marcus! What exciting times."}]
Observation Date: 2025-08-19
Output:
{"memory": [
{"id": "0", "text": "User's name is Marcus and was promoted to Senior Engineer at Shopify around August 12, 2025 after working toward it for two years"},
{"id": "1", "text": "Marcus has a wife named Elena and they celebrate special occasions at Osteria Francescana, their go-to restaurant"},
{"id": "2", "text": "Marcus and his wife Elena are expecting their first baby in March 2026"}
]}
Three distinct topics — career, relationship/dining, family milestone — each get their own memory with full context.
## Example 2: Extracting from Assistant Recommendations
Summary: "User is an aspiring stand-up comedian interested in improving their craft."
Recently Extracted: []
Existing Memories: []
New Messages:
[{"role": "user", "content": "Can you recommend some sports documentaries on Netflix with strong storytelling? I love \\"The Last Dance\\" by Michael Jordan."},
{"role": "assistant", "content": "Great taste! Here are some Netflix documentaries known for their storytelling: 1) \\"Formula 1: Drive to Survive\\" (behind the scenes of Formula 1 racing) 2) \\"Athlete A\\" (investigative look at USA Gymnastics) 3) \\"The Battered Bastards of Baseball\\" (independent baseball story). All focus on powerful, narrative-driven sports stories."}]
Observation Date: 2023-06-01
Output:
{"memory": [
{"id": "0", "text": "User enjoys watching sports documentaries on Netflix with strong storytelling, such as 'The Last Dance' featuring Michael Jordan"},
{"id": "1", "text": "User was recommended the following sports documentaries on Netflix for storytelling: 'Formula 1: Drive to Survive', 'Athlete A', and 'The Battered Bastards of Baseball'"}
]}
The user's viewing preference (Netflix stand-up comedy) is extracted alongside the assistant's specific recommendations. Both are valuable for future personalization.
## Example 3: Nothing to Extract
Summary: "User is a product manager named David."
Existing Memories: [{"id": "0", "text": "David is a product manager at a fintech startup"}]
New Messages:
[{"role": "user", "content": "Hey, good morning!"},
{"role": "assistant", "content": "Good morning, David! How can I help you today?"}]
Observation Date: 2025-08-19
Output: {"memory": []}
## Example 5: Deduplication — Skip Already Captured
Recently Extracted: ["Marcus was promoted to Senior Engineer at Shopify around August 12, 2025"]
Existing Memories: [{"id": "0", "text": "Marcus was promoted to Senior Engineer at Shopify around August 12, 2025"}]
New Messages:
[{"role": "user", "content": "Still can't believe I got the senior engineer promotion at Shopify!"}]
Observation Date: 2025-08-19
Output: {"memory": []}
## Example 6: Extract ALL Dimensions — Don't Miss Secondary Info
Summary: "User is an aspiring actor."
Recently Extracted: []
Existing Memories: []
New Messages:
[{"role": "user", "content": "As an aspiring actor, I'm looking for advice on improving my craft. Can you recommend some films on Netflix with strong acting performances like Daniel Day-Lewis in 'There Will Be Blood'? I also want to find online resources for acting techniques."},
{"role": "assistant", "content": "For Netflix films with great acting, check out 'Marriage Story' and 'The Irishman'. For acting techniques, I'd recommend 'An Actor Prepares' by Stanislavski and the MasterClass by Helen Mirren."}]
Observation Date: 2023-06-01
Output:
{"memory": [
{"id": "0", "text": "User is an aspiring actor seeking to improve their craft through studying films with strong performances and acting technique resources"},
{"id": "1", "text": "User enjoys watching films on Netflix with outstanding acting, especially performances like Daniel Day-Lewis in 'There Will Be Blood'"},
{"id": "2", "text": "User was recommended 'Marriage Story' and 'The Irishman' for performance study, 'An Actor Prepares' by Stanislavski, and Helen Mirren's MasterClass for acting techniques"}
]}
Three dimensions: (1) career aspiration, (2) entertainment viewing preference, (3) specific recommendations. Each extracted separately.
## Example 7: Vague Temporal References with Historical Observation Date
Recently Extracted: ["User started reading 'The Hitchhiker's Guide to the Galaxy' on January 16, 2022"]
Existing Memories: [{"id": "0", "text": "User started reading 'The Hitchhiker's Guide to the Galaxy' on January 16, 2022"}]
New Messages:
[{"role": "user", "content": "I've actually listened to Ready Player One as an audiobook recently and enjoyed the pop culture references."}]
Observation Date: 2022-01-16
Current Date: 2026-02-18
Output:
{"memory": [{"id": "0", "text": "User listened to the Ready Player One audiobook around early January 2022 and enjoyed the pop culture references"}]}
"Recently" is grounded to the Observation Date (January 2022), NOT Current Date (February 2026). The Hitchhiker's Guide memory already exists — not re-extracted.
## Example 8: Document / Reference Material — Extract Content, Not Actions
Summary: ""
Recently Extracted: []
Existing Memories: []
New Messages:
[{"role": "user", "content": "I want you to remember this case. If you understand, just say acknowledged. Bajimaya v Reward Homes Pty Ltd [2021] NSWCATAP 297 — The construction began in 2014, contract signed in 2015 with completion due by October 2015. The plaintiff received keys in December 2016 and found defects including incomplete works, poor workmanship, and non-compliance with the building code. The tribunal found the builder breached contract."},
{"role": "assistant", "content": "Acknowledged."}]
Observation Date: 2024-03-10
Output:
{"memory": [
{"id": "0", "text": "Bajimaya v Reward Homes Pty Ltd [2021] NSWCATAP 297: construction of the home began in 2014, contract signed in 2015, with completion due by October 2015. Keys were delivered in December 2016."},
{"id": "1", "text": "In Bajimaya v Reward Homes, the plaintiff found defects including incomplete works, poor workmanship, and non-compliance with the Building Code of Australia after receiving the home in December 2016."},
{"id": "2", "text": "The tribunal found Reward Homes Pty Ltd breached its contract with Mr. Bajimaya by failing to complete work in a proper and workmanlike manner and failing to comply with plans, specifications, and the Building Code."}
]}
The user shared reference material to be remembered. Extract the actual factual content — dates, parties, findings — NOT "User shared a case summary" or "User asked to remember a case."
## Example 9: Structured Data with Counts and Specifics
Summary: ""
Recently Extracted: []
Existing Memories: []
New Messages:
[{"role": "user", "content": "Here are the enemy stat blocks for our D&D campaign: Mummies (4): AC 11, HP 45, Speed 20 ft, with Curse of the Pharaohs (DC 15 Wisdom) and Mummy Rot (DC 15 Constitution). Construct Guardians (2): AC 17, HP 110, Speed 30 ft, with Immutable Form, Magic Resistance, and Siege Monster. Skeletal Warriors (6): AC 12, HP 22, Speed 30 ft, with Undead Fortitude."},
{"role": "assistant", "content": "Got it! I've noted all the stat blocks. Ready when you want to start the encounter."}]
Observation Date: 2024-01-15
Output:
{"memory": [
{"id": "0", "text": "User's D&D campaign encounter includes 4 Mummies (AC 11, 45 HP, Speed 20 ft) with Curse of the Pharaohs (DC 15 Wisdom save) and Mummy Rot (DC 15 Constitution save)"},
{"id": "1", "text": "User's D&D campaign encounter includes 2 Construct Guardians (AC 17, 110 HP, Speed 30 ft) with Immutable Form, Magic Resistance, and Siege Monster traits"},
{"id": "2", "text": "User's D&D campaign encounter includes 6 Skeletal Warriors (AC 12, 22 HP, Speed 30 ft) with the Undead Fortitude trait"}
]}
Every count (4 Mummies, 2 Construct Guardians, 6 Skeletal Warriors) and every specific value (AC, HP, DCs, trait names) is preserved. Dropping the counts or stat values would destroy the most queryable information.
## Example 10: Memory Linking — Connecting Related Memories
Summary: ""
Recently Extracted: []
Existing Memories: [{"id": "a1b2c3d4-5678-9abc-def0-111111111111", "text": "User has a dog named Poppy, a golden retriever"}, {"id": "b2c3d4e5-6789-abcd-ef01-222222222222", "text": "User works as a Senior Engineer at Shopify"}]
New Messages:
[{"role": "user", "content": "Poppy had her vet checkup yesterday — she's healthy but needs to lose a few pounds. Also, I'm switching teams at work next month to the payments platform."}]
Observation Date: 2025-03-15
Output:
{"memory": [
{"id": "0", "text": "User's dog Poppy had a vet checkup around March 14, 2025, is healthy but needs to lose weight", "linked_memory_ids": ["a1b2c3d4-5678-9abc-def0-111111111111"]},
{"id": "1", "text": "User is switching teams at Shopify to the payments platform in April 2025", "linked_memory_ids": ["b2c3d4e5-6789-abcd-ef01-222222222222"]}
]}
Both new memories link to related existing memories — the vet checkup links to the existing Poppy memory, and the team switch links to the existing Shopify memory. This enables the system to build a graph of related memories.
## Example 11: Long Multi-Topic Conversation — Don't Stop After First Topic
Summary: ""
Recently Extracted: []
Existing Memories: []
New Messages:
[{"role": "user", "content": "I adopted a puppy named Max last weekend! He's a beagle mix."},
{"role": "assistant", "content": "Congratulations! How's he settling in?"},
{"role": "user", "content": "Great! Oh, and I also started pottery classes on Tuesdays. Made a mug with my daughter's face on it."},
{"role": "assistant", "content": "Fun! Sounds like a lot going on."},
{"role": "user", "content": "Yeah — my sister just moved to Portland too. I'm happy but honestly a bit overwhelmed. My boss gave me a promotion to team lead last week as well."}]
Observation Date: 2025-03-10
Output:
{"memory": [
{"id": "0", "text": "User adopted a beagle mix puppy named Max around March 1-2, 2025"},
{"id": "1", "text": "User started taking pottery classes on Tuesdays"},
{"id": "2", "text": "User made a ceramic mug with their daughter's face on it in pottery class"},
{"id": "3", "text": "User's sister recently moved to Portland"},
{"id": "4", "text": "User was promoted to team lead around March 3, 2025, and feels happy but overwhelmed about all the recent changes"}
]}
FIVE topics across 5 messages — each one extracted separately. Do not stop after the first topic (the puppy). The pottery mug detail, the sister's move, and the emotional reaction to the promotion are all distinct, extractable facts.
## Example 12: Multi-Speaker Conversation — Extract From ALL Speakers
Summary: "John has a dog named Max."
Recently Extracted: []
Existing Memories: [{"id": "a1b2c3d4-0000-0000-0000-111111111111", "text": "John has a dog named Max"}]
New Messages:
[{"role": "user", "content": "John: Max and I had a blast on our camping trip last summer. We hiked, swam, and made great memories. It was a really peaceful experience."},
{"role": "assistant", "content": "Maria: That sounds amazing! I actually just got a new cat named Bailey last week — she's been such a joy already. Camping with pets is so soul-nourishing."},
{"role": "user", "content": "John: Congrats on Bailey! Here's a picture of my family too — that was from a trip we took for my daughter Sara's birthday last fall."}]
Observation Date: 2023-08-11
Output:
{"memory": [
{"id": "0", "text": "John and his dog Max went on a camping trip in the summer of 2023 where they hiked, swam, and found it a peaceful experience", "linked_memory_ids": ["a1b2c3d4-0000-0000-0000-111111111111"]},
{"id": "1", "text": "Maria got a new cat named Bailey around early August 2023 and describes her as a joy"},
{"id": "2", "text": "John has a daughter named Sara and the family took a trip for her birthday in fall 2022"}
]}
Three key lessons: (1) The existing memory "John has a dog named Max" does NOT mean all Max-related information is captured — the camping trip is a new event with specific activities (hiking, swimming) and must be extracted and linked. (2) Maria is a named speaker in the "assistant" role but shares a genuine personal fact (new cat Bailey) — this MUST be extracted with the same rigor as user facts. Her echo ("that sounds amazing", "camping is soul-nourishing") is correctly skipped, but her personal fact is not. (3) Sara's name and the birthday trip are separate factual details that each deserve their own extraction.
# CRITICAL: Exhaustive Extraction Checklist
Before producing output, mentally scan the ENTIRE conversation — every single message — and verify:
1. Have you extracted at least one memory from every distinct topic or subject change in the conversation?
2. Have you extracted facts from messages in the MIDDLE and END of the conversation, not just the beginning?
3. For conversations with 10+ messages, you should typically extract 5-15 memories. If you have fewer than 3, re-read the conversation — you are almost certainly missing information.
4. Re-read each user message individually: does EVERY specific fact, preference, experience, or event mentioned in that message have a corresponding extraction? If a single message mentions two distinct facts (e.g., an allergy AND a hobby), both must be captured.
A common failure mode is "first topic dominance" — the extractor captures the first major topic thoroughly, then treats subsequent topics as filler. This is WRONG. Every topic mentioned deserves extraction if it contains memorable facts. If a chunk has 8 messages covering 4 different topics, you MUST produce memories for all 4 topics — not just the first or most prominent one.
# OUTPUT FORMAT
Return ONLY valid JSON parsable by json.loads(). No text, reasoning, explanations, or wrappers.
## Structure
{
"memory": [
{"id": "0", "text": "First extracted memory", "attributed_to": "user", "linked_memory_ids": ["uuid-of-related-existing-memory"]},
{"id": "1", "text": "Second extracted memory", "attributed_to": "assistant"}
]
}
## Fields
- **id** (string, required): Sequential integers as strings starting at "0".
- **text** (string, required): A contextually rich, self-contained factual statement (15-80 words).
- **attributed_to** (string, required): Who this memory is about. Use "user" for facts stated by or about the user (preferences, plans, personal facts). Use "assistant" for information provided by the assistant (recommendations, confirmations, plans created, information researched).
- **linked_memory_ids** (array of strings, optional): IDs of Existing Memories that this new memory relates to. Use the exact IDs from the Existing Memories list. Omit or pass [] if no existing memories are related.
## Rules
- Extract every piece of memorable information as a separate memory object.
- If nothing is worth extracting, return: {"memory": []}
- No duplicate IDs. Use double quotes. No trailing commas.
`;
export const AGENT_CONTEXT_SUFFIX = `
## Entity Context
The primary entity is an AI agent. Frame memories from the agent's perspective:
- For user-stated facts, frame as agent knowledge: "Agent was informed that [fact]" or "Agent learned that [fact]"
- For agent actions, use direct statements: "Agent recommended [X]" or "Agent specializes in [domain]"
- For agent configuration or instructions, capture directly: "Agent is configured to [behavior]"
The attributed_to field should still reflect the original source: "user" for facts the user stated, "assistant" for things the agent said or did.
`;
// ---------------------------------------------------------------------------
// V3 Additive Extraction Schema
// ---------------------------------------------------------------------------
export const AdditiveExtractionSchema = z.object({
memory: z.array(
z.object({
id: z.string(),
text: z.string(),
attributed_to: z.enum(["user", "assistant"]).optional(),
linked_memory_ids: z.array(z.string()).optional(),
}),
),
});
// ---------------------------------------------------------------------------
// V3 Prompt Builder — generates the user-side prompt for additive extraction
// Ported from mem0/configs/prompts.py generate_additive_extraction_prompt()
// ---------------------------------------------------------------------------
const PAST_MESSAGE_TRUNCATION_LIMIT = 300;
function truncateContent(
text: string,
limit = PAST_MESSAGE_TRUNCATION_LIMIT,
): string {
if (text.length <= limit) return text;
return text.slice(0, limit) + "...";
}
function formatConversationHistory(
messages?: Array<{ role: string; content: string }>,
): string {
if (!messages || messages.length === 0) return "";
let result = "";
for (const msg of messages) {
const role = msg.role ?? "";
const content = msg.content ?? "";
if (role && content) {
result += `${role}: ${truncateContent(content)}\n`;
}
}
return result;
}
function serializeMemories(
memories?: Array<{ id: string; text: string }>,
): string {
return JSON.stringify(memories ?? []);
}
export function generateAdditiveExtractionPrompt(options: {
existingMemories?: Array<{ id: string; text: string }>;
newMessages?: string;
lastKMessages?: Array<{ role: string; content: string }>;
customInstructions?: string;
currentDate?: string;
observationDate?: string;
}): string {
const now = new Date().toISOString().split("T")[0];
const currentDate = options.currentDate ?? now;
const observationDate = options.observationDate ?? currentDate;
const sections: string[] = [];
// Summary — empty for now; callers can extend later
sections.push("## Summary\n");
sections.push(
`## Last k Messages\n${formatConversationHistory(options.lastKMessages)}`,
);
// Recently Extracted Memories — empty for now
sections.push("## Recently Extracted Memories\n[]");
sections.push(
`## Existing Memories\n${serializeMemories(options.existingMemories)}`,
);
sections.push(`## New Messages\n${options.newMessages ?? "[]"}`);
sections.push(`## Observation Date\n${observationDate}`);
sections.push(`## Current Date\n${currentDate}`);
if (options.customInstructions) {
sections.push(`## Custom Instructions\n${options.customInstructions}`);
}
sections.push("# Output:");
return sections.join("\n\n");
}
// ---------------------------------------------------------------------------
// Legacy helpers (kept for backward compatibility)
// ---------------------------------------------------------------------------
export function parseMessages(messages: string[]): string {
return messages.join("\n");
}
@@ -1,4 +1,5 @@
import Database from "better-sqlite3";
import { randomUUID } from "crypto";
import { HistoryManager } from "./base";
import { ensureSQLiteDirectory } from "../utils/sqlite";
@@ -26,6 +27,16 @@ export class SQLiteManager implements HistoryManager {
is_deleted INTEGER DEFAULT 0
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_scope TEXT,
role TEXT,
content TEXT,
name TEXT,
created_at TEXT
)
`);
this.stmtInsert = this.db.prepare(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
@@ -60,8 +71,103 @@ export class SQLiteManager implements HistoryManager {
return this.stmtSelect.all(memoryId) as any[];
}
async saveMessages(
messages: Array<{ role: string; content: string; name?: string }>,
sessionScope: string,
): Promise<void> {
if (!messages.length) return;
const insertMsg = this.db.prepare(
`INSERT INTO messages (id, session_scope, role, content, name, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
);
const evict = this.db.prepare(
`DELETE FROM messages WHERE session_scope = ? AND id NOT IN (
SELECT id FROM (
SELECT id FROM messages WHERE session_scope = ? ORDER BY created_at DESC LIMIT 10
)
)`,
);
const txn = this.db.transaction(() => {
const now = new Date().toISOString();
for (const msg of messages) {
insertMsg.run(
randomUUID(),
sessionScope,
msg.role,
msg.content,
msg.name ?? null,
now,
);
}
evict.run(sessionScope, sessionScope);
});
txn();
}
async getLastMessages(
sessionScope: string,
limit = 10,
): Promise<
Array<{ role: string; content: string; name?: string; createdAt: string }>
> {
const rows = this.db
.prepare(
`SELECT role, content, name, created_at FROM (
SELECT role, content, name, created_at
FROM messages
WHERE session_scope = ?
ORDER BY created_at DESC
LIMIT ?
) ORDER BY created_at ASC`,
)
.all(sessionScope, limit) as Array<{
role: string;
content: string;
name: string | null;
created_at: string;
}>;
return rows.map((r) => ({
role: r.role,
content: r.content,
...(r.name != null ? { name: r.name } : {}),
createdAt: r.created_at,
}));
}
async batchAddHistory(
records: Array<{
memoryId: string;
previousValue: string | null;
newValue: string | null;
action: string;
createdAt?: string;
updatedAt?: string;
isDeleted?: number;
}>,
): Promise<void> {
const txn = this.db.transaction(() => {
for (const record of records) {
this.stmtInsert.run(
record.memoryId,
record.previousValue,
record.newValue,
record.action,
record.createdAt ?? null,
record.updatedAt ?? null,
record.isDeleted ?? 0,
);
}
});
txn();
}
async reset(): Promise<void> {
this.db.exec("DROP TABLE IF EXISTS memory_history");
this.db.exec("DROP TABLE IF EXISTS messages");
this.init();
}
+23
View File
@@ -11,4 +11,27 @@ export interface HistoryManager {
getHistory(memoryId: string): Promise<any[]>;
reset(): Promise<void>;
close(): void;
// V3 optional methods — implementations that don't need them can omit these.
saveMessages?(
messages: Array<{ role: string; content: string; name?: string }>,
sessionScope: string,
): Promise<void>;
getLastMessages?(
sessionScope: string,
limit?: number,
): Promise<
Array<{ role: string; content: string; name?: string; createdAt: string }>
>;
batchAddHistory?(
records: Array<{
memoryId: string;
previousValue: string | null;
newValue: string | null;
action: string;
createdAt?: string;
updatedAt?: string;
isDeleted?: number;
}>,
): Promise<void>;
}
@@ -38,7 +38,6 @@ describe("backward compat: ConfigManager.mergeConfig", () => {
expect(cfg.historyStore!.provider).toBe("sqlite");
expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db");
expect(cfg.disableHistory).toBe(false);
expect(cfg.graphStore).toBeUndefined();
});
it("workaround: explicit historyStore still works (existing user pattern)", () => {
@@ -104,20 +103,6 @@ describe("backward compat: ConfigManager.mergeConfig", () => {
expect(cfg.vectorStore.config.dimension).toBe(768);
});
it("graphStore config passes through unchanged", () => {
const cfg = ConfigManager.mergeConfig({
graphStore: {
provider: "neo4j",
config: {
url: "neo4j://custom:7687",
username: "admin",
password: "pass",
},
},
});
expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687");
});
it("customInstructions passes through unchanged", () => {
const cfg = ConfigManager.mergeConfig({
customInstructions: "You are a helpful assistant",
+3 -35
View File
@@ -50,19 +50,6 @@ export interface LLMConfig {
modelProperties?: Record<string, any>;
}
export interface Neo4jConfig {
url: string;
username: string;
password: string;
}
export interface GraphStoreConfig {
provider: string;
config: Neo4jConfig;
llm?: LLMConfig;
customInstructions?: string;
}
export interface MemoryConfig {
version?: string;
embedder: {
@@ -81,7 +68,6 @@ export interface MemoryConfig {
disableHistory?: boolean;
historyDbPath?: string;
customInstructions?: string;
graphStore?: GraphStoreConfig;
}
export interface MemoryItem {
@@ -95,15 +81,14 @@ export interface MemoryItem {
}
export interface SearchFilters {
userId?: string;
agentId?: string;
runId?: string;
user_id?: string;
agent_id?: string;
run_id?: string;
[key: string]: any;
}
export interface SearchResult {
results: MemoryItem[];
relations?: any[];
}
export interface VectorStoreResult {
@@ -148,23 +133,6 @@ export const MemoryConfigSchema = z.object({
}),
historyDbPath: z.string().optional(),
customInstructions: z.string().optional(),
graphStore: z
.object({
provider: z.string(),
config: z.object({
url: z.string(),
username: z.string(),
password: z.string(),
}),
llm: z
.object({
provider: z.string(),
config: z.record(z.string(), z.any()),
})
.optional(),
customInstructions: z.string().optional(),
})
.optional(),
historyStore: z
.object({
provider: z.string(),
@@ -0,0 +1,720 @@
/**
* Entity extraction from text using NLP and regex heuristics.
*
* Extracts four types of entities from text:
* - PROPER: Capitalized multi-word sequences (person names, places, brands)
* - QUOTED: Text in single or double quotes (titles, specific terms)
* - COMPOUND: Multi-word noun phrases with specific modifiers (e.g., "machine learning")
* - NOUN: Single nouns from circumstantial compound patterns
*
* Uses the `compromise` npm package for NLP-based extraction when available.
* Falls back to regex-only extraction if `compromise` is not installed.
*/
// ---------------------------------------------------------------------------
// Filter lists (ported from Python)
// ---------------------------------------------------------------------------
/** Words that are too generic to be useful as entity heads. */
const GENERIC_HEADS: Set<string> = new Set([
"thing",
"stuff",
"way",
"time",
"experience",
"situation",
"case",
"fact",
"matter",
"issue",
"idea",
"thought",
"feeling",
"place",
"area",
"part",
"kind",
"type",
"sort",
"lot",
"bit",
"day",
"year",
"week",
"month",
"moment",
"instance",
"example",
"technique",
"method",
"approach",
"process",
"step",
"tool",
"result",
"outcome",
"goal",
"task",
"item",
"topic",
"scale",
"size",
"level",
"degree",
"amount",
"number",
"style",
"look",
"color",
"colour",
"shape",
"form",
"piece",
"section",
"side",
"end",
"edge",
"surface",
"point",
]);
/** Adjectives too vague to make a compound entity specific. */
const NON_SPECIFIC_ADJ: Set<string> = new Set([
"many",
"few",
"several",
"some",
"any",
"all",
"most",
"more",
"less",
"much",
"little",
"enough",
"various",
"numerous",
"multiple",
"countless",
"great",
"good",
"bad",
"nice",
"terrible",
"awful",
"awesome",
"amazing",
"wonderful",
"horrible",
"excellent",
"poor",
"best",
"worst",
"fine",
"okay",
"new",
"old",
"recent",
"past",
"future",
"current",
"previous",
"next",
"last",
"first",
"latest",
"early",
"late",
"former",
"modern",
"ancient",
"big",
"small",
"large",
"tiny",
"huge",
"enormous",
"long",
"short",
"tall",
"high",
"low",
"wide",
"narrow",
"thick",
"thin",
"deep",
"shallow",
"similar",
"different",
"same",
"other",
"another",
"such",
"certain",
"important",
"main",
"major",
"minor",
"key",
"primary",
"real",
"actual",
"true",
"whole",
"entire",
"full",
"complete",
"total",
"basic",
"simple",
"interesting",
"boring",
"exciting",
"special",
"particular",
"general",
"common",
"unique",
"rare",
"typical",
"usual",
"normal",
"regular",
"possible",
"likely",
"potential",
"available",
"necessary",
"only",
"solo",
"individual",
"team",
"group",
"joint",
"collaborative",
"final",
"initial",
"side",
]);
/** Generic tail words to strip from compound entities. */
const GENERIC_ENDINGS: Set<string> = new Set([
"work",
"works",
"job",
"jobs",
"task",
"tasks",
"stuff",
"things",
"thing",
"info",
"information",
"details",
"data",
"content",
"material",
"materials",
"activities",
"activity",
"efforts",
"effort",
"options",
"option",
"choices",
"choice",
"results",
"result",
"output",
"outputs",
"products",
"product",
"items",
"item",
]);
/** Capitalized single words that are too generic to be proper nouns. */
const GENERIC_CAPS: Set<string> = new Set([
"works",
"items",
"things",
"stuff",
"resources",
"options",
"tips",
"ideas",
"steps",
"ways",
"methods",
"tools",
"features",
"benefits",
"examples",
"details",
"notes",
"instructions",
"guidelines",
"recommendations",
"suggestions",
"overview",
"summary",
"conclusion",
"introduction",
"pros",
"cons",
"advantages",
"disadvantages",
]);
/** Markdown/formatting markers to skip during extraction. */
const FORMATTING_MARKERS: Set<string> = new Set([
"*",
"-",
"+",
"\u2022",
"\u2013",
"\u2014",
"#",
"##",
"###",
"**",
"__",
]);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ExtractedEntity {
type: "PROPER" | "QUOTED" | "COMPOUND" | "NOUN";
text: string;
}
// ---------------------------------------------------------------------------
// compromise dynamic import
// ---------------------------------------------------------------------------
let nlp: any;
try {
nlp = require("compromise");
} catch {
// compromise not installed -- use regex-only fallback
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Check for formatting artifacts that indicate non-entity text. */
function hasArtifacts(txt: string): boolean {
if (txt.includes("**") || txt.includes("__") || txt.includes(":*")) {
return true;
}
if (/\s\*\s|\s\*$|^\*\s/.test(txt)) {
return true;
}
if (txt.includes(" ") || txt.includes("\n") || txt.includes("\t")) {
return true;
}
if (txt.length > 100) {
return true;
}
if (/^[\u2022\-+\u2013\u2014]/.test(txt)) {
return true;
}
return false;
}
/** Strip generic trailing words from a phrase's word list. */
function stripGenericEnding(words: string[]): string[] {
if (words.length <= 1) {
return words;
}
const last = words[words.length - 1].toLowerCase();
if (GENERIC_ENDINGS.has(last) && words.length > 2) {
return words.slice(0, -1);
}
return words;
}
/**
* Determine if a token position is at the start of a sentence.
* Simple heuristic: index 0, or preceded by sentence-ending punctuation
* or formatting markers.
*/
function isSentenceStart(
tokens: string[],
idx: number,
rawText: string,
): boolean {
if (idx === 0) {
return true;
}
const prev = tokens[idx - 1];
if (/[.!?:]$/.test(prev)) {
return true;
}
if (FORMATTING_MARKERS.has(prev)) {
return true;
}
// Check for newline before this token in the raw text
const tokenStart = rawText.indexOf(tokens[idx]);
if (tokenStart > 0 && rawText.charAt(tokenStart - 1) === "\n") {
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Extraction strategies
// ---------------------------------------------------------------------------
/** Extract quoted entities via regex. */
function extractQuoted(text: string): ExtractedEntity[] {
const entities: ExtractedEntity[] = [];
// Double-quoted
const doubleQuoteRe = /"([^"]+)"/g;
let match: RegExpExecArray | null;
while ((match = doubleQuoteRe.exec(text)) !== null) {
const inner = match[1].trim();
if (inner.length > 2) {
entities.push({ type: "QUOTED", text: inner });
}
}
// Single-quoted (with boundary constraints to avoid apostrophes)
const singleQuoteRe = /(?:^|[\s([{,;])'([^']+)'(?=[\s.,;:!?)\]]|$)/g;
while ((match = singleQuoteRe.exec(text)) !== null) {
const inner = match[1].trim();
if (inner.length > 2) {
entities.push({ type: "QUOTED", text: inner });
}
}
return entities;
}
/**
* Extract proper noun sequences using capitalization heuristics.
* Finds sequences of capitalized words that are not at sentence starts.
*/
function extractProper(text: string): ExtractedEntity[] {
const entities: ExtractedEntity[] = [];
// Tokenize on whitespace, preserving order
const tokens = text.split(/\s+/).filter(Boolean);
const functionWords = new Set([
"'s",
"of",
"the",
"in",
"and",
"for",
"at",
"is",
]);
let i = 0;
while (i < tokens.length) {
const tok = tokens[i];
// Skip formatting markers
if (FORMATTING_MARKERS.has(tok)) {
i++;
continue;
}
const isLabel = i + 1 < tokens.length && tokens[i + 1] === ":";
const isCap =
tok.length > 0 &&
tok.charAt(0) === tok.charAt(0).toUpperCase() &&
/[A-Z]/.test(tok.charAt(0));
if (isCap && !isLabel) {
const seq: Array<{ token: string; idx: number }> = [
{ token: tok, idx: i },
];
let j = i + 1;
while (j < tokens.length) {
const t = tokens[j];
const tIsCap =
t.length > 0 &&
t.charAt(0) === t.charAt(0).toUpperCase() &&
/[A-Z]/.test(t.charAt(0));
if (tIsCap || functionWords.has(t.toLowerCase())) {
seq.push({ token: t, idx: j });
j++;
} else {
break;
}
}
// Strip trailing function words
while (
seq.length > 0 &&
functionWords.has(seq[seq.length - 1].token.toLowerCase())
) {
seq.pop();
}
if (seq.length > 0) {
// Check for at least one mid-sentence capitalized word
const hasMidCap = seq.some(({ token, idx: tokenIdx }) => {
const isCapWord =
/[A-Z]/.test(token.charAt(0)) &&
!functionWords.has(token.toLowerCase());
return isCapWord && !isSentenceStart(tokens, tokenIdx, text);
});
if (hasMidCap) {
const phrase = seq.map((s) => s.token).join(" ");
if (phrase.length > 2) {
entities.push({ type: "PROPER", text: phrase });
}
}
}
i = j;
} else {
i++;
}
}
return entities;
}
/**
* Extract compound noun phrases using the `compromise` NLP library.
* Returns COMPOUND and NOUN entities derived from noun chunks.
*/
function extractCompoundsWithNlp(text: string): ExtractedEntity[] {
if (!nlp) {
return [];
}
const entities: ExtractedEntity[] = [];
const doc = nlp(text);
const nouns = doc.nouns().out("array") as string[];
for (const nounPhrase of nouns) {
const trimmed = nounPhrase.trim();
if (!trimmed || trimmed.length <= 3) {
continue;
}
const words = trimmed.split(/\s+/);
if (words.length < 2) {
continue;
}
// Filter out phrases where the head is generic
const head = words[words.length - 1].toLowerCase();
if (GENERIC_HEADS.has(head)) {
// Check if there's a specific modifier
const hasSpecificMod = words.some(
(w) =>
!NON_SPECIFIC_ADJ.has(w.toLowerCase()) &&
w !== words[words.length - 1],
);
if (!hasSpecificMod) {
continue;
}
}
// Filter non-specific adjectives from the beginning
const filtered = words.filter(
(w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()),
);
const cleaned = stripGenericEnding(filtered);
if (cleaned.length >= 2) {
const phrase = cleaned.join(" ");
if (phrase.length > 3) {
entities.push({ type: "COMPOUND", text: phrase });
}
}
}
return entities;
}
/**
* Regex-only fallback for compound extraction when compromise is not available.
* Finds multi-word capitalized sequences and common compound patterns.
*/
function extractCompoundsRegex(text: string): ExtractedEntity[] {
const entities: ExtractedEntity[] = [];
// Multi-word sequences with at least one non-trivial word
// Match sequences like "machine learning", "New York", "data science"
const compoundRe =
/\b([A-Z][a-z]+(?:\s+(?:of|and|the|for|in)\s+)?[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g;
let match: RegExpExecArray | null;
while ((match = compoundRe.exec(text)) !== null) {
const phrase = match[1].trim();
if (phrase.length > 3 && phrase.includes(" ")) {
const words = phrase.split(/\s+/);
const head = words[words.length - 1].toLowerCase();
if (!GENERIC_HEADS.has(head)) {
const filtered = words.filter(
(w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()),
);
const cleaned = stripGenericEnding(filtered);
if (cleaned.length >= 2) {
entities.push({ type: "COMPOUND", text: cleaned.join(" ") });
}
}
}
}
// Also try lowercase compound patterns (e.g., "machine learning", "deep learning")
const lowerCompoundRe = /\b([a-z]+(?:\s+[a-z]+){1,3})\b/g;
while ((match = lowerCompoundRe.exec(text)) !== null) {
const phrase = match[1].trim();
const words = phrase.split(/\s+/);
if (words.length >= 2 && words.length <= 4 && phrase.length > 5) {
const head = words[words.length - 1].toLowerCase();
const allGeneric = words.every(
(w) =>
NON_SPECIFIC_ADJ.has(w.toLowerCase()) ||
GENERIC_HEADS.has(w.toLowerCase()),
);
if (!allGeneric && !GENERIC_HEADS.has(head)) {
// Only include if it looks like a meaningful compound
const hasContentWord = words.some(
(w) =>
!NON_SPECIFIC_ADJ.has(w.toLowerCase()) &&
!GENERIC_HEADS.has(w.toLowerCase()) &&
w.length > 2,
);
if (hasContentWord) {
const filtered = words.filter(
(w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()),
);
const cleaned = stripGenericEnding(filtered);
if (cleaned.length >= 2) {
entities.push({ type: "COMPOUND", text: cleaned.join(" ") });
}
}
}
}
}
return entities;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Extract named entities, quoted text, and noun compounds from text.
*
* Uses `compromise` for NLP-based noun phrase extraction when available,
* falling back to regex-only heuristics otherwise.
*
* Entity types (in priority order for deduplication):
* PROPER - Capitalized multi-word sequences not at sentence start
* COMPOUND - Multi-word noun phrases with specific modifiers
* QUOTED - Text in single or double quotes (min 3 chars)
* NOUN - Single nouns from circumstantial patterns
*
* @param text - Input text to extract entities from.
* @returns Deduplicated list of extracted entities.
*/
export function extractEntities(text: string): ExtractedEntity[] {
const raw: ExtractedEntity[] = [];
// 1. QUOTED entities (always regex)
raw.push(...extractQuoted(text));
// 2. PROPER entities (capitalization heuristics)
raw.push(...extractProper(text));
// 3. COMPOUND entities (NLP or regex fallback)
if (nlp) {
raw.push(...extractCompoundsWithNlp(text));
} else {
raw.push(...extractCompoundsRegex(text));
}
// === DEDUPLICATION & CLEANUP ===
// First pass: deduplicate by lowercase text
const seen = new Set<string>();
const deduped: ExtractedEntity[] = [];
for (const entity of raw) {
const key = entity.text.toLowerCase().trim();
if (key.length > 2 && !seen.has(key)) {
seen.add(key);
deduped.push(entity);
}
}
// Clean up formatting artifacts
const cleaned: ExtractedEntity[] = [];
for (const entity of deduped) {
let txt = entity.text.trim();
// Strip leading/trailing asterisks
txt = txt.replace(/^\*+\s*|\s*\*+$/g, "");
// Strip trailing colons
txt = txt.replace(/\s*:+$/, "");
// Strip leading numbered list markers
txt = txt.replace(/^\d+\s*\.\s*/, "");
if (!txt || txt.length <= 2 || hasArtifacts(txt)) {
continue;
}
// Filter generic single-word PROPER nouns
if (
entity.type === "PROPER" &&
!txt.includes(" ") &&
GENERIC_CAPS.has(txt.toLowerCase())
) {
continue;
}
cleaned.push({ type: entity.type, text: txt });
}
// Keep best type per entity (PROPER > COMPOUND > QUOTED > NOUN)
const typePriority: Record<string, number> = {
PROPER: 0,
COMPOUND: 1,
QUOTED: 2,
NOUN: 3,
};
const best = new Map<string, ExtractedEntity>();
for (const entity of cleaned) {
const key = entity.text.toLowerCase();
const existing = best.get(key);
if (
!existing ||
(typePriority[entity.type] ?? 99) < (typePriority[existing.type] ?? 99)
) {
best.set(key, entity);
}
}
const bestEntities = Array.from(best.values());
// Remove entities that are substrings of longer entities
const allLower = bestEntities.map((e) => e.text.toLowerCase());
return bestEntities.filter(
(entity) =>
!allLower.some(
(other) =>
entity.text.toLowerCase() !== other &&
other.includes(entity.text.toLowerCase()),
),
);
}
/**
* Extract entities from multiple texts.
*
* @param texts - List of input texts to extract entities from.
* @returns List of entity lists, one per input text.
*/
export function extractEntitiesBatch(texts: string[]): ExtractedEntity[][] {
return texts.map(extractEntities);
}
+1 -1
View File
@@ -21,6 +21,7 @@ import { VectorizeDB } from "../vector_stores/vectorize";
import { RedisDB } from "../vector_stores/redis";
import { OllamaLLM } from "../llms/ollama";
import { LMStudioLLM } from "../llms/lmstudio";
import { DeepSeekLLM } from "../llms/deepseek";
import { SupabaseDB } from "../vector_stores/supabase";
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
@@ -35,7 +36,6 @@ import { LangchainEmbedder } from "../embeddings/langchain";
import { LangchainVectorStore } from "../vector_stores/langchain";
import { AzureAISearch } from "../vector_stores/azure_ai_search";
import { PGVector } from "../vector_stores/pgvector";
import { DeepSeekLLM } from "../llms/deepseek";
export class EmbedderFactory {
static create(provider: string, config: EmbeddingConfig): Embedder {
+278
View File
@@ -0,0 +1,278 @@
/**
* BM25 lemmatization for consistent keyword matching.
*
* Uses the `natural` npm package for Porter stemming when available.
* Falls back to simple lowercasing + stop word removal if `natural`
* is not installed.
*
* Also includes original -ing forms alongside stems to handle cases
* where stemming produces inconsistent results (e.g., "meeting" as
* noun vs verb -> different stems).
*/
/** Standard English stop words (based on NLTK stop word list). */
const STOP_WORDS: Set<string> = new Set([
"a",
"about",
"above",
"after",
"again",
"against",
"all",
"am",
"an",
"and",
"any",
"are",
"aren't",
"as",
"at",
"be",
"because",
"been",
"before",
"being",
"below",
"between",
"both",
"but",
"by",
"can",
"can't",
"cannot",
"could",
"couldn't",
"did",
"didn't",
"do",
"does",
"doesn't",
"doing",
"don't",
"down",
"during",
"each",
"few",
"for",
"from",
"further",
"get",
"got",
"had",
"hadn't",
"has",
"hasn't",
"have",
"haven't",
"having",
"he",
"her",
"here",
"hers",
"herself",
"him",
"himself",
"his",
"how",
"i",
"if",
"in",
"into",
"is",
"isn't",
"it",
"it's",
"its",
"itself",
"just",
"let's",
"me",
"might",
"more",
"most",
"mustn't",
"must",
"my",
"myself",
"no",
"nor",
"not",
"of",
"off",
"on",
"once",
"only",
"or",
"other",
"ought",
"our",
"ours",
"ourselves",
"out",
"over",
"own",
"same",
"shall",
"shan't",
"she",
"should",
"shouldn't",
"so",
"some",
"such",
"than",
"that",
"the",
"their",
"theirs",
"them",
"themselves",
"then",
"there",
"these",
"they",
"this",
"those",
"through",
"to",
"too",
"under",
"until",
"up",
"very",
"was",
"wasn't",
"we",
"were",
"weren't",
"what",
"when",
"where",
"which",
"while",
"who",
"whom",
"why",
"will",
"with",
"won't",
"would",
"wouldn't",
"you",
"your",
"yours",
"yourself",
"yourselves",
]);
/**
* Attempt to load the Porter stemmer from the `natural` package.
* Returns null if the package is not installed.
*/
let _porterStemmer: { stem: (word: string) => string } | null | undefined;
function getPorterStemmer(): { stem: (word: string) => string } | null {
if (_porterStemmer !== undefined) {
return _porterStemmer;
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const natural = require("natural");
_porterStemmer = natural.PorterStemmer;
return _porterStemmer!;
} catch {
_porterStemmer = null;
return null;
}
}
/**
* Simple built-in Porter-like stemmer for common English suffixes.
* Used only when the `natural` package is not available.
*/
function simpleStem(word: string): string {
if (word.length <= 3) {
return word;
}
// Step-like suffix stripping (simplified Porter rules)
let w = word;
if (w.endsWith("ies") && w.length > 4) {
w = w.slice(0, -3) + "i";
} else if (w.endsWith("sses")) {
w = w.slice(0, -2);
} else if (w.endsWith("ness")) {
w = w.slice(0, -4);
} else if (w.endsWith("ment") && w.length > 5) {
w = w.slice(0, -4);
} else if (w.endsWith("ation") && w.length > 6) {
w = w.slice(0, -5) + "e";
} else if (w.endsWith("ting") && w.length > 5) {
w = w.slice(0, -3);
} else if (w.endsWith("ing") && w.length > 5) {
w = w.slice(0, -3);
} else if (w.endsWith("ed") && w.length > 4) {
w = w.slice(0, -2);
} else if (w.endsWith("ly") && w.length > 4) {
w = w.slice(0, -2);
} else if (w.endsWith("er") && w.length > 4) {
w = w.slice(0, -2);
} else if (w.endsWith("est") && w.length > 4) {
w = w.slice(0, -3);
} else if (w.endsWith("s") && !w.endsWith("ss") && w.length > 3) {
w = w.slice(0, -1);
}
return w;
}
/**
* Lemmatize (stem) text for BM25 matching.
*
* Processing steps:
* 1. Lowercase the text.
* 2. Tokenize into words (alphanumeric sequences).
* 3. Remove stop words.
* 4. Apply Porter stemming to each word.
* 5. For words ending in -ing, keep both the stemmed and original form.
* 6. Return space-joined result.
*
* Falls back to simple suffix stripping if `natural` is not installed.
*
* @param text - Input text to lemmatize.
* @returns Space-joined lemmatized/stemmed tokens.
*/
export function lemmatizeForBm25(text: string): string {
const lower = text.toLowerCase();
const words = lower.match(/[a-z0-9]+/g);
if (!words) {
return text.toLowerCase();
}
const stemmer = getPorterStemmer();
const stemFn = stemmer
? (w: string) => stemmer.stem(w).toLowerCase()
: simpleStem;
const tokens: string[] = [];
for (const word of words) {
if (STOP_WORDS.has(word)) {
continue;
}
const stemmed = stemFn(word);
if (stemmed && /^[a-z0-9]+$/.test(stemmed)) {
tokens.push(stemmed);
}
// Also add original if it ends in -ing and differs from stem.
// This handles noun/verb ambiguity (meeting/meet, attending/attend).
if (word.endsWith("ing") && word !== stemmed && /^[a-z0-9]+$/.test(word)) {
tokens.push(word);
}
}
return tokens.join(" ");
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Scoring utilities for hybrid retrieval.
*
* Provides:
* - BM25 normalization: Sigmoid normalization of raw BM25 scores to [0, 1].
* - BM25 parameter selection: Query-length-adaptive sigmoid parameters.
* - Additive scoring: Combined scoring with semantic + BM25 + entity boost.
*/
export const ENTITY_BOOST_WEIGHT = 0.5;
/**
* Get BM25 sigmoid parameters based on query length.
*
* Longer queries tend to have higher raw BM25 scores, so we adjust
* the sigmoid midpoint and steepness accordingly.
*
* @param query - The original query string.
* @param lemmatized - Optional pre-lemmatized query string. If not provided,
* the term count is estimated from the raw query.
* @returns A tuple of [midpoint, steepness] for sigmoid normalization.
*/
export function getBm25Params(
query: string,
lemmatized?: string,
): [number, number] {
const text = lemmatized ?? query;
const numTerms = text.trim().split(/\s+/).filter(Boolean).length || 1;
if (numTerms <= 3) {
return [5.0, 0.7];
} else if (numTerms <= 6) {
return [7.0, 0.6];
} else if (numTerms <= 9) {
return [9.0, 0.5];
} else if (numTerms <= 15) {
return [10.0, 0.5];
} else {
return [12.0, 0.5];
}
}
/**
* Normalize a raw BM25 score to [0, 1] using logistic sigmoid.
*
* @param rawScore - Raw BM25 score (unbounded, typically 0-20+).
* @param midpoint - Score at which sigmoid outputs 0.5.
* @param steepness - Controls how quickly sigmoid transitions.
* @returns Normalized score in range [0, 1].
*/
export function normalizeBm25(
rawScore: number,
midpoint: number,
steepness: number,
): number {
return 1.0 / (1.0 + Math.exp(-steepness * (rawScore - midpoint)));
}
export interface ScoredResult {
id: string;
score: number;
scoreBreakdown: {
semantic: number;
bm25: number;
entityBoost: number;
};
payload: Record<string, any>;
}
/**
* Score candidates additively and return top-k results.
*
* For each candidate:
* combined = (semantic + bm25 + entity_boost) / max_possible
*
* Threshold gates the semantic score BEFORE combining -- candidates
* below the threshold are excluded even if BM25/entity would boost them.
*
* The divisor adapts based on which signals are active:
* - Semantic only: max_possible = 1.0
* - Semantic + BM25: max_possible = 2.0
* - Semantic + BM25 + entity: max_possible = 2.5
* - Semantic + entity (no BM25): max_possible = 1.5
*
* @param semanticResults - Candidate results with id, score, and payload.
* @param bm25Scores - Map of memory ID to normalized BM25 score.
* @param entityBoosts - Map of memory ID to entity boost score.
* @param threshold - Minimum semantic score to include a candidate.
* @param topK - Maximum number of results to return.
* @returns Sorted list of scored results, highest score first.
*/
export function scoreAndRank(
semanticResults: Array<{
id: string;
score: number;
payload: Record<string, any>;
}>,
bm25Scores: Record<string, number>,
entityBoosts: Record<string, number>,
threshold: number,
topK: number,
): ScoredResult[] {
const hasBm25 = Object.keys(bm25Scores).length > 0;
const hasEntity = Object.keys(entityBoosts).length > 0;
let maxPossible = 1.0;
if (hasBm25) {
maxPossible += 1.0;
}
if (hasEntity) {
maxPossible += ENTITY_BOOST_WEIGHT;
}
const scored: ScoredResult[] = [];
for (const result of semanticResults) {
const memId = result.id;
if (memId == null) {
continue;
}
const semanticScore = result.score ?? 0.0;
if (semanticScore < threshold) {
continue;
}
const memIdStr = String(memId);
const bm25Score = bm25Scores[memIdStr] ?? 0.0;
const entityBoost = entityBoosts[memIdStr] ?? 0.0;
const rawCombined = semanticScore + bm25Score + entityBoost;
const combined = Math.min(rawCombined / maxPossible, 1.0);
scored.push({
id: memIdStr,
score: combined,
scoreBreakdown: {
semantic: semanticScore,
bm25: bm25Score,
entityBoost: entityBoost,
},
payload: result.payload,
});
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, topK);
}
@@ -326,6 +326,45 @@ export class AzureAISearch implements VectorStore {
}
}
/**
* Keyword search using Azure AI Search native full-text (BM25) capabilities
*/
async keywordSearch(
query: string,
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[] | null> {
try {
const filterExpression = filters
? this.buildFilterExpression(filters)
: undefined;
const searchResults = await this.searchClient.search(query, {
filter: filterExpression,
top: topK,
searchFields: ["payload"],
});
const results: VectorStoreResult[] = [];
for await (const result of searchResults.results) {
const payloadStr = result.document.payload as string;
const payload = JSON.parse(this.extractJson(payloadStr));
results.push({
id: result.document.id as string,
score: result.score,
payload,
});
}
return results;
} catch (error) {
console.error("Error during keyword search:", error);
return null;
}
}
/**
* Search for similar vectors
*/
@@ -11,6 +11,11 @@ export interface VectorStore {
topK?: number,
filters?: SearchFilters,
): Promise<VectorStoreResult[]>;
keywordSearch?(
query: string,
topK?: number,
filters?: SearchFilters,
): Promise<VectorStoreResult[] | null>;
get(vectorId: string): Promise<VectorStoreResult | null>;
update(
vectorId: string,
@@ -99,6 +99,10 @@ export class LangchainVectorStore implements VectorStore {
}
}
async keywordSearch(): Promise<null> {
return null;
}
async search(
query: number[],
topK: number = 5,
+237 -4
View File
@@ -67,11 +67,139 @@ export class MemoryVectorStore implements VectorStore {
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Check if a single field condition matches the payload.
* Supports comparison operators: eq, ne, gt, gte, lt, lte, in, nin, contains, icontains
*/
private matchFieldCondition(
payload: Record<string, any>,
key: string,
value: any,
): boolean {
const payloadValue = payload[key];
// Handle non-dict values
if (typeof value !== "object" || value === null) {
// Wildcard: match any value
if (value === "*") {
return true;
}
// Simple equality
return payloadValue === value;
}
// Handle array shorthand: {"field": ["a", "b"]} treated as "in" operator
if (Array.isArray(value)) {
return value.includes(payloadValue);
}
// Handle comparison operators
if ("eq" in value) {
return payloadValue === value.eq;
}
if ("ne" in value) {
return payloadValue !== value.ne;
}
if ("gt" in value) {
return payloadValue > value.gt;
}
if ("gte" in value) {
return payloadValue >= value.gte;
}
if ("lt" in value) {
return payloadValue < value.lt;
}
if ("lte" in value) {
return payloadValue <= value.lte;
}
if ("in" in value) {
return Array.isArray(value.in) && value.in.includes(payloadValue);
}
if ("nin" in value) {
return !Array.isArray(value.nin) || !value.nin.includes(payloadValue);
}
if ("contains" in value) {
return (
typeof payloadValue === "string" &&
payloadValue.includes(value.contains)
);
}
if ("icontains" in value) {
return (
typeof payloadValue === "string" &&
payloadValue.toLowerCase().includes(value.icontains.toLowerCase())
);
}
// Unknown operator - treat as nested object for equality (shouldn't happen normally)
return payloadValue === value;
}
/**
* Filter a vector by the given filters.
* Supports logical operators (AND, OR, NOT) and comparison operators.
*/
private filterVector(vector: MemoryVector, filters?: SearchFilters): boolean {
if (!filters) return true;
return Object.entries(filters).every(
([key, value]) => vector.payload[key] === value,
);
if (!filters || Object.keys(filters).length === 0) return true;
// Normalize $or/$not/$and → OR/NOT/AND
const keyMap: Record<string, string> = {
$and: "AND",
$or: "OR",
$not: "NOT",
};
const normalized: Record<string, any> = {};
for (const [key, value] of Object.entries(filters)) {
const normKey = keyMap[key] || key;
if (!(normKey in normalized)) {
normalized[normKey] = value;
}
}
for (const [key, value] of Object.entries(normalized)) {
// Handle logical operators
if (key === "AND") {
if (!Array.isArray(value)) {
throw new Error(
`AND filter value must be a list of filter dicts, got ${typeof value}`,
);
}
// All conditions must match
const allMatch = value.every((sub: SearchFilters) =>
this.filterVector(vector, sub),
);
if (!allMatch) return false;
} else if (key === "OR") {
if (!Array.isArray(value)) {
throw new Error(
`OR filter value must be a list of filter dicts, got ${typeof value}`,
);
}
// At least one condition must match
const anyMatch = value.some((sub: SearchFilters) =>
this.filterVector(vector, sub),
);
if (!anyMatch) return false;
} else if (key === "NOT") {
if (!Array.isArray(value)) {
throw new Error(
`NOT filter value must be a list of filter dicts, got ${typeof value}`,
);
}
// None of the conditions should match
const noneMatch = value.every(
(sub: SearchFilters) => !this.filterVector(vector, sub),
);
if (!noneMatch) return false;
} else {
// Regular field condition
if (!this.matchFieldCondition(vector.payload, key, value)) {
return false;
}
}
}
return true;
}
async insert(
@@ -98,6 +226,111 @@ export class MemoryVectorStore implements VectorStore {
insertMany(vectors, ids, payloads);
}
private tokenize(text: string): string[] {
return text.toLowerCase().split(/\s+/).filter(Boolean);
}
async keywordSearch(
query: string,
topK: number = 10,
filters?: SearchFilters,
): Promise<VectorStoreResult[] | null> {
try {
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
// Collect documents that pass the filter
const candidates: {
id: string;
payload: Record<string, any>;
tokens: string[];
}[] = [];
for (const row of rows) {
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(
new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
),
),
payload,
};
if (this.filterVector(memoryVector, filters)) {
const text = payload.text_lemmatized || payload.data || "";
candidates.push({ id: row.id, payload, tokens: this.tokenize(text) });
}
}
if (candidates.length === 0) {
return [];
}
const tokenizedQuery = this.tokenize(query);
if (tokenizedQuery.length === 0) {
return [];
}
// Compute BM25 scores inline
const k1 = 1.5;
const b = 0.75;
const N = candidates.length;
const avgDocLength =
candidates.reduce((sum, c) => sum + c.tokens.length, 0) / N;
// Compute document frequency for query terms
const docFreq = new Map<string, number>();
for (const term of tokenizedQuery) {
if (!docFreq.has(term)) {
let count = 0;
for (const c of candidates) {
if (c.tokens.includes(term)) count++;
}
docFreq.set(term, count);
}
}
// Compute IDF for query terms
const idf = new Map<string, number>();
for (const [term, freq] of docFreq) {
idf.set(term, Math.log((N - freq + 0.5) / (freq + 0.5) + 1));
}
// Score each candidate
const scored = candidates.map((candidate) => {
let score = 0;
const docLength = candidate.tokens.length;
for (const term of tokenizedQuery) {
const tf = candidate.tokens.filter((t) => t === term).length;
const termIdf = idf.get(term) || 0;
score +=
(termIdf * tf * (k1 + 1)) /
(tf + k1 * (1 - b + (b * docLength) / avgDocLength));
}
return { ...candidate, score };
});
// Filter out zero-score documents and sort descending
const results = scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((s) => ({
id: s.id,
payload: s.payload,
score: s.score,
}));
return results;
} catch (error) {
console.error("Error during keyword search:", error);
return null;
}
}
async search(
query: number[],
topK: number = 10,
@@ -162,6 +162,51 @@ export class PGVector implements VectorStore {
);
}
async keywordSearch(
query: string,
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[] | null> {
try {
const filterConditions: string[] = [];
const filterValues: any[] = [query, topK];
let filterIndex = 3;
if (filters) {
for (const [key, value] of Object.entries(filters)) {
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
filterValues.push(value);
filterIndex++;
}
}
const filterClause =
filterConditions.length > 0
? "AND " + filterConditions.join(" AND ")
: "";
const searchQuery = `
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', $1)) AS score, payload
FROM ${this.collectionName}
WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', $1)
${filterClause}
ORDER BY score DESC
LIMIT $2
`;
const result = await this.client.query(searchQuery, filterValues);
return result.rows.map((row) => ({
id: row.id,
payload: row.payload,
score: row.score,
}));
} catch (error) {
console.error("Error during keyword search:", error);
return null;
}
}
async search(
query: number[],
topK: number = 5,
+177 -29
View File
@@ -31,17 +31,29 @@ interface QdrantConfig extends VectorStoreConfig {
}
interface QdrantFilter {
must?: QdrantCondition[];
must_not?: QdrantCondition[];
should?: QdrantCondition[];
must?: (QdrantCondition | QdrantFilter)[];
must_not?: (QdrantCondition | QdrantFilter)[];
should?: (QdrantCondition | QdrantFilter)[];
}
interface QdrantCondition {
key: string;
match?: { value: any };
range?: { gte?: number; gt?: number; lte?: number; lt?: number };
match?: { value?: any; any?: any[]; except?: any[]; text?: string };
range?: {
gte?: number | string;
gt?: number | string;
lte?: number | string;
lt?: number | string;
};
}
// Normalize $and/$or/$not to AND/OR/NOT
const KEY_MAP: Record<string, string> = {
$and: "AND",
$or: "OR",
$not: "NOT",
};
export class Qdrant implements VectorStore {
private client: QdrantClient;
private readonly collectionName: string;
@@ -90,35 +102,167 @@ export class Qdrant implements VectorStore {
this.initialize().catch(console.error);
}
private createFilter(filters?: SearchFilters): QdrantFilter | undefined {
if (!filters) return undefined;
/**
* Build a single field condition from a key-value filter pair.
* Supports enhanced filter syntax with comparison operators.
*/
private buildFieldCondition(key: string, value: any): QdrantCondition | null {
// Handle non-dict values
if (typeof value !== "object" || value === null) {
// Wildcard: match any value - skip this filter
if (value === "*") {
return null;
}
// Simple equality
return { key, match: { value } };
}
const conditions: QdrantCondition[] = [];
// Handle array shorthand: {"field": ["a", "b"]} treated as "in" operator
if (Array.isArray(value)) {
return { key, match: { any: value } };
}
const ops = Object.keys(value);
const rangeOps = ["gt", "gte", "lt", "lte"];
const hasRangeOps = ops.some((op) => rangeOps.includes(op));
const nonRangeOps = ops.filter((op) => !rangeOps.includes(op));
// Handle range operators
if (hasRangeOps) {
if (nonRangeOps.length > 0) {
throw new Error(
`Cannot mix range operators (${ops.filter((o) => rangeOps.includes(o)).join(", ")}) ` +
`with non-range operators (${nonRangeOps.join(", ")}) for field '${key}'. ` +
`Use AND to combine them as separate conditions.`,
);
}
const range: Record<string, number | string> = {};
for (const op of rangeOps) {
if (op in value) {
range[op] = value[op];
}
}
return { key, range };
}
// Handle comparison operators
if ("eq" in value) {
return { key, match: { value: value.eq } };
}
if ("ne" in value) {
return { key, match: { except: [value.ne] } };
}
if ("in" in value) {
return { key, match: { any: value.in } };
}
if ("nin" in value) {
return { key, match: { except: value.nin } };
}
if ("contains" in value || "icontains" in value) {
const text = value.contains || value.icontains;
return { key, match: { text } };
}
// Unknown operator - treat as nested object for simple match
const supportedOps = [
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"in",
"nin",
"contains",
"icontains",
];
throw new Error(
`Unsupported filter operator(s) for field '${key}': ${ops.join(", ")}. ` +
`Supported operators: ${supportedOps.join(", ")}`,
);
}
/**
* Create a Filter object from the provided filters.
* Supports logical operators (AND, OR, NOT) and comparison operators.
*/
private createFilter(filters?: SearchFilters): QdrantFilter | undefined {
if (!filters || Object.keys(filters).length === 0) return undefined;
// Normalize $or/$not/$and → OR/NOT/AND and deduplicate
const normalized: Record<string, any> = {};
for (const [key, value] of Object.entries(filters)) {
if (
typeof value === "object" &&
value !== null &&
"gte" in value &&
"lte" in value
) {
conditions.push({
key,
range: {
gte: value.gte,
lte: value.lte,
},
});
} else {
conditions.push({
key,
match: {
value,
},
});
const normKey = KEY_MAP[key] || key;
if (!(normKey in normalized)) {
normalized[normKey] = value;
}
}
return conditions.length ? { must: conditions } : undefined;
const must: (QdrantCondition | QdrantFilter)[] = [];
const should: (QdrantCondition | QdrantFilter)[] = [];
const mustNot: (QdrantCondition | QdrantFilter)[] = [];
for (const [key, value] of Object.entries(normalized)) {
// Handle logical operators
if (key === "AND" || key === "OR" || key === "NOT") {
if (!Array.isArray(value)) {
throw new Error(
`${key} filter value must be a list of filter dicts, got ${typeof value}`,
);
}
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (
typeof item !== "object" ||
item === null ||
Array.isArray(item)
) {
throw new Error(
`${key} filter list item at index ${i} must be a dict, got ${typeof item}`,
);
}
}
if (key === "AND") {
for (const sub of value) {
const built = this.createFilter(sub);
if (built) {
must.push(built);
}
}
} else if (key === "OR") {
for (const sub of value) {
const built = this.createFilter(sub);
if (built) {
should.push(built);
}
}
} else if (key === "NOT") {
for (const sub of value) {
const built = this.createFilter(sub);
if (built) {
mustNot.push(built);
}
}
}
} else {
// Regular field condition
const condition = this.buildFieldCondition(key, value);
if (condition !== null) {
must.push(condition);
}
}
}
if (must.length === 0 && should.length === 0 && mustNot.length === 0) {
return undefined;
}
return {
must: must.length > 0 ? must : undefined,
should: should.length > 0 ? should : undefined,
must_not: mustNot.length > 0 ? mustNot : undefined,
};
}
async insert(
@@ -137,6 +281,10 @@ export class Qdrant implements VectorStore {
});
}
async keywordSearch(): Promise<null> {
return null;
}
async search(
query: number[],
topK: number = 5,
@@ -357,6 +357,10 @@ export class RedisDB implements VectorStore {
}
}
async keywordSearch(): Promise<null> {
return null;
}
async search(
query: number[],
topK: number = 5,
@@ -229,6 +229,10 @@ See the SQL migration instructions in the code comments.`,
}
}
async keywordSearch(): Promise<null> {
return null;
}
async search(
query: number[],
topK: number = 5,
@@ -74,6 +74,10 @@ export class VectorizeDB implements VectorStore {
}
}
async keywordSearch(): Promise<null> {
return null;
}
async search(
query: number[],
topK: number = 5,
+4 -76
View File
@@ -363,78 +363,6 @@ describe("ConfigManager", () => {
});
});
// ─────────────────────────────────────────────────────────────────────────
// Graph store LLM config propagation (issue #3425)
// ─────────────────────────────────────────────────────────────────────────
describe("mergeConfig - graph store LLM config (issue #3425)", () => {
const baseEmbedder = {
provider: "openai",
config: { apiKey: "test-key" },
};
const baseVectorStore = {
provider: "memory",
config: { collectionName: "test" },
};
const graphStoreNeo4j = {
provider: "neo4j",
config: {
url: "neo4j://localhost:7687",
username: "neo4j",
password: "password",
},
};
it("should NOT have a default graphStore.llm — root llm should be the fallback", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "anthropic",
config: { model: "claude-sonnet-4-20250514" },
},
graphStore: graphStoreNeo4j,
});
// graphStore should NOT have its own llm after merge
expect(config.graphStore?.llm).toBeUndefined();
// root llm should be anthropic
expect(config.llm.provider).toBe("anthropic");
expect(config.llm.config.model).toBe("claude-sonnet-4-20250514");
});
it("should preserve explicit graphStore.llm when user provides it", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "anthropic",
config: { model: "claude-sonnet-4-20250514" },
},
graphStore: {
...graphStoreNeo4j,
llm: { provider: "openai", config: { model: "gpt-4o" } },
},
});
// graphStore should have its own llm
expect(config.graphStore?.llm?.provider).toBe("openai");
expect(config.graphStore?.llm?.config).toEqual({ model: "gpt-4o" });
// root llm should still be anthropic
expect(config.llm.provider).toBe("anthropic");
});
it("should not have graphStore.llm when user does not provide one", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: { provider: "openai", config: { model: "gpt-4o" } },
});
// Default graphStore should not have llm
expect(config.graphStore?.llm).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────────
// Memory class – LM Studio end-to-end flow (mocked factories)
// ─────────────────────────────────────────────────────────────────────────
@@ -520,7 +448,7 @@ describe("Memory – LM Studio end-to-end flow", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
"lmstudio",
@@ -555,7 +483,7 @@ describe("Memory – LM Studio end-to-end flow", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
const vsCall = mockVectorStoreFactory.create.mock.calls[0];
@@ -583,7 +511,7 @@ describe("Memory – LM Studio end-to-end flow", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
"lmstudio",
@@ -636,7 +564,7 @@ describe("Memory – LM Studio end-to-end flow", () => {
});
const result = await mem.search("What does the user like?", {
userId: "u1",
filters: { user_id: "u1" },
});
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
@@ -313,7 +313,7 @@ describe("Memory – auto-initialization", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
// Should have called embed("dimension probe") to detect dimension
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
@@ -339,7 +339,7 @@ describe("Memory – auto-initialization", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
// embed should NOT have been called for probing
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
@@ -365,7 +365,7 @@ describe("Memory – auto-initialization", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
// ConfigManager resolves dimension from embeddingDims → no probe needed
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
@@ -403,9 +403,11 @@ describe("Memory – auto-initialization", () => {
let searchDone = false;
let getDone = false;
const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true));
const getAllP = mem
.getAll({ filters: { user_id: "u" } })
.then(() => (getAllDone = true));
const searchP = mem
.search("q", { userId: "u" })
.search("q", { filters: { user_id: "u" } })
.then(() => (searchDone = true));
const getP = mem.get("id").then(() => (getDone = true));
@@ -435,7 +437,7 @@ describe("Memory – auto-initialization", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
// Reset should re-create vector store
@@ -473,7 +475,7 @@ describe("Memory – auto-initialization", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
});
@@ -497,7 +499,7 @@ describe("Memory – auto-initialization", () => {
});
// getAll should reject with the init error
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
await expect(mem.getAll({ filters: { user_id: "u1" } })).rejects.toThrow(
"auto-detect embedding dimension",
);
@@ -1,681 +0,0 @@
/**
* Regression tests for graph_memory.ts response parsing (issue #4248).
*
* Exercises the three json_object call sites in MemoryGraph with a mocked LLM:
* 1. _retrieveNodesFromData → entity extraction
* 2. _establishNodesRelationsFromData → relation extraction
* 3. _getDeleteEntitiesFromSearchOutput → deletion identification
*
* Covers: malformed LLM responses, missing fields, bad JSON in toolCalls,
* string-only responses, empty tool calls, and prompt construction.
*
* See: https://github.com/mem0ai/mem0/issues/4248
*/
import { MemoryGraph } from "../src/memory/graph_memory";
import {
EXTRACT_RELATIONS_PROMPT,
getDeleteMessages,
} from "../src/graphs/utils";
// ---------------------------------------------------------------------------
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
// ---------------------------------------------------------------------------
// Mock neo4j-driver: provides a fake Driver with a no-op session
jest.mock("neo4j-driver", () => ({
__esModule: true,
default: {
driver: jest.fn(() => ({
session: () => ({
run: jest.fn().mockResolvedValue({ records: [] }),
close: jest.fn(),
}),
})),
auth: { basic: jest.fn() },
},
}));
// Mock factory so constructor doesn't try to instantiate real LLMs / embedders
const mockGenerateResponse = jest.fn();
const mockGenerateChat = jest.fn();
const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]);
jest.mock("../src/utils/factory", () => ({
LLMFactory: {
create: jest.fn(() => ({
generateResponse: mockGenerateResponse,
generateChat: mockGenerateChat,
})),
},
EmbedderFactory: {
create: jest.fn(() => ({
embed: mockEmbed,
})),
},
}));
// Minimal config that satisfies the MemoryGraph constructor
function makeConfig(overrides: Record<string, any> = {}) {
return {
graphStore: {
config: {
url: "bolt://localhost:7687",
username: "neo4j",
password: "test",
},
...overrides,
},
embedder: { provider: "openai", config: {} },
llm: { provider: "openai", config: {} },
} as any;
}
// Helper to access private methods via `any` cast
function graph(overrides: Record<string, any> = {}): any {
return new MemoryGraph(makeConfig(overrides));
}
const FILTERS = { userId: "test-user" };
beforeEach(() => {
jest.clearAllMocks();
});
// ═══════════════════════════════════════════════════════════════════════════
// 1. _retrieveNodesFromData – entity extraction
// ═══════════════════════════════════════════════════════════════════════════
describe("_retrieveNodesFromData", () => {
it("parses a well-formed extract_entities tool call", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "extract_entities",
arguments: JSON.stringify({
entities: [
{ entity: "Alice", entity_type: "person" },
{ entity: "Pizza", entity_type: "food" },
],
}),
},
],
});
const mg = graph();
const result = await mg._retrieveNodesFromData(
"Alice likes pizza",
FILTERS,
);
expect(result).toEqual({ alice: "person", pizza: "food" });
});
it("returns empty map when LLM returns a plain string", async () => {
mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object");
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("returns empty map when toolCalls is undefined", async () => {
mockGenerateResponse.mockResolvedValueOnce({});
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("returns empty map when toolCalls is an empty array", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("handles malformed JSON in tool call arguments gracefully", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{ name: "extract_entities", arguments: "NOT VALID JSON {{{" },
],
});
const mg = graph();
// Should not throw — the catch block in the source logs the error
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("handles missing entities array in arguments", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "extract_entities",
arguments: JSON.stringify({ wrong_key: [] }),
},
],
});
const mg = graph();
// args.entities is undefined → for..of on undefined throws → caught
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("skips tool calls with unrelated names", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "some_other_tool",
arguments: JSON.stringify({
entities: [{ entity: "X", entity_type: "Y" }],
}),
},
],
});
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({});
});
it("normalises entity names to lowercase with underscores", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "extract_entities",
arguments: JSON.stringify({
entities: [{ entity: "New York City", entity_type: "City Name" }],
}),
},
],
});
const mg = graph();
const result = await mg._retrieveNodesFromData("anything", FILTERS);
expect(result).toEqual({ new_york_city: "city_name" });
});
it("passes json_object response format and the correct system prompt", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._retrieveNodesFromData("test data", FILTERS);
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
expect(responseFormat).toEqual({ type: "json_object" });
const systemMsg = messages[0].content as string;
expect(systemMsg.toLowerCase()).toContain("json");
expect(systemMsg).toContain("test-user");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 2. _establishNodesRelationsFromData – relation extraction
// ═══════════════════════════════════════════════════════════════════════════
describe("_establishNodesRelationsFromData", () => {
it("parses a well-formed establish_relationships tool call", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "establish_relationships",
arguments: JSON.stringify({
entities: [
{ source: "Alice", relationship: "likes", destination: "Pizza" },
],
}),
},
],
});
const mg = graph();
const result = await mg._establishNodesRelationsFromData(
"Alice likes pizza",
FILTERS,
{ alice: "person", pizza: "food" },
);
expect(result).toEqual([
{ source: "alice", relationship: "likes", destination: "pizza" },
]);
});
it("returns empty array when LLM returns a string", async () => {
mockGenerateResponse.mockResolvedValueOnce("just a string");
const mg = graph();
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
expect(result).toEqual([]);
});
it("returns empty array when toolCalls is empty", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
expect(result).toEqual([]);
});
it("returns empty array when entities key is missing from arguments", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "establish_relationships",
arguments: JSON.stringify({ not_entities: [] }),
},
],
});
const mg = graph();
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
// args.entities is undefined → falls back to []
expect(result).toEqual([]);
});
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }],
});
const mg = graph();
// _establishNodesRelationsFromData does JSON.parse without try/catch
await expect(
mg._establishNodesRelationsFromData("x", FILTERS, {}),
).rejects.toThrow();
});
it("appends JSON format suffix to system prompt (no custom prompt)", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" });
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
expect(responseFormat).toEqual({ type: "json_object" });
const systemContent = messages[0].content as string;
expect(systemContent.toLowerCase()).toContain("json");
expect(systemContent).toContain("test-user");
expect(systemContent).not.toContain("USER_ID");
// CUSTOM_PROMPT placeholder stays when no custom prompt is configured
// (only replaced when config.graphStore.customInstructions is set)
});
it("appends JSON format suffix and custom prompt when configured", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph({
customInstructions: "Focus on food relationships only.",
});
await mg._establishNodesRelationsFromData("data", FILTERS, {});
const [messages] = mockGenerateResponse.mock.calls[0];
const systemContent = messages[0].content as string;
expect(systemContent.toLowerCase()).toContain("json");
expect(systemContent).toContain("Focus on food relationships only.");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 3. _getDeleteEntitiesFromSearchOutput – deletion identification
// ═══════════════════════════════════════════════════════════════════════════
describe("_getDeleteEntitiesFromSearchOutput", () => {
const SEARCH_OUTPUT = [
{
source: "alice",
source_id: "1",
relationship: "likes",
relation_id: "r1",
destination: "pizza",
destination_id: "2",
similarity: 0.95,
},
];
it("parses a well-formed delete_graph_memory tool call", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "delete_graph_memory",
arguments: JSON.stringify({
source: "Alice",
relationship: "likes",
destination: "Pizza",
}),
},
],
});
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"Alice hates pizza",
FILTERS,
);
expect(result).toEqual([
{ source: "alice", relationship: "likes", destination: "pizza" },
]);
});
it("returns empty array when LLM returns a string", async () => {
mockGenerateResponse.mockResolvedValueOnce("string response");
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]);
});
it("returns empty array when no tool calls are present", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]);
});
it("skips non-delete_graph_memory tool calls", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "noop",
arguments: JSON.stringify({}),
},
],
});
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]);
});
it("collects multiple delete tool calls", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "delete_graph_memory",
arguments: JSON.stringify({
source: "A",
relationship: "r1",
destination: "B",
}),
},
{
name: "delete_graph_memory",
arguments: JSON.stringify({
source: "C",
relationship: "r2",
destination: "D",
}),
},
],
});
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toHaveLength(2);
expect(result[0].source).toBe("a");
expect(result[1].source).toBe("c");
});
it("passes json_object format and includes 'json' in system prompt", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS);
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
expect(responseFormat).toEqual({ type: "json_object" });
const systemContent = messages[0].content as string;
expect(systemContent.toLowerCase()).toContain("json");
expect(systemContent).toContain("test-user");
expect(systemContent).not.toContain("USER_ID");
});
it("handles empty searchOutput array", async () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput(
[],
"data",
FILTERS,
);
expect(result).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 4. Prompt construction — JSON keyword present in every json_object site
// ═══════════════════════════════════════════════════════════════════════════
describe("Prompt construction — all json_object sites include 'json'", () => {
it("_retrieveNodesFromData system message includes 'json' for any userId", async () => {
for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._retrieveNodesFromData("test", { userId });
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
expect(systemMsg.toLowerCase()).toContain("json");
}
});
it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => {
for (const userId of ["", "user-1", "special<>chars"]) {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._establishNodesRelationsFromData("test", { userId }, {});
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
expect(systemMsg.toLowerCase()).toContain("json");
}
});
it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => {
for (const userId of ["", "user-1", "special<>chars"]) {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph();
await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId });
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
expect(systemMsg.toLowerCase()).toContain("json");
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
// ═══════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════
// 5a. LLM config propagation — graph store uses correct provider & config
// Regression test for https://github.com/mem0ai/mem0/issues/3425
// ═══════════════════════════════════════════════════════════════════════════
describe("LLM config propagation to graph store (issue #3425)", () => {
const { LLMFactory } = require("../src/utils/factory");
beforeEach(() => {
(LLMFactory.create as jest.Mock).mockClear();
});
it("uses root llm config when no graphStore.llm is provided", () => {
const config = {
graphStore: {
config: {
url: "bolt://localhost:7687",
username: "neo4j",
password: "test",
},
},
embedder: { provider: "openai", config: {} },
llm: {
provider: "anthropic",
config: { model: "claude-sonnet-4-20250514", apiKey: "sk-ant-test" },
},
} as any;
new MemoryGraph(config);
expect(LLMFactory.create).toHaveBeenCalledWith("anthropic", {
model: "claude-sonnet-4-20250514",
apiKey: "sk-ant-test",
});
// Both llm and structuredLlm should use the same config
expect(LLMFactory.create).toHaveBeenCalledTimes(2);
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "anthropic", {
model: "claude-sonnet-4-20250514",
apiKey: "sk-ant-test",
});
expect(LLMFactory.create).toHaveBeenNthCalledWith(2, "anthropic", {
model: "claude-sonnet-4-20250514",
apiKey: "sk-ant-test",
});
});
it("uses graphStore.llm config when provided, overriding root llm", () => {
const config = {
graphStore: {
config: {
url: "bolt://localhost:7687",
username: "neo4j",
password: "test",
},
llm: {
provider: "openai",
config: { model: "gpt-4o", apiKey: "sk-openai-test" },
},
},
embedder: { provider: "openai", config: {} },
llm: {
provider: "anthropic",
config: { model: "claude-sonnet-4-20250514", apiKey: "sk-ant-test" },
},
} as any;
new MemoryGraph(config);
// Should use graphStore.llm, NOT root llm
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", {
model: "gpt-4o",
apiKey: "sk-openai-test",
});
expect(LLMFactory.create).toHaveBeenNthCalledWith(2, "openai", {
model: "gpt-4o",
apiKey: "sk-openai-test",
});
});
it("falls back to root llm config when graphStore.llm.config is undefined", () => {
// Note: in practice, Zod schema requires config when graphStore.llm is
// present. This tests the defensive fallback in MemoryGraph itself.
const config = {
graphStore: {
config: {
url: "bolt://localhost:7687",
username: "neo4j",
password: "test",
},
llm: {
provider: "openai",
// config explicitly undefined
config: undefined,
},
},
embedder: { provider: "openai", config: {} },
llm: {
provider: "anthropic",
config: { model: "claude-sonnet-4-20250514" },
},
} as any;
new MemoryGraph(config);
// Provider from graphStore.llm, but config falls back to root llm.config
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", {
model: "claude-sonnet-4-20250514",
});
});
it("defaults to openai when neither root nor graphStore llm provider is set", () => {
const config = {
graphStore: {
config: {
url: "bolt://localhost:7687",
username: "neo4j",
password: "test",
},
},
embedder: { provider: "openai", config: {} },
llm: { config: { model: "gpt-4" } },
} as any;
new MemoryGraph(config);
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", {
model: "gpt-4",
});
});
});
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
it("normalises spaces and case in entity source/relationship/destination", async () => {
mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [
{
name: "establish_relationships",
arguments: JSON.stringify({
entities: [
{
source: "New York",
relationship: "Capital Of",
destination: "United States",
},
],
}),
},
],
});
const mg = graph();
const result = await mg._establishNodesRelationsFromData(
"test",
FILTERS,
{},
);
expect(result).toEqual([
{
source: "new_york",
relationship: "capital_of",
destination: "united_states",
},
]);
});
});
-177
View File
@@ -1,177 +0,0 @@
import {
DELETE_RELATIONS_SYSTEM_PROMPT,
EXTRACT_RELATIONS_PROMPT,
UPDATE_GRAPH_PROMPT,
getDeleteMessages,
formatEntities,
} from "../src/graphs/utils";
/**
* Regression tests for graph prompts (issue #4248).
*
* When response_format: { type: "json_object" } is used, OpenAI requires
* the word "json" (case-insensitive) to appear in at least one message.
* Missing it produces a 400 error.
*
* Three call sites use json_object today:
* 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT
* 2. _retrieveNodesFromData → inline prompt (graph_memory.ts)
* 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix
*
* See: https://github.com/mem0ai/mem0/issues/4248
*/
// ─── JSON keyword presence ────────────────────────────────────────────────────
describe("Graph prompts — JSON keyword requirement", () => {
it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => {
expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json");
});
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
// graph_memory.ts appends "\nPlease provide your response in JSON format."
const withSuffix =
EXTRACT_RELATIONS_PROMPT +
"\nPlease provide your response in JSON format.";
expect(withSuffix.toLowerCase()).toContain("json");
});
it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => {
const [systemContent] = getDeleteMessages(
"alice -- loves -- pizza",
"Alice now hates pizza",
"user-42",
);
expect(systemContent.toLowerCase()).toContain("json");
});
it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => {
// Mirrors the template in _retrieveNodesFromData()
const userId = "user-1";
const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`;
expect(prompt.toLowerCase()).toContain("json");
});
});
// ─── getDeleteMessages ────────────────────────────────────────────────────────
describe("getDeleteMessages", () => {
it("replaces USER_ID with the provided userId in the system prompt", () => {
const [system] = getDeleteMessages("mem", "data", "alice-123");
expect(system).toContain("alice-123");
expect(system).not.toContain("USER_ID");
});
it("includes existing memories and new data in the user prompt", () => {
const existing = "bob -- knows -- carol";
const newData = "Bob no longer knows Carol";
const [, user] = getDeleteMessages(existing, newData, "u1");
expect(user).toContain(existing);
expect(user).toContain(newData);
});
it("returns a 2-tuple [system, user]", () => {
const result = getDeleteMessages("a", "b", "c");
expect(result).toHaveLength(2);
expect(typeof result[0]).toBe("string");
expect(typeof result[1]).toBe("string");
});
// — Malformed / edge-case inputs —
it("handles empty strings without throwing", () => {
expect(() => getDeleteMessages("", "", "")).not.toThrow();
const [system, user] = getDeleteMessages("", "", "");
expect(system.toLowerCase()).toContain("json");
expect(typeof user).toBe("string");
});
it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
const [system] = getDeleteMessages(
"mem",
"data",
'<script>alert("xss")</script>',
);
expect(system).toContain('<script>alert("xss")</script>');
expect(system).not.toContain("USER_ID");
});
it("handles unicode input", () => {
const [system, user] = getDeleteMessages(
"日本語メモリ",
"新しい情報",
"ユーザー1",
);
expect(system).toContain("ユーザー1");
expect(user).toContain("日本語メモリ");
expect(user).toContain("新しい情報");
});
it("handles very long input strings", () => {
const longStr = "x".repeat(100_000);
expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow();
const [system] = getDeleteMessages(longStr, longStr, "u");
expect(system.toLowerCase()).toContain("json");
});
});
// ─── formatEntities ───────────────────────────────────────────────────────────
describe("formatEntities", () => {
it("formats a single entity triplet", () => {
const result = formatEntities([
{ source: "Alice", relationship: "knows", destination: "Bob" },
]);
expect(result).toBe("Alice -- knows -- Bob");
});
it("joins multiple entities with newlines", () => {
const result = formatEntities([
{ source: "A", relationship: "r1", destination: "B" },
{ source: "C", relationship: "r2", destination: "D" },
]);
expect(result).toBe("A -- r1 -- B\nC -- r2 -- D");
});
it("returns empty string for empty array", () => {
expect(formatEntities([])).toBe("");
});
it("preserves special characters in entity fields", () => {
const result = formatEntities([
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" },
]);
expect(result).toContain("O'Brien");
expect(result).toContain('said "hello"');
expect(result).toContain("café");
});
});
// ─── Prompt structural invariants ─────────────────────────────────────────────
describe("Prompt structural invariants", () => {
it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => {
expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID");
});
it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => {
expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID");
});
it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => {
expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT");
});
it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => {
expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}");
expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}");
});
it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => {
expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100);
});
it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => {
expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100);
});
});
+26 -16
View File
@@ -22,18 +22,21 @@ jest.mock("../src/llms/openai", () => ({
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const hasSystemRole = messages.some((m) => m.role === "system");
if (hasSystemRole) {
return JSON.stringify({ facts: ["extracted fact from input"] });
}
// V3 pipeline: single LLM call with additive extraction prompt.
const userMsg = messages.find((m) => m.role === "user");
const content = userMsg?.content ?? "";
const newMsgMatch = content.match(
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
);
const extracted = newMsgMatch
? newMsgMatch[1].trim()
: "extracted fact from input";
return JSON.stringify({
memory: [
{
id: "new",
event: "ADD",
text: "extracted fact from input",
old_memory: "",
new_memory: "extracted fact from input",
id: "0",
text: extracted,
attributed_to: "user",
},
],
});
@@ -42,9 +45,15 @@ jest.mock("../src/llms/openai", () => ({
})),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
@@ -93,15 +102,16 @@ describe("Memory - add()", () => {
});
test("returns at least one result with an id", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
userId,
});
const result: SearchResult = await memory.add(
"I enjoy hiking in the mountains",
{ userId },
);
expect(result.results.length).toBeGreaterThan(0);
expect(result.results[0].id).toBeDefined();
});
test("result item has a memory string field", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
const result: SearchResult = await memory.add("My favorite color is blue", {
userId,
});
expect(typeof result.results[0].memory).toBe("string");
@@ -116,14 +126,14 @@ describe("Memory - add()", () => {
expect(result.results.length).toBeGreaterThan(0);
});
test("works with agentId filter instead of userId", async () => {
test("works with agentId instead of userId", async () => {
const result: SearchResult = await memory.add("test", {
agentId: "agent_1",
});
expect(result.results.length).toBeGreaterThan(0);
});
test("works with runId filter instead of userId", async () => {
test("works with runId instead of userId", async () => {
const result: SearchResult = await memory.add("test", { runId: "run_1" });
expect(result.results.length).toBeGreaterThan(0);
});
+50 -23
View File
@@ -22,18 +22,21 @@ jest.mock("../src/llms/openai", () => ({
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const hasSystemRole = messages.some((m) => m.role === "system");
if (hasSystemRole) {
return JSON.stringify({ facts: ["stored fact"] });
}
// V3 pipeline: single LLM call with additive extraction prompt.
// Extract the user input from the prompt to produce unique memories.
const userMsg = messages.find((m) => m.role === "user");
const content = userMsg?.content ?? "";
// Pull the text between "## New Messages" and the next "##"
const newMsgMatch = content.match(
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
);
const extracted = newMsgMatch ? newMsgMatch[1].trim() : "stored fact";
return JSON.stringify({
memory: [
{
id: "new",
event: "ADD",
text: "stored fact",
old_memory: "",
new_memory: "stored fact",
id: "0",
text: extracted,
attributed_to: "user",
},
],
});
@@ -42,9 +45,15 @@ jest.mock("../src/llms/openai", () => ({
})),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
@@ -87,7 +96,9 @@ describe("Memory - get()", () => {
});
test("returns the memory matching the ID from add()", async () => {
const addResult: SearchResult = await memory.add("I love AI", { userId });
const addResult: SearchResult = await memory.add("I love AI", {
userId,
});
const id = addResult.results[0].id;
const item: MemoryItem | null = await memory.get(id);
expect(item).not.toBeNull();
@@ -108,7 +119,9 @@ describe("Memory - get()", () => {
});
test("returns hash and createdAt on stored memory", async () => {
const addResult: SearchResult = await memory.add("Hash test", { userId });
const addResult: SearchResult = await memory.add("Hash test", {
userId,
});
const item: MemoryItem | null = await memory.get(addResult.results[0].id);
expect(typeof item!.hash).toBe("string");
expect(item!.createdAt).toBeDefined();
@@ -233,13 +246,15 @@ describe("Memory - deleteAll()", () => {
await memory.add("Fact B", { userId });
const result = await memory.deleteAll({ userId });
expect(result.message).toBe("Memories deleted successfully!");
const remaining: SearchResult = await memory.getAll({ userId });
const remaining: SearchResult = await memory.getAll({
filters: { user_id: userId },
});
expect(remaining.results).toHaveLength(0);
});
test("throws when no filter is provided", async () => {
await expect(memory.deleteAll({} as any)).rejects.toThrow(
"At least one filter is required",
"At least one filter is required to delete all memories",
);
});
});
@@ -261,13 +276,17 @@ describe("Memory - getAll()", () => {
test("returns all stored memories for the user", async () => {
await memory.add("First", { userId });
await memory.add("Second", { userId });
const result: SearchResult = await memory.getAll({ userId });
const result: SearchResult = await memory.getAll({
filters: { user_id: userId },
});
expect(Array.isArray(result.results)).toBe(true);
expect(result.results.length).toBeGreaterThanOrEqual(2);
});
test("each result has id and memory fields", async () => {
const result: SearchResult = await memory.getAll({ userId });
const result: SearchResult = await memory.getAll({
filters: { user_id: userId },
});
for (const item of result.results) {
expect(item.id).toBeDefined();
expect(typeof item.memory).toBe("string");
@@ -276,7 +295,7 @@ describe("Memory - getAll()", () => {
test("returns empty array when no memories exist", async () => {
const result: SearchResult = await memory.getAll({
userId: "no_such_user",
filters: { user_id: "no_such_user" },
});
expect(result.results).toHaveLength(0);
});
@@ -298,12 +317,16 @@ describe("Memory - search()", () => {
});
test("returns SearchResult with results array", async () => {
const result: SearchResult = await memory.search("TypeScript", { userId });
const result: SearchResult = await memory.search("TypeScript", {
filters: { user_id: userId },
});
expect(Array.isArray(result.results)).toBe(true);
});
test("returns results with score field", async () => {
const result: SearchResult = await memory.search("content", { userId });
const result: SearchResult = await memory.search("content", {
filters: { user_id: userId },
});
if (result.results.length > 0) {
expect(typeof result.results[0].score).toBe("number");
}
@@ -311,13 +334,13 @@ describe("Memory - search()", () => {
test("throws when no userId/agentId/runId provided", async () => {
await expect(memory.search("query", {} as any)).rejects.toThrow(
"One of the filters: userId, agentId or runId is required!",
"filters must contain at least one of: user_id, agent_id, run_id",
);
});
test("returns empty results for user with no memories", async () => {
const result: SearchResult = await memory.search("query", {
userId: "empty_user",
filters: { user_id: "empty_user" },
});
expect(result.results).toHaveLength(0);
});
@@ -338,14 +361,18 @@ describe("Memory - history()", () => {
});
test("records ADD event after add()", async () => {
const addResult: SearchResult = await memory.add("New fact", { userId });
const addResult: SearchResult = await memory.add("New fact", {
userId,
});
const history = await memory.history(addResult.results[0].id);
expect(Array.isArray(history)).toBe(true);
expect(history.length).toBeGreaterThan(0);
});
test("records additional entry after update()", async () => {
const addResult: SearchResult = await memory.add("Before", { userId });
const addResult: SearchResult = await memory.add("Before", {
userId,
});
const id = addResult.results[0].id;
await memory.update(id, "After");
const history = await memory.history(id);
+23 -14
View File
@@ -16,26 +16,25 @@ jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
// ─── Content-based LLM mock (reviewer #9) ────────────────
// Returns facts for system-prompt calls, memory actions for user-only calls.
// ─── Content-based LLM mock (V3 additive extraction pipeline) ─────────
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const hasSystemRole = messages.some((m) => m.role === "system");
if (hasSystemRole) {
return JSON.stringify({ facts: ["test fact"] });
}
const userMsg = messages.find((m) => m.role === "user");
const content = userMsg?.content ?? "";
const newMsgMatch = content.match(
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
);
const extracted = newMsgMatch ? newMsgMatch[1].trim() : "test fact";
return JSON.stringify({
memory: [
{
id: "new",
event: "ADD",
text: "test fact",
old_memory: "",
new_memory: "test fact",
id: "0",
text: extracted,
attributed_to: "user",
},
],
});
@@ -44,9 +43,15 @@ jest.mock("../src/llms/openai", () => ({
})),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
@@ -114,12 +119,16 @@ describe("Memory - reset()", () => {
const userId = `reset_test_${Date.now()}`;
await mem.add("Remember this fact", { userId });
const before: SearchResult = await mem.getAll({ userId });
const before: SearchResult = await mem.getAll({
filters: { user_id: userId },
});
expect(before.results.length).toBeGreaterThan(0);
await mem.reset();
const after: SearchResult = await mem.getAll({ userId });
const after: SearchResult = await mem.getAll({
filters: { user_id: userId },
});
expect(after.results).toHaveLength(0);
});
});
@@ -944,7 +944,7 @@ describe("Memory class – backward compat with all providers", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
const embedder = mockEmbedderFactory.create.mock.results[0].value;
expect(embedder.embed).not.toHaveBeenCalledWith("dimension probe");
@@ -967,7 +967,7 @@ describe("Memory class – backward compat with all providers", () => {
const mockEmbedder768 = createMockEmbedder(768);
mockEmbedderFactory.create.mockReturnValue(mockEmbedder768);
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockEmbedder768.embed).not.toHaveBeenCalledWith("dimension probe");
});
@@ -982,7 +982,7 @@ describe("Memory class – backward compat with all providers", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockEmbedder768.embed).toHaveBeenCalledWith("dimension probe");
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
@@ -1003,7 +1003,7 @@ describe("Memory class – backward compat with all providers", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockVStore.initialize).toHaveBeenCalled();
});
@@ -1048,11 +1048,13 @@ describe("Memory class – backward compat with all providers", () => {
});
// getAll
const all = await mem.getAll({ userId: "u1" });
const all = await mem.getAll({ filters: { user_id: "u1" } });
expect(all).toBeDefined();
// search
const searchResult = await mem.search("query", { userId: "u1" });
const searchResult = await mem.search("query", {
filters: { user_id: "u1" },
});
expect(searchResult).toBeDefined();
// get
@@ -1093,7 +1095,7 @@ describe("Memory class – backward compat with all providers", () => {
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
await mem.getAll({ filters: { user_id: "u1" } });
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
await mem.reset();
@@ -1120,12 +1122,12 @@ describe("Memory class – backward compat with all providers", () => {
disableHistory: true,
});
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
"auto-detect embedding dimension",
);
await expect(mem.search("q", { userId: "u1" })).rejects.toThrow(
await expect(mem.getAll({ filters: { user_id: "u1" } })).rejects.toThrow(
"auto-detect embedding dimension",
);
await expect(
mem.search("q", { filters: { user_id: "u1" } }),
).rejects.toThrow("auto-detect embedding dimension");
await expect(mem.get("id")).rejects.toThrow(
"auto-detect embedding dimension",
);