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", () => {