diff --git a/packages/tools/package.json b/packages/tools/package.json index 74f2f5bc..997f9f68 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -8,7 +8,7 @@ "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/mastra/processor.ts b/packages/tools/src/mastra/processor.ts index e7a39e96..37df4ddd 100644 --- a/packages/tools/src/mastra/processor.ts +++ b/packages/tools/src/mastra/processor.ts @@ -19,6 +19,7 @@ import { MemoryCache, buildMemoriesText, extractQueryText, + wrapMemoryContext, type Logger, type MemoryMode, type PromptTemplate, @@ -163,7 +164,7 @@ export class SupermemoryInputProcessor implements Processor { const cachedMemories = this.ctx.memoryCache.get(turnKey) if (cachedMemories) { this.ctx.logger.debug("Using cached memories", { turnKey }) - messageList.addSystem(cachedMemories, "supermemory") + messageList.addSystem(wrapMemoryContext(cachedMemories), "supermemory") return messageList } @@ -185,7 +186,7 @@ export class SupermemoryInputProcessor implements Processor { if (memories) { this.ctx.memoryCache.set(turnKey, memories) - messageList.addSystem(memories, "supermemory") + messageList.addSystem(wrapMemoryContext(memories), "supermemory") this.ctx.logger.debug("Injected memories into system prompt", { length: memories.length, }) diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index bc4d7108..740d496d 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,6 +1,11 @@ import type OpenAI from "openai" import Supermemory from "supermemory" import { addConversation } from "../conversations-client" +import { + replaceMemoryContext, + stripMemoryContext, + wrapMemoryContext, +} from "../shared" import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" @@ -240,18 +245,26 @@ const addSystemPrompt = async ( } if (systemPromptExists) { - logger.debug("Added memories to existing system prompt") - return messages.map((msg) => - msg.role === "system" - ? { ...msg, content: `${msg.content} \n ${memories}` } - : msg, - ) + logger.debug("Replaced Supermemory context in existing system prompt") + let injected = false + return messages.map((msg) => { + if (msg.role !== "system") return msg + const content = typeof msg.content === "string" ? msg.content : "" + if (!injected) { + injected = true + return { ...msg, content: replaceMemoryContext(content, memories) } + } + return { ...msg, content: stripMemoryContext(content) } + }) } logger.debug( "System prompt does not exist, created system prompt with memories", ) - return [{ role: "system" as const, content: memories }, ...messages] + const memoryContext = wrapMemoryContext(memories) + return memoryContext + ? [{ role: "system" as const, content: memoryContext }, ...messages] + : messages } /** @@ -590,9 +603,10 @@ export function createOpenAIMiddleware( const results = await Promise.all(operations) const memories = results[results.length - 1] // Memory search result is always last - const enhancedInstructions = memories - ? `${params.instructions || ""}\n\n${memories}`.trim() - : params.instructions + const enhancedInstructions = replaceMemoryContext( + params.instructions || "", + typeof memories === "string" ? memories : "", + ) return originalResponsesCreate.call( openaiClient.responses, @@ -658,7 +672,9 @@ export function createOpenAIMiddleware( ) const results = await Promise.all(operations) - const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last + const enhancedMessages = results[ + results.length - 1 + ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last return originalCreate.call( openaiClient.chat.completions, diff --git a/packages/tools/src/shared/index.ts b/packages/tools/src/shared/index.ts index 5a6e0f7b..889a5729 100644 --- a/packages/tools/src/shared/index.ts +++ b/packages/tools/src/shared/index.ts @@ -40,3 +40,12 @@ export { type BuildMemoriesTextOptions, type GenericMessage, } from "./memory-client" + +// SDK-owned prompt context +export { + MEMORY_CONTEXT_START, + MEMORY_CONTEXT_END, + stripMemoryContext, + wrapMemoryContext, + replaceMemoryContext, +} from "./memory-context" diff --git a/packages/tools/src/shared/memory-context.ts b/packages/tools/src/shared/memory-context.ts new file mode 100644 index 00000000..84116c33 --- /dev/null +++ b/packages/tools/src/shared/memory-context.ts @@ -0,0 +1,32 @@ +export const MEMORY_CONTEXT_START = + '' +export const MEMORY_CONTEXT_END = "" + +const MEMORY_CONTEXT_PATTERN = + /[ \t]*[\s\S]*?<\/supermemory>[ \t]*/g + +/** Remove every context block previously owned by the Supermemory middleware. */ +export function stripMemoryContext(content: string): string { + return content + .replace(MEMORY_CONTEXT_PATTERN, "") + .replace(/\n{3,}/g, "\n\n") + .trim() +} + +/** Mark retrieved memory context so a later turn can replace it safely. */ +export function wrapMemoryContext(memories: string): string { + const normalized = memories.trim() + if (!normalized) return "" + return `${MEMORY_CONTEXT_START}\n${normalized}\n${MEMORY_CONTEXT_END}` +} + +/** Replace prior middleware context while preserving caller-authored instructions. */ +export function replaceMemoryContext( + content: string, + memories: string, +): string { + const preserved = stripMemoryContext(content) + const memoryContext = wrapMemoryContext(memories) + if (!memoryContext) return preserved + return preserved ? `${preserved}\n\n${memoryContext}` : memoryContext +} diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index c26637df..bede457b 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -56,6 +56,23 @@ describe("deduplicateMemoriesForMode", () => { expect(deduplicated.searchResults).toEqual(["User likes TypeScript"]) }) + it("deduplicates normalized fact variants within and across sources", () => { + const deduplicated = deduplicateMemoriesForMode("full", { + static: [ + { memory: "User likes TypeScript" }, + { memory: " user likes typescript " }, + ], + dynamic: [{ memory: "[2026-08-10] USER LIKES TYPESCRIPT" }], + searchResults: [{ memory: "User prefers async/await" }], + }) + + expect(deduplicated).toEqual({ + static: ["User likes TypeScript"], + dynamic: [], + searchResults: ["User prefers async/await"], + }) + }) + it("deduplicates search results against the profile in full mode", () => { const deduplicated = deduplicateMemoriesForMode("full", { static: [{ memory: "User is allergic to peanuts" }], diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index fa25bcae..edb39168 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -288,6 +288,15 @@ export interface DeduplicatedMemories { searchResults: string[] } +/** Normalize exact fact variants without attempting semantic/fuzzy matching. */ +export function normalizeMemoryFact(memory: string): string { + return memory + .replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, "") + .trim() + .replace(/\s+/g, " ") + .toLowerCase() +} + /** * Deduplicates memory items across static, dynamic, and search result sources. * Priority: Static > Dynamic > Search Results @@ -334,9 +343,10 @@ export function deduplicateMemories( for (const item of staticItems as Array) { const memory = getMemoryString(item) - if (memory !== null) { + const key = memory === null ? null : normalizeMemoryFact(memory) + if (memory !== null && key !== null && !seenMemories.has(key)) { staticMemories.push(memory) - seenMemories.add(memory) + seenMemories.add(key) } } @@ -344,9 +354,10 @@ export function deduplicateMemories( for (const item of dynamicItems as Array) { const memory = getMemoryString(item) - if (memory !== null && !seenMemories.has(memory)) { + const key = memory === null ? null : normalizeMemoryFact(memory) + if (memory !== null && key !== null && !seenMemories.has(key)) { dynamicMemories.push(memory) - seenMemories.add(memory) + seenMemories.add(key) } } @@ -354,9 +365,10 @@ export function deduplicateMemories( for (const item of searchItems as Array) { const memory = getMemoryString(item) - if (memory !== null && !seenMemories.has(memory)) { + const key = memory === null ? null : normalizeMemoryFact(memory) + if (memory !== null && key !== null && !seenMemories.has(key)) { searchMemories.push(memory) - seenMemories.add(memory) + seenMemories.add(key) } } diff --git a/packages/tools/src/vercel/memory-prompt.ts b/packages/tools/src/vercel/memory-prompt.ts index 0f928aa2..54b7a742 100644 --- a/packages/tools/src/vercel/memory-prompt.ts +++ b/packages/tools/src/vercel/memory-prompt.ts @@ -8,7 +8,13 @@ export { type BuildMemoriesTextOptions, } from "../shared" -import type { Logger, MemoryPromptData } from "../shared" +import { + type Logger, + type MemoryPromptData, + replaceMemoryContext, + stripMemoryContext, + wrapMemoryContext, +} from "../shared" import type { LanguageModelCallOptions } from "./util" /** @@ -66,21 +72,28 @@ export const injectMemoriesIntoParams = ( ) if (systemPromptExists) { - logger.debug("Added memories to existing system prompt") + logger.debug("Replaced Supermemory context in existing system prompt") + let injected = false // biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3 prompt types - const newPrompt = params.prompt.map((prompt: any) => - prompt.role === "system" - ? { ...prompt, content: `${prompt.content} \n ${memories}` } - : prompt, - ) + const newPrompt = params.prompt.map((prompt: any) => { + if (prompt.role !== "system") return prompt + const content = String(prompt.content ?? "") + if (!injected) { + injected = true + return { ...prompt, content: replaceMemoryContext(content, memories) } + } + return { ...prompt, content: stripMemoryContext(content) } + }) return { ...params, prompt: newPrompt } as LanguageModelCallOptions } logger.debug( "System prompt does not exist, created system prompt with memories", ) + const memoryContext = wrapMemoryContext(memories) + if (!memoryContext) return params const newPrompt = [ - { role: "system" as const, content: memories }, + { role: "system" as const, content: memoryContext }, ...params.prompt, // biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3 prompt types ] as any diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index 7e788c3d..a87285b6 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -15,6 +15,9 @@ import { MemoryCache, buildMemoriesText, extractQueryText, + replaceMemoryContext, + stripMemoryContext, + wrapMemoryContext, type Logger, type MemoryMode, } from "../shared" @@ -335,47 +338,42 @@ const injectMemoriesIntoMessages = ( memories: string, logger: Logger, ): VoltAgentMessage[] => { - const systemMessageIndex = messages.findIndex((msg) => msg.role === "system") - - if (systemMessageIndex !== -1) { - logger.debug("Added memories to existing system message") - const newMessages = [...messages] - const systemMessage = newMessages[systemMessageIndex] - if (!systemMessage) { - return messages - } - - // Extract existing text from parts (UIMessage format) or content fallback - const parts = ( - systemMessage as { parts?: Array<{ type: string; text?: string }> } - ).parts - const existingContent = parts - ? parts - .filter((p) => p.type === "text") - .map((p) => p.text || "") - .join("\n") - : typeof systemMessage.content === "string" - ? systemMessage.content - : "" - - const newContent = `${existingContent}\n\n${memories}` - - newMessages[systemMessageIndex] = { - ...systemMessage, - content: newContent, - // Update parts array to match - this is what the LLM actually reads - parts: [{ type: "text", text: newContent }], - } as VoltAgentMessage - return newMessages + if (messages.some((msg) => msg.role === "system")) { + logger.debug("Replaced Supermemory context in existing system message") + let injected = false + return messages.map((message) => { + if (message.role !== "system") return message + const parts = ( + message as { parts?: Array<{ type: string; text?: string }> } + ).parts + const partContent = parts + ?.filter((part) => part.type === "text") + .map((part) => part.text || "") + .join("\n") + const existingContent = + partContent || + (typeof message.content === "string" ? message.content : "") + const newContent = !injected + ? replaceMemoryContext(existingContent, memories) + : stripMemoryContext(existingContent) + injected = true + return { + ...message, + content: newContent, + parts: [{ type: "text", text: newContent }], + } as VoltAgentMessage + }) } logger.debug("Created system message with memories") + const memoryContext = wrapMemoryContext(memories) + if (!memoryContext) return messages return [ { id: crypto.randomUUID(), role: "system" as const, - content: memories, - parts: [{ type: "text", text: memories }], + content: memoryContext, + parts: [{ type: "text", text: memoryContext }], } as VoltAgentMessage, ...messages, ] diff --git a/packages/tools/test/mastra/unit.test.ts b/packages/tools/test/mastra/unit.test.ts index 82b0cac6..6f0f1102 100644 --- a/packages/tools/test/mastra/unit.test.ts +++ b/packages/tools/test/mastra/unit.test.ts @@ -198,6 +198,9 @@ describe("SupermemoryInputProcessor", () => { const systemCall = messageList.calls.find((c) => c.method === "addSystem") expect(systemCall).toBeDefined() expect(systemCall?.args[0]).toContain("TypeScript") + expect(systemCall?.args[0]).toContain( + '', + ) expect(systemCall?.args[1]).toBe("supermemory") }) diff --git a/packages/tools/test/openai-middleware.unit.test.ts b/packages/tools/test/openai-middleware.unit.test.ts new file mode 100644 index 00000000..b68e1e79 --- /dev/null +++ b/packages/tools/test/openai-middleware.unit.test.ts @@ -0,0 +1,61 @@ +import type OpenAI from "openai" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { withSupermemory } from "../src/openai" + +describe("OpenAI middleware memory context", () => { + const originalApiKey = process.env.SUPERMEMORY_API_KEY + + beforeEach(() => { + process.env.SUPERMEMORY_API_KEY = "sm_test_key" + }) + + afterEach(() => { + if (originalApiKey === undefined) delete process.env.SUPERMEMORY_API_KEY + else process.env.SUPERMEMORY_API_KEY = originalApiKey + vi.unstubAllGlobals() + }) + + it("replaces prior SDK context in chat system messages", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + profile: { static: [{ memory: "Fresh profile fact" }], dynamic: [] }, + searchResults: { results: [] }, + }), + }), + ) + const originalCreate = vi.fn().mockResolvedValue({ choices: [] }) + const client = { + chat: { completions: { create: originalCreate } }, + } as unknown as OpenAI + const wrapped = withSupermemory(client, { + containerTag: "user-a", + customId: "conversation-a", + mode: "profile", + addMemory: "never", + }) + + await wrapped.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: + 'Be helpful.\n\n\nStale profile fact\n', + }, + { role: "user", content: "What do you remember?" }, + ], + }) + + const forwarded = originalCreate.mock.calls[0]?.[0] + const content = String(forwarded.messages[0].content) + expect(content).toContain("Be helpful.") + expect(content).toContain("Fresh profile fact") + expect(content).not.toContain("Stale profile fact") + expect( + content.match(//g), + ).toHaveLength(1) + }) +}) diff --git a/packages/tools/test/voltagent.unit.test.ts b/packages/tools/test/voltagent.unit.test.ts new file mode 100644 index 00000000..559f1fa1 --- /dev/null +++ b/packages/tools/test/voltagent.unit.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { createSupermemoryHooks } from "../src/voltagent" + +describe("VoltAgent memory context", () => { + afterEach(() => vi.unstubAllGlobals()) + + it("replaces prior SDK context in the prepared system message", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + profile: { static: [{ memory: "Fresh profile fact" }], dynamic: [] }, + searchResults: { results: [] }, + }), + }), + ) + const hooks = createSupermemoryHooks("user-a", { + customId: "conversation-a", + apiKey: "sm_test_key", + mode: "profile", + addMemory: "never", + }) + + const args = { + agent: { name: "test-agent" }, + context: { + input: { messages: [{ role: "user", content: "Remember me" }] }, + }, + messages: [ + { + id: "system", + role: "system", + content: + 'Be helpful.\n\n\nStale profile fact\n', + parts: [], + }, + ], + } as Parameters>[0] + const result = await hooks.onPrepareMessages?.(args) + + const content = String(result?.messages?.[0]?.content ?? "") + expect(content).toContain("Be helpful.") + expect(content).toContain("Fresh profile fact") + expect(content).not.toContain("Stale profile fact") + expect( + content.match(//g), + ).toHaveLength(1) + }) +}) diff --git a/packages/tools/test/with-supermemory/unit.test.ts b/packages/tools/test/with-supermemory/unit.test.ts index 2653b345..50900a6f 100644 --- a/packages/tools/test/with-supermemory/unit.test.ts +++ b/packages/tools/test/with-supermemory/unit.test.ts @@ -301,6 +301,121 @@ describe("Unit: withSupermemory", () => { expect(fetchMock).toHaveBeenCalledTimes(2) expect(result2.prompt[0]?.content).toContain("Memory from call 2") }) + + it("replaces the prior SDK memory block instead of accumulating context", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve(createMockProfileResponse(["Fresh profile fact"])), + }) + + const inner = createMockLanguageModel() + vi.mocked(inner.doGenerate).mockResolvedValue({ + content: [{ type: "text", text: "Done" }], + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + warnings: [], + }) + const wrapped = withSupermemory(inner, { + containerTag: TEST_CONFIG.containerTag, + customId: "conversation-a", + mode: "profile", + addMemory: "never", + apiKey: TEST_CONFIG.apiKey, + }) + + await wrapped.doGenerate({ + prompt: [ + { + role: "system", + content: + 'Be helpful.\n\n\nStale profile fact\n', + }, + { + role: "user", + content: [{ type: "text", text: "What do you remember?" }], + }, + ], + }) + + const forwarded = vi.mocked(inner.doGenerate).mock.calls[0]?.[0] + const system = forwarded?.prompt.find( + (message) => message.role === "system", + ) + const content = String(system?.content ?? "") + + expect(content).toContain("Be helpful.") + expect(content).toContain("Fresh profile fact") + expect(content).not.toContain("Stale profile fact") + expect( + content.match(//g), + ).toHaveLength(1) + }) + + it("keeps concurrent user contexts isolated", async () => { + fetchMock.mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) + return { + ok: true, + json: async () => + createMockProfileResponse([ + body.containerTag === "user-a" + ? "Fact for Alice" + : "Fact for Bob", + ]), + } + }) + const innerA = createMockLanguageModel() + const innerB = createMockLanguageModel() + vi.mocked(innerA.doGenerate).mockResolvedValue({ + content: [{ type: "text", text: "A" }], + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + warnings: [], + }) + vi.mocked(innerB.doGenerate).mockResolvedValue({ + content: [{ type: "text", text: "B" }], + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + warnings: [], + }) + const wrappedA = withSupermemory(innerA, { + containerTag: "user-a", + customId: "conversation-a", + apiKey: TEST_CONFIG.apiKey, + addMemory: "never", + }) + const wrappedB = withSupermemory(innerB, { + containerTag: "user-b", + customId: "conversation-b", + apiKey: TEST_CONFIG.apiKey, + addMemory: "never", + }) + const params = { + prompt: [ + { + role: "user" as const, + content: [{ type: "text" as const, text: "Remember me" }], + }, + ], + } + + await Promise.all([ + wrappedA.doGenerate(params), + wrappedB.doGenerate(params), + ]) + + const promptA = String( + vi.mocked(innerA.doGenerate).mock.calls[0]?.[0].prompt[0]?.content, + ) + const promptB = String( + vi.mocked(innerB.doGenerate).mock.calls[0]?.[0].prompt[0]?.content, + ) + expect(promptA).toContain("Fact for Alice") + expect(promptA).not.toContain("Fact for Bob") + expect(promptB).toContain("Fact for Bob") + expect(promptB).not.toContain("Fact for Alice") + }) }) describe("Edge cases", () => {