diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 33ac373a..c9b8b4b8 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai" import Supermemory from "supermemory" import { addConversation } from "../conversations-client" -import { deduplicateMemories } from "../tools-shared" +import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" @@ -184,7 +184,7 @@ const addSystemPrompt = async ( mode, }) - const deduplicated = deduplicateMemories({ + const deduplicated = deduplicateMemoriesForMode(mode, { static: memoriesResponse.profile.static, dynamic: memoriesResponse.profile.dynamic, searchResults: memoriesResponse.searchResults?.results, @@ -471,7 +471,7 @@ export function createOpenAIMiddleware( mode, }) - const deduplicated = deduplicateMemories({ + const deduplicated = deduplicateMemoriesForMode(mode, { static: memoriesResponse.profile.static, dynamic: memoriesResponse.profile.dynamic, searchResults: memoriesResponse.searchResults?.results, diff --git a/packages/tools/src/shared/memory-client.test.ts b/packages/tools/src/shared/memory-client.test.ts new file mode 100644 index 00000000..4b4edc0a --- /dev/null +++ b/packages/tools/src/shared/memory-client.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { buildMemoriesText } from "./memory-client" +import { createLogger } from "./logger" + +const API_KEY = "sm_test_key" +const BASE_URL = "https://api.supermemory.ai" +const CONTAINER_TAG = "user-123" + +const logger = createLogger(false) + +/** Stubs `/v4/profile` so the injected prompt can be asserted without network access. */ +function mockProfileResponse(body: unknown) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => body, + }) + vi.stubGlobal("fetch", fetchMock) + return fetchMock +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("buildMemoriesText", () => { + // The profile is not injected in "query" mode. Deduplicating the search + // results against it would drop a fact present in both, leaving the model + // with nothing. + it("injects a search result that also exists in the profile in query mode", async () => { + mockProfileResponse({ + profile: { + static: [{ memory: "User is allergic to peanuts" }], + dynamic: [], + }, + searchResults: { results: [{ memory: "User is allergic to peanuts" }] }, + }) + + const memories = await buildMemoriesText({ + containerTag: CONTAINER_TAG, + queryText: "what should I avoid eating?", + mode: "query", + baseUrl: BASE_URL, + apiKey: API_KEY, + logger, + }) + + expect(memories).toContain("User is allergic to peanuts") + }) + + it("does not repeat a profile memory in the search results in full mode", async () => { + mockProfileResponse({ + profile: { + static: [{ memory: "User is allergic to peanuts" }], + dynamic: [], + }, + searchResults: { + results: [ + { memory: "User is allergic to peanuts" }, + { memory: "User prefers async/await" }, + ], + }, + }) + + const memories = await buildMemoriesText({ + containerTag: CONTAINER_TAG, + queryText: "what should I avoid eating?", + mode: "full", + baseUrl: BASE_URL, + apiKey: API_KEY, + logger, + }) + + expect(memories).toContain("## Static Profile") + expect(memories).toContain("User prefers async/await") + // Present once, under the profile — not duplicated into the search results. + expect(memories.match(/User is allergic to peanuts/g)).toHaveLength(1) + }) +}) diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 4ed1825e..9f2d73a7 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -1,4 +1,4 @@ -import { deduplicateMemories } from "../tools-shared" +import { deduplicateMemoriesForMode } from "../tools-shared" import type { Logger, MemoryMode, @@ -119,7 +119,7 @@ export const buildMemoriesText = async ( mode, }) - const deduplicated = deduplicateMemories({ + const deduplicated = deduplicateMemoriesForMode(mode, { static: memoriesResponse.profile.static, dynamic: memoriesResponse.profile.dynamic, searchResults: memoriesResponse.searchResults?.results, diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 319df6eb..c26637df 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { getContainerTags } from "./tools-shared" +import { deduplicateMemoriesForMode, getContainerTags } from "./tools-shared" describe("getContainerTags", () => { it("uses the default project when no config is provided", () => { @@ -26,3 +26,59 @@ describe("getContainerTags", () => { ).toThrow("either projectId or containerTags") }) }) + +describe("deduplicateMemoriesForMode", () => { + // The profile is not injected in "query" mode, so a memory that is both a + // profile fact and a search hit must survive in the search results — + // otherwise it is dropped from the prompt entirely. + it("keeps a search result that duplicates a profile memory in query mode", () => { + const deduplicated = deduplicateMemoriesForMode("query", { + static: [{ memory: "User is allergic to peanuts" }], + dynamic: [], + searchResults: [{ memory: "User is allergic to peanuts" }], + }) + + expect(deduplicated.searchResults).toEqual(["User is allergic to peanuts"]) + expect(deduplicated.static).toEqual([]) + expect(deduplicated.dynamic).toEqual([]) + }) + + it("still deduplicates within the search results in query mode", () => { + const deduplicated = deduplicateMemoriesForMode("query", { + static: [], + dynamic: [], + searchResults: [ + { memory: "User likes TypeScript" }, + "User likes TypeScript", + ], + }) + + expect(deduplicated.searchResults).toEqual(["User likes TypeScript"]) + }) + + it("deduplicates search results against the profile in full mode", () => { + const deduplicated = deduplicateMemoriesForMode("full", { + static: [{ memory: "User is allergic to peanuts" }], + dynamic: [{ memory: "User is shipping a release today" }], + searchResults: [ + { memory: "User is allergic to peanuts" }, + { memory: "User prefers async/await" }, + ], + }) + + expect(deduplicated.static).toEqual(["User is allergic to peanuts"]) + expect(deduplicated.dynamic).toEqual(["User is shipping a release today"]) + expect(deduplicated.searchResults).toEqual(["User prefers async/await"]) + }) + + it("deduplicates search results against the profile in profile mode", () => { + const deduplicated = deduplicateMemoriesForMode("profile", { + static: [{ memory: "User is allergic to peanuts" }], + dynamic: [], + searchResults: [{ memory: "User is allergic to peanuts" }], + }) + + expect(deduplicated.static).toEqual(["User is allergic to peanuts"]) + expect(deduplicated.searchResults).toEqual([]) + }) +}) diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index 4b4a5b94..80ba33a6 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -2,6 +2,8 @@ * Shared constants and descriptions for Supermemory tools */ +import type { MemoryMode } from "./shared/types" + // Tool descriptions export const TOOL_DESCRIPTIONS = { searchMemories: @@ -177,3 +179,29 @@ export function deduplicateMemories( searchResults: searchMemories, } } + +/** + * Deduplicates memory items against only the sources the given mode actually + * injects into the prompt. + * + * `"query"` mode injects the search results but not the profile, so search + * results must not be deduplicated against the profile: a memory present in + * both would be dropped as a duplicate of something the model never sees, and + * would disappear from the prompt entirely. + * + * @param mode - The memory retrieval mode + * @param data - Profile data with memory items from different sources + * @returns Deduplicated memory strings for each source + */ +export function deduplicateMemoriesForMode( + mode: MemoryMode, + data: ProfileWithMemories, +): DeduplicatedMemories { + const injectsProfile = mode !== "query" + + return deduplicateMemories({ + static: injectsProfile ? data.static : [], + dynamic: injectsProfile ? data.dynamic : [], + searchResults: data.searchResults, + }) +}