feat(tools): SDK-level cross-source memory deduplication

Move profile deduplication into the SDK middleware. Facts are normalized
(strip leading date, trim, collapse whitespace, casefold) and deduplicated
in static > dynamic > search priority within each request, then injected as
one owned <supermemory> block that replaces the previous block instead of
accumulating. Dedup is mode-aware so query-mode search results are not
dropped against an omitted profile. No global/browser Set: request-local
only, safe for concurrent requests and Cloudflare Workers.

Covers AI SDK, OpenAI Chat/Responses, Mastra, and VoltAgent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dhravya Shah 2026-08-18 08:13:47 -07:00 committed by ved015
parent 4e04a0d7af
commit 7fa452b6b8
13 changed files with 389 additions and 62 deletions

View file

@ -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": {

View file

@ -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,
})

View file

@ -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,

View file

@ -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"

View file

@ -0,0 +1,32 @@
export const MEMORY_CONTEXT_START =
'<supermemory context="user-memories" readonly>'
export const MEMORY_CONTEXT_END = "</supermemory>"
const MEMORY_CONTEXT_PATTERN =
/[ \t]*<supermemory context="user-memories" readonly>[\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
}

View file

@ -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" }],

View file

@ -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<MemoryItem | string>) {
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<MemoryItem | string>) {
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<MemoryItem | string>) {
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)
}
}

View file

@ -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

View file

@ -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,
]

View file

@ -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(
'<supermemory context="user-memories" readonly>',
)
expect(systemCall?.args[1]).toBe("supermemory")
})

View file

@ -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<supermemory context="user-memories" readonly>\nStale profile fact\n</supermemory>',
},
{ 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(/<supermemory context="user-memories" readonly>/g),
).toHaveLength(1)
})
})

View file

@ -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<supermemory context="user-memories" readonly>\nStale profile fact\n</supermemory>',
parts: [],
},
],
} as Parameters<NonNullable<typeof hooks.onPrepareMessages>>[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(/<supermemory context="user-memories" readonly>/g),
).toHaveLength(1)
})
})

View file

@ -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<supermemory context="user-memories" readonly>\nStale profile fact\n</supermemory>',
},
{
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(/<supermemory context="user-memories" readonly>/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", () => {