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

## Stack Context

This stack moves memory deduplication **out of the playground UI and into the SDKs themselves**, so every integration injects a single, deduplicated, self-replacing memory block. Three PRs:

1. **`sdk-dedup/tools-ts`** (this PR) — TypeScript SDK core + integrations
2. `sdk-dedup/python` — Python SDKs
3. `sdk-dedup/playground` — playground debug view reflects the SDK-owned block

## What?

Move profile deduplication into the SDK middleware for the TypeScript tools package.

- Facts are normalized (strip leading `[YYYY-MM-DD]`, trim, collapse whitespace, casefold) and deduplicated in **`static > dynamic > search`** priority within a single request.
- The result is injected as one **owned `<supermemory>` block** that *replaces* the previous block instead of accumulating a new one each turn.
- Dedup is **mode-aware**: in query mode, search results are not dropped against a profile that isn't being injected.
- Deduplication is **request-local** — no global/browser `Set`. Safe for multiple users, concurrent requests, and Cloudflare Worker isolates.

Covers AI SDK, OpenAI (Chat + Responses), Mastra, and VoltAgent. New `shared/memory-context.ts` owns the block-replacement logic.

## Why?

The earlier "conversation-scoped deduplication" was only a playground browser `Set` — a UI debug affordance that did not change what the SDK sent to the model, and would have been unsafe as server-side global state. Real cross-source dedup belongs in the SDK, applied fresh per stateless model request.

## Testing

- `bun run test` in `packages/tools`: 145 passed (the one failing suite, `claude-memory.test.ts`, is a pre-existing broken import unrelated to this change).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how system prompts and instructions are built across all TypeScript integrations; behavior is well-covered by unit tests but incorrect strip/replace logic could drop or duplicate context in production prompts.
>
> **Overview**
> Moves **cross-source memory deduplication** and **owned prompt injection** into `@supermemory/tools` so every integration sends one deduplicated memory block per request instead of growing context each turn.
>
> **Deduplication:** Facts are normalized via `normalizeMemoryFact` (strip `[YYYY-MM-DD]`, trim, collapse whitespace, lowercase) and deduplicated with **static → dynamic → search** priority. `deduplicateMemoriesForMode` keeps search hits in **query** mode when the profile is not injected.
>
> **Owned `<supermemory>` block:** New `shared/memory-context.ts` wraps memories in `<supermemory context="user-memories" readonly>`, strips stale blocks, and **replaces** prior SDK context while preserving caller system instructions. Applied in AI SDK (`injectMemoriesIntoParams`), OpenAI Chat/Responses middleware, Mastra input processor (`wrapMemoryContext`), and VoltAgent hooks.
>
> **Tests:** Unit coverage for block replacement (with-supermemory, OpenAI, VoltAgent), Mastra wrapper tag assertion, normalized dedup variants, and concurrent `containerTag` isolation.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2fa2e0d85c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Dhravya 2026-09-01 06:10:36 +00:00
parent b01d2b69a3
commit d0f53b0d64
No known key found for this signature in database
GPG key ID: 135A27003CF4F6CB
20 changed files with 1068 additions and 235 deletions

View file

@ -360,7 +360,7 @@
},
"packages/tools": {
"name": "@supermemory/tools",
"version": "2.2.0",
"version": "2.3.0",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.25",
"@ai-sdk/openai": "^2.0.23",

View file

@ -1,14 +1,14 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "2.2.0",
"version": "2.3.0",
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
"scripts": {
"build": "tsdown",
"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,
@ -138,6 +139,11 @@ export class SupermemoryInputProcessor implements Processor {
async processInput(args: ProcessInputArgs): Promise<ProcessInputResult> {
const { messages, messageList, requestContext } = args
// Mastra owns tagged system messages by tag. Clear the previous value on
// every invocation so empty, skipped, cached, fresh, and error paths cannot
// leave stale Supermemory context behind.
messageList.clearSystemMessages("supermemory")
try {
const queryText = extractQueryText(
messages as unknown as Array<{
@ -163,7 +169,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 +191,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

@ -5,7 +5,13 @@ import {
addConversation,
type ContentPart as ConversationContentPart,
type ConversationMessage,
toConversationImageUrl,
} 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"
@ -34,6 +40,61 @@ const deferAPIPromise = <T>(
})
}
export interface OpenAIMiddlewareOptions {
/** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */
containerTag: string
/** Custom ID to group messages into a single document. Required. */
customId: string
verbose?: boolean
mode?: "profile" | "query" | "full"
addMemory?: "always" | "never"
/** Supermemory API key (falls back to SUPERMEMORY_API_KEY). */
apiKey?: string
baseUrl?: string
}
interface SupermemoryProfileSearchResult {
id: string
memory?: string
chunk?: string
metadata: Record<string, unknown> | null
updatedAt: string
similarity: number
}
interface SupermemoryProfileSearch {
profile: {
static?: string[]
dynamic?: string[]
buckets?: Record<string, string[]>
}
searchResults?: {
results: SupermemoryProfileSearchResult[]
total: number
timing: number
}
}
const extractTextContent = (content: unknown): string => {
if (typeof content === "string") return content.trim()
if (!Array.isArray(content)) return ""
return content
.flatMap((part) => {
if (!part || typeof part !== "object") return []
const { type, text } = part as { type?: unknown; text?: unknown }
if (
(type === "text" || type === "input_text") &&
typeof text === "string" &&
text.trim()
) {
return [text.trim()]
}
return []
})
.join("\n")
}
const convertConversationContent = (
content: unknown,
): string | ConversationContentPart[] => {
@ -64,27 +125,263 @@ const convertConversationContent = (
return converted
}
export interface OpenAIMiddlewareOptions {
/** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */
containerTag: string
/** Custom ID to group messages into a single document. Required. */
customId: string
verbose?: boolean
mode?: "profile" | "query" | "full"
addMemory?: "always" | "never"
/** Supermemory API key (falls back to SUPERMEMORY_API_KEY). */
apiKey?: string
baseUrl?: string
const convertChatConversationMessages = (
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
): ConversationMessage[] => {
return messages.map((message) => ({
role:
message.role === "developer"
? "system"
: message.role === "function"
? "tool"
: message.role,
content: convertConversationContent(message.content),
...("name" in message && message.name && { name: message.name }),
...("tool_calls" in message &&
message.tool_calls && { tool_calls: message.tool_calls }),
...("tool_call_id" in message &&
message.tool_call_id && { tool_call_id: message.tool_call_id }),
}))
}
interface SupermemoryProfileSearch {
profile: {
static?: Array<{ memory: string; metadata?: Record<string, unknown> }>
dynamic?: Array<{ memory: string; metadata?: Record<string, unknown> }>
const convertResponsesConversationMessages = (
input: unknown,
): ConversationMessage[] => {
if (typeof input === "string") {
return input.trim() ? [{ role: "user", content: input }] : []
}
searchResults: {
results: Array<{ memory: string; metadata?: Record<string, unknown> }>
if (!Array.isArray(input)) return []
const messages: ConversationMessage[] = []
for (const item of input) {
if (!item || typeof item !== "object") continue
const structuredItem = item as {
type?: unknown
call_id?: unknown
name?: unknown
arguments?: unknown
output?: unknown
}
if (
structuredItem.type === "function_call" &&
typeof structuredItem.call_id === "string" &&
typeof structuredItem.name === "string" &&
typeof structuredItem.arguments === "string"
) {
messages.push({
role: "assistant",
content: "",
tool_calls: [
{
id: structuredItem.call_id,
type: "function",
function: {
name: structuredItem.name,
arguments: structuredItem.arguments,
},
},
],
})
continue
}
if (
structuredItem.type === "function_call_output" &&
typeof structuredItem.call_id === "string" &&
typeof structuredItem.output === "string"
) {
messages.push({
role: "tool",
content: structuredItem.output,
tool_call_id: structuredItem.call_id,
})
continue
}
const message = item as { role?: unknown; content?: unknown }
if (
message.role !== "user" &&
message.role !== "assistant" &&
message.role !== "system" &&
message.role !== "developer"
) {
continue
}
const role = message.role === "developer" ? "system" : message.role
if (typeof message.content === "string") {
if (message.content.trim())
messages.push({ role, content: message.content })
continue
}
if (!Array.isArray(message.content)) continue
const content: ConversationContentPart[] = []
for (const part of message.content) {
if (!part || typeof part !== "object") continue
const value = part as {
type?: unknown
text?: unknown
image_url?: unknown
}
if (
(value.type === "text" ||
value.type === "input_text" ||
value.type === "output_text") &&
typeof value.text === "string" &&
value.text
) {
content.push({ type: "text", text: value.text })
} else if (value.type === "input_image") {
const url = toConversationImageUrl(value.image_url)
if (url) content.push({ type: "image_url", imageUrl: { url } })
}
}
if (content.length > 0) messages.push({ role, content })
}
return messages
}
const hasPersistableUserConversationMessage = (
messages: ConversationMessage[],
): boolean => {
return messages.some(
(message) =>
message.role === "user" &&
(typeof message.content === "string"
? Boolean(message.content.trim())
: message.content.length > 0),
)
}
const getLastResponsesUserInput = (input: unknown): string => {
if (typeof input === "string") return input.trim()
if (!Array.isArray(input)) return ""
for (let index = input.length - 1; index >= 0; index -= 1) {
const item = input[index]
if (!item || typeof item !== "object") continue
const message = item as { role?: unknown; content?: unknown }
if (message.role === "user") {
return extractTextContent(message.content)
}
}
return ""
}
const stripResponsesInputMemoryContexts = <T>(input: T): T => {
if (!Array.isArray(input)) return input
let inputChanged = false
const cleanedInput = input.map((item) => {
if (!item || typeof item !== "object") return item
const message = item as { role?: unknown; content?: unknown }
if (message.role !== "system" && message.role !== "developer") return item
if (typeof message.content === "string") {
const content = stripMemoryContext(message.content)
if (content === message.content) return item
inputChanged = true
return { ...item, content }
}
if (!Array.isArray(message.content)) return item
let contentChanged = false
const content = message.content.map((part) => {
if (!part || typeof part !== "object") return part
const textPart = part as { type?: unknown; text?: unknown }
if (
(textPart.type !== "text" && textPart.type !== "input_text") ||
typeof textPart.text !== "string"
) {
return part
}
const text = stripMemoryContext(textPart.text)
if (text === textPart.text) return part
contentChanged = true
return { ...part, text }
})
if (!contentChanged) return item
inputChanged = true
return { ...item, content }
})
return (inputChanged ? cleanedInput : input) as T
}
const getSearchResultMemories = (
results: SupermemoryProfileSearchResult[] | undefined,
): string[] => {
return (results ?? []).flatMap((result) => {
for (const value of [result.memory, result.chunk]) {
if (typeof value === "string" && value.trim()) return [value.trim()]
}
return []
})
}
type ChatInstructionMessage =
| OpenAI.Chat.Completions.ChatCompletionDeveloperMessageParam
| OpenAI.Chat.Completions.ChatCompletionSystemMessageParam
const isChatInstructionMessage = (
message: OpenAI.Chat.Completions.ChatCompletionMessageParam,
): message is ChatInstructionMessage =>
message.role === "developer" || message.role === "system"
const updateInstructionMessageMemoryContext = (
message: ChatInstructionMessage,
memories?: string,
): ChatInstructionMessage => {
if (typeof message.content === "string") {
return {
...message,
content:
memories === undefined
? stripMemoryContext(message.content)
: replaceMemoryContext(message.content, memories),
}
}
let injected = false
const content = message.content.map((part) => {
if (memories !== undefined && !injected) {
injected = true
return { ...part, text: replaceMemoryContext(part.text, memories) }
}
return { ...part, text: stripMemoryContext(part.text) }
})
if (memories !== undefined && !injected) {
const memoryContext = wrapMemoryContext(memories)
if (memoryContext) content.push({ type: "text", text: memoryContext })
}
return { ...message, content }
}
const updateChatMemoryContexts = (
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
memories?: string,
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] => {
const developerIndex = messages.findIndex(
(message) => message.role === "developer",
)
const injectionIndex =
developerIndex >= 0
? developerIndex
: messages.findIndex((message) => message.role === "system")
return messages.map((message, index) => {
if (!isChatInstructionMessage(message)) return message
return updateInstructionMessageMemoryContext(
message,
memories !== undefined && index === injectionIndex ? memories : undefined,
)
})
}
/**
@ -117,9 +414,7 @@ const getLastUserMessage = (
.reverse()
.find((msg) => msg.role === "user")
return typeof lastUserMessage?.content === "string"
? lastUserMessage.content
: ""
return extractTextContent(lastUserMessage?.content)
}
/**
@ -229,7 +524,7 @@ const addSystemPrompt = async (
apiKey: string,
baseUrl: string,
) => {
const systemPromptExists = messages.some((msg) => msg.role === "system")
const instructionPromptExists = messages.some(isChatInstructionMessage)
const queryText = mode !== "profile" ? getLastUserMessage(messages) : ""
@ -255,7 +550,9 @@ const addSystemPrompt = async (
const deduplicated = deduplicateMemoriesForMode(mode, {
static: memoriesResponse.profile.static,
dynamic: memoriesResponse.profile.dynamic,
searchResults: memoriesResponse.searchResults?.results,
searchResults: getSearchResultMemories(
memoriesResponse.searchResults?.results,
),
})
logger.debug("Memory deduplication completed for chat API", {
@ -284,7 +581,7 @@ const addSystemPrompt = async (
})
: ""
const searchResultsMemories =
mode !== "profile"
mode !== "profile" && deduplicated.searchResults.length > 0
? `Search results for user's recent message: \n${deduplicated.searchResults
.map((memory) => `- ${memory}`)
.join("\n")}`
@ -299,19 +596,18 @@ 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,
)
if (instructionPromptExists) {
logger.debug("Replaced Supermemory context in existing instruction prompt")
return updateChatMemoryContexts(messages, memories)
}
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
}
/**
@ -342,7 +638,7 @@ const getConversationContent = (
return messages
.map((msg) => {
const role = msg.role === "user" ? "User" : "Assistant"
const content = typeof msg.content === "string" ? msg.content : ""
const content = extractTextContent(msg.content)
return `${role}: ${content}`
})
.join("\n\n")
@ -362,7 +658,7 @@ const getConversationContent = (
* @param content - The content to save as a memory (used for fallback)
* @param customId - Optional custom ID for the memory (e.g., conversation:456)
* @param logger - Logger instance for debugging and info output
* @param messages - Optional OpenAI messages array (for conversation endpoint)
* @param conversationMessages - Optional normalized messages (for conversation endpoint)
* @param apiKey - API key for direct conversation endpoint calls
* @param baseUrl - Base URL for API calls
* @returns Promise that resolves when memory is saved (or fails silently)
@ -387,34 +683,14 @@ const addMemoryTool = async (
content: string,
customId: string | undefined,
logger: Logger,
messages?: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
conversationMessages?: ConversationMessage[],
apiKey?: string,
baseUrl?: string,
): Promise<void> => {
try {
if (customId && messages && apiKey) {
if (customId && conversationMessages && apiKey) {
const conversationId = customId.replace("conversation:", "")
// Convert OpenAI messages to conversation format
const conversationMessages: ConversationMessage[] = messages.map(
(msg) => ({
role:
msg.role === "developer"
? "system"
: msg.role === "function"
? "tool"
: msg.role,
content: convertConversationContent(msg.content),
...("name" in msg && msg.name && { name: msg.name }),
...("tool_calls" in msg &&
msg.tool_calls && { tool_calls: msg.tool_calls }),
...("tool_call_id" in msg &&
msg.tool_call_id && {
tool_call_id: msg.tool_call_id,
}),
}),
)
const response = await addConversation({
conversationId,
messages: conversationMessages,
@ -426,7 +702,7 @@ const addMemoryTool = async (
logger.info("Conversation saved successfully via /v4/conversations", {
containerTag,
customId,
messageCount: messages.length,
messageCount: conversationMessages.length,
responseId: response.id,
})
return
@ -548,7 +824,9 @@ export function createOpenAIMiddleware(
const deduplicated = deduplicateMemoriesForMode(mode, {
static: memoriesResponse.profile.static,
dynamic: memoriesResponse.profile.dynamic,
searchResults: memoriesResponse.searchResults?.results,
searchResults: getSearchResultMemories(
memoriesResponse.searchResults?.results,
),
})
logger.debug(`Memory deduplication completed for ${context} API`, {
@ -577,7 +855,7 @@ export function createOpenAIMiddleware(
})
: ""
const searchResultsMemories =
mode !== "profile"
mode !== "profile" && deduplicated.searchResults.length > 0
? `Search results for user's ${context === "chat" ? "recent message" : "input"}: \n${deduplicated.searchResults
.map((memory) => `- ${memory}`)
.join("\n")}`
@ -605,14 +883,45 @@ export function createOpenAIMiddleware(
)
}
const input = typeof params.input === "string" ? params.input : ""
const input = getLastResponsesUserInput(params.input)
const cleanedInput = stripResponsesInputMemoryContexts(params.input)
const conversationMessages =
convertResponsesConversationMessages(cleanedInput)
const shouldPersist =
addMemory === "always" &&
(customId
? hasPersistableUserConversationMessage(conversationMessages)
: Boolean(input.trim()))
const memoryCustomId = customId ? `conversation:${customId}` : undefined
const persistResponsesInput = () =>
addMemoryTool(
client,
containerTag,
input,
memoryCustomId,
logger,
conversationMessages,
apiKey,
baseUrl,
)
if (mode !== "profile" && !input) {
logger.debug("No input found for Responses API, skipping memory search")
if (shouldPersist) await persistResponsesInput()
logger.debug(
"No textual user input found for Responses API, skipping memory search",
)
const cleanedParams = {
...params,
input: cleanedInput,
...(typeof params.instructions === "string"
? { instructions: stripMemoryContext(params.instructions) }
: {}),
}
return {
request: originalResponsesCreate.call(
openaiClient.responses,
params,
cleanedParams,
requestOptions,
),
}
@ -626,14 +935,7 @@ export function createOpenAIMiddleware(
const operations: Promise<unknown>[] = []
if (addMemory === "always" && input?.trim()) {
const content = customId ? `Input: ${input}` : input
const memoryCustomId = customId ? `conversation:${customId}` : undefined
operations.push(
addMemoryTool(client, containerTag, content, memoryCustomId, logger),
)
}
if (shouldPersist) operations.push(persistResponsesInput())
const queryText = mode !== "profile" ? input : ""
operations.push(
@ -646,18 +948,34 @@ export function createOpenAIMiddleware(
),
)
const results = await Promise.all(operations)
const memories = results[results.length - 1] // Memory search result is always last
let enhancedInstructions: string
try {
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
enhancedInstructions = replaceMemoryContext(
params.instructions || "",
typeof memories === "string" ? memories : "",
)
} catch (error) {
logger.warn(
"Memory search failed for Responses API; continuing without stale Supermemory context",
{
error: error instanceof Error ? error.message : "Unknown error",
},
)
enhancedInstructions =
typeof params.instructions === "string"
? stripMemoryContext(params.instructions)
: ""
}
return {
request: originalResponsesCreate.call(
openaiClient.responses,
{
...params,
input: cleanedInput,
instructions: enhancedInstructions,
},
requestOptions,
@ -676,10 +994,14 @@ export function createOpenAIMiddleware(
) => {
const messages = Array.isArray(params.messages) ? params.messages : []
const userMessage = getLastUserMessage(messages)
const hasUserMessage = messages.some((message) => message.role === "user")
const conversationMessages = convertChatConversationMessages(
updateChatMemoryContexts(messages),
)
const shouldPersist =
addMemory === "always" &&
(customId ? hasUserMessage : Boolean(userMessage.trim()))
(customId
? hasPersistableUserConversationMessage(conversationMessages)
: Boolean(userMessage.trim()))
const memoryContent = customId
? getConversationContent(messages)
: userMessage
@ -693,7 +1015,7 @@ export function createOpenAIMiddleware(
memoryContent,
memoryCustomId,
logger,
messages,
conversationMessages,
apiKey,
baseUrl,
)
@ -702,7 +1024,10 @@ export function createOpenAIMiddleware(
return {
request: originalCreate.call(
openaiClient.chat.completions,
params,
{
...params,
messages: updateChatMemoryContexts(messages),
},
requestOptions,
),
}
@ -724,7 +1049,7 @@ export function createOpenAIMiddleware(
memoryContent,
memoryCustomId,
logger,
messages,
conversationMessages,
apiKey,
baseUrl,
),
@ -735,10 +1060,21 @@ export function createOpenAIMiddleware(
addSystemPrompt(messages, containerTag, logger, mode, apiKey, baseUrl),
)
const results = await Promise.all(operations)
const enhancedMessages = results[
results.length - 1
] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last
let enhancedMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]
try {
const results = await Promise.all(operations)
enhancedMessages = results[
results.length - 1
] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last
} catch (error) {
logger.warn(
"Memory search failed for Chat Completions API; continuing without stale Supermemory context",
{
error: error instanceof Error ? error.message : "Unknown error",
},
)
enhancedMessages = updateChatMemoryContexts(messages)
}
return {
request: originalCreate.call(

View file

@ -1,6 +1,8 @@
// Types
export type {
MemoryPromptData,
MemorySearchResult,
ProfileSearchResult,
PromptTemplate,
MemoryMode,
AddMemoryMode,
@ -40,3 +42,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

@ -1,4 +1,8 @@
import { deduplicateMemoriesForMode } from "../tools-shared"
import {
deduplicateMemoriesForMode,
getMemoryText,
normalizeMemoryFact,
} from "../tools-shared"
import type {
Logger,
MemoryMode,
@ -121,10 +125,11 @@ export const buildMemoriesText = async (
mode,
})
const rawSearchResults = memoriesResponse.searchResults?.results ?? []
const deduplicated = deduplicateMemoriesForMode(mode, {
static: memoriesResponse.profile.static,
dynamic: memoriesResponse.profile.dynamic,
searchResults: memoriesResponse.searchResults?.results,
searchResults: rawSearchResults,
})
logger.debug("Memory deduplication completed", {
@ -153,16 +158,28 @@ export const buildMemoriesText = async (
})
: ""
const generalSearchMemories =
mode !== "profile"
mode !== "profile" && deduplicated.searchResults.length > 0
? `Search results for user's recent message: \n${deduplicated.searchResults
.map((memory) => `- ${memory}`)
.join("\n")}`
: ""
const visibleSearchKeys = new Set(
deduplicated.searchResults.map(normalizeMemoryFact),
)
const seenSearchKeys = new Set<string>()
const deduplicatedSearchResults = rawSearchResults.flatMap((result) => {
const memory = getMemoryText(result)
if (!memory) return []
const key = normalizeMemoryFact(memory)
if (!visibleSearchKeys.has(key) || seenSearchKeys.has(key)) return []
seenSearchKeys.add(key)
return [{ ...result, memory }]
})
const promptData: MemoryPromptData = {
userMemories,
generalSearchMemories,
searchResults: memoriesResponse.searchResults?.results ?? [],
searchResults: deduplicatedSearchResults,
}
const memories = promptTemplate(promptData)

View file

@ -0,0 +1,41 @@
export const MEMORY_CONTEXT_START =
'<supermemory context="user-memories" readonly>'
export const MEMORY_CONTEXT_END = "</supermemory>"
const MEMORY_CONTEXT_PATTERN =
/(?:\r?\n)?<supermemory context="user-memories" readonly>[\s\S]*?<\/supermemory>/g
const SUPERMEMORY_TAG_PATTERN = /<\s*\/?\s*supermemory\b[^>]*>/gi
/** Prevent retrieved text from terminating or nesting the SDK-owned block. */
function escapeMemoryContextDelimiters(memories: string): string {
return memories.replace(SUPERMEMORY_TAG_PATTERN, (tag) =>
tag.replace("<", "&lt;").replace(">", "&gt;"),
)
}
/** Remove every context block previously owned by the Supermemory middleware. */
export function stripMemoryContext(content: string): string {
return content.replace(MEMORY_CONTEXT_PATTERN, "")
}
/** 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 ""
const escaped = escapeMemoryContextDelimiters(normalized)
return `${MEMORY_CONTEXT_START}\n${escaped}\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
// The newline belongs to the SDK-owned block and is removed with it, so caller
// whitespace round-trips while Markdown/XML boundaries remain valid.
return preserved ? `${preserved}\n${memoryContext}` : memoryContext
}

View file

@ -7,8 +7,10 @@ import type {
/**
* Default prompt template that formats memories in the original "User Supermemories" format.
*/
export const defaultPromptTemplate: PromptTemplate = (data) =>
`User Supermemories: \n${data.userMemories}\n${data.generalSearchMemories}`.trim()
export const defaultPromptTemplate: PromptTemplate = (data) => {
if (!data.userMemories.trim() && !data.generalSearchMemories.trim()) return ""
return `User Supermemories: \n${data.userMemories}\n${data.generalSearchMemories}`.trim()
}
/**
* Convert profile data to markdown format with sections for static and dynamic memories.

View file

@ -14,11 +14,28 @@ export interface MemoryPromptData {
*/
generalSearchMemories: string
/**
* Raw search results from the API for the current query.
* Use this to traverse, filter, or selectively include results based on metadata.
* Empty array if mode is "profile" or when no search was performed.
* Metadata-preserving search results that remain after cross-source deduplication.
* Use this to traverse, filter, or selectively include visible results.
* The runtime always supplies an array (empty in profile mode or when no search
* was performed).
*/
searchResults: Array<{ memory: string; metadata?: Record<string, unknown> }>
searchResults: MemorySearchResult[]
}
/** A raw query result returned inside `/v4/profile.searchResults.results`. */
export interface ProfileSearchResult {
id: string
memory?: string
chunk?: string
metadata: Record<string, unknown> | null
updatedAt: string
similarity: number
}
/** A visible, deduplicated query result provided to prompt templates. */
export interface MemorySearchResult
extends Omit<ProfileSearchResult, "memory"> {
memory: string
}
/**
@ -73,19 +90,23 @@ export interface ProfileStructure {
* Core, stable facts about the user that rarely change.
* Examples: name, profession, long-term preferences, goals.
*/
static?: Array<{ memory: string; metadata?: Record<string, unknown> }>
static?: string[]
/**
* Recently learned or frequently updated information about the user.
* Examples: current projects, recent interests, ongoing topics.
*/
dynamic?: Array<{ memory: string; metadata?: Record<string, unknown> }>
dynamic?: string[]
/** Memories grouped by custom profile bucket. */
buckets?: Record<string, string[]>
}
searchResults: {
searchResults?: {
/**
* Memories retrieved based on semantic similarity to the current query.
* Most relevant to the immediate conversation context.
*/
results: Array<{ memory: string; metadata?: Record<string, unknown> }>
results: ProfileSearchResult[]
total: number
timing: number
}
}
@ -99,7 +120,7 @@ export interface ProfileMarkdownData {
/** Recently learned or updated information (current projects, interests) */
dynamic?: string[]
}
searchResults: {
searchResults?: {
/** Query-relevant memories based on semantic similarity */
results: Array<{ memory: string }>
}

View file

@ -87,6 +87,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

@ -291,13 +291,17 @@ function hasCompleteContainerTagScope(
* Memory item interface representing a single memory with optional metadata
*/
export interface MemoryItem {
memory: string
metadata?: Record<string, unknown>
memory?: string
chunk?: string
metadata?: Record<string, unknown> | null
}
/**
* Profile data structure containing memory items from different sources.
* API may return either MemoryItem objects or plain strings.
* Profile data from `/v4/profile`.
*
* Current profile arrays contain plain strings and search results contain
* MemoryItem objects. Object profile entries and string search entries remain
* accepted for compatibility with older API responses and SDK fixtures.
*/
export interface ProfileWithMemories {
static?: Array<MemoryItem | string>
@ -314,6 +318,32 @@ export interface DeduplicatedMemories {
searchResults: string[]
}
/** Normalize exact fact variants without attempting semantic/fuzzy matching. */
export function normalizeMemoryFact(memory: string): string {
return memory
.trim()
.replace(/^\[recent\]\s*/i, "")
.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, "")
.trim()
.replace(/\s+/g, " ")
.toLowerCase()
}
/** Extract the first non-empty fact from current memory or chunk result shapes. */
export function getMemoryText(item: MemoryItem | string): string | null {
if (typeof item === "string") {
const trimmed = item.trim()
return trimmed.length > 0 ? trimmed : null
}
for (const value of [item.memory, item.chunk]) {
if (typeof value !== "string") continue
const trimmed = value.trim()
if (trimmed) return trimmed
}
return null
}
/**
* Deduplicates memory items across static, dynamic, and search result sources.
* Priority: Static > Dynamic > Search Results
@ -324,8 +354,8 @@ export interface DeduplicatedMemories {
* @example
* ```typescript
* const deduplicated = deduplicateMemories({
* static: [{ memory: "User likes TypeScript" }],
* dynamic: [{ memory: "User likes TypeScript" }, { memory: "User works remotely" }],
* static: ["User likes TypeScript"],
* dynamic: ["User likes TypeScript", "User works remotely"],
* searchResults: [{ memory: "User prefers async/await" }]
* });
* // Returns:
@ -343,46 +373,37 @@ export function deduplicateMemories(
const dynamicItems = data.dynamic ?? []
const searchItems = data.searchResults ?? []
const getMemoryString = (item: MemoryItem | string): string | null => {
if (!item) return null
// Handle both string format (from API) and object format
if (typeof item === "string") {
const trimmed = item.trim()
return trimmed.length > 0 ? trimmed : null
}
if (typeof item.memory !== "string") return null
const trimmed = item.memory.trim()
return trimmed.length > 0 ? trimmed : null
}
const staticMemories: string[] = []
const seenMemories = new Set<string>()
for (const item of staticItems as Array<MemoryItem | string>) {
const memory = getMemoryString(item)
if (memory !== null) {
const memory = getMemoryText(item)
const key = memory === null ? null : normalizeMemoryFact(memory)
if (memory !== null && key && !seenMemories.has(key)) {
staticMemories.push(memory)
seenMemories.add(memory)
seenMemories.add(key)
}
}
const dynamicMemories: string[] = []
for (const item of dynamicItems as Array<MemoryItem | string>) {
const memory = getMemoryString(item)
if (memory !== null && !seenMemories.has(memory)) {
const memory = getMemoryText(item)
const key = memory === null ? null : normalizeMemoryFact(memory)
if (memory !== null && key && !seenMemories.has(key)) {
dynamicMemories.push(memory)
seenMemories.add(memory)
seenMemories.add(key)
}
}
const searchMemories: string[] = []
for (const item of searchItems as Array<MemoryItem | string>) {
const memory = getMemoryString(item)
if (memory !== null && !seenMemories.has(memory)) {
const memory = getMemoryText(item)
const key = memory === null ? null : normalizeMemoryFact(memory)
if (memory !== null && key && !seenMemories.has(key)) {
searchMemories.push(memory)
seenMemories.add(memory)
seenMemories.add(key)
}
}

View file

@ -11,6 +11,7 @@ import {
saveMemoryAfterResponse,
} from "./middleware"
import type { PromptTemplate, MemoryPromptData } from "./memory-prompt"
import { injectMemoriesIntoParams } from "./memory-prompt"
const DEFAULT_MEMORY_RETRIEVAL_TIMEOUT_MS = 5000
@ -166,7 +167,7 @@ const wrapVercelLanguageModel = <T extends LanguageModel>(
: "Unknown error",
},
)
modelParams = params
modelParams = injectMemoriesIntoParams(params, "", ctx.logger)
} else {
ctx.logger.error("Error during memory retrieval for generation", {
error:
@ -230,7 +231,7 @@ const wrapVercelLanguageModel = <T extends LanguageModel>(
: "Unknown error",
},
)
modelParams = params
modelParams = injectMemoriesIntoParams(params, "", ctx.logger)
} else {
ctx.logger.error("Error during memory retrieval for stream", {
error:

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

@ -321,8 +321,10 @@ export const transformParamsWithMemory = async (
if (ctx.mode !== "profile") {
if (!userMessage) {
ctx.logger.debug("No user message found, skipping memory search")
return params
ctx.logger.debug(
"No user message found, skipping memory search and clearing stale context",
)
return injectMemoriesIntoParams(params, "", ctx.logger)
}
}

View file

@ -16,10 +16,22 @@ import {
normalizeBaseUrl,
MemoryCache,
buildMemoriesText,
convertProfileToMarkdown,
defaultPromptTemplate,
extractQueryText,
replaceMemoryContext,
stripMemoryContext,
supermemoryProfileSearch,
wrapMemoryContext,
type Logger,
type MemoryMode,
type PromptTemplate,
} from "../shared"
import {
deduplicateMemoriesForMode,
getMemoryText,
normalizeMemoryFact,
} from "../tools-shared"
import type {
SearchFilters,
SupermemoryVoltAgent,
@ -38,11 +50,7 @@ export interface SupermemoryMiddlewareContext {
addMemory: "always" | "never"
normalizedBaseUrl: string
apiKey: string
promptTemplate?: (data: {
userMemories: string
generalSearchMemories: string
searchResults: Array<{ memory: string; metadata?: Record<string, unknown> }>
}) => string
promptTemplate?: PromptTemplate
/**
* Per-turn memory cache. Stores the injected memories string for each
* user turn (keyed by turnKey) to avoid redundant API calls.
@ -176,12 +184,6 @@ const isNewUserTurn = (messages: VoltAgentMessage[]): boolean => {
return lastMessage?.role === "user"
}
type VoltAgentContentPart = {
type: string
text?: string
[key: string]: unknown
}
const getMessageContent = (
message: VoltAgentMessage,
): string | VoltAgentContentPart[] => {
@ -240,7 +242,7 @@ export const enhanceMessagesWithMemories = async (
if (ctx.mode !== "profile" && !userMessage) {
ctx.logger.debug("No user message found, skipping memory search")
return messagesToEnhance
return injectMemoriesIntoMessages(messagesToEnhance, "", ctx.logger)
}
const turnKey = makeTurnKey(ctx, userMessage || "")
@ -288,55 +290,95 @@ export const enhanceMessagesWithMemories = async (
)
}
let memories: string
const memories = await (async (): Promise<string> => {
if (useAdvancedSearch && ctx.mode !== "profile") {
ctx.logger.info("Using advanced search with custom parameters")
if (useAdvancedSearch && ctx.mode !== "profile") {
ctx.logger.info("Using advanced search with custom parameters")
const searchParams: Supermemory.SearchParams = {
q: queryText,
containerTag: ctx.containerTag,
}
if (ctx.threshold !== undefined) searchParams.threshold = ctx.threshold
if (ctx.limit !== undefined) searchParams.limit = ctx.limit
if (ctx.rerank !== undefined) searchParams.rerank = ctx.rerank
if (ctx.rewriteQuery !== undefined)
searchParams.rewriteQuery = ctx.rewriteQuery
if (ctx.filters !== undefined) searchParams.filters = ctx.filters
if (ctx.include !== undefined) searchParams.include = ctx.include
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
const response = await ctx.client.search(searchParams)
// Hybrid search returns both memory entries (`memory` field) and
// document chunks (`chunk` field). Normalize both for prompt templates.
const searchResults = response.results.flatMap((result) => {
const memory = result.memory ?? result.chunk
if (!memory) {
return []
const searchParams: Supermemory.SearchParams = {
q: queryText,
containerTag: ctx.containerTag,
}
return [
{
memory,
...(result.metadata ? { metadata: result.metadata } : {}),
},
]
})
const formattedMemories = searchResults
.map((result) => `- ${result.memory}`)
.join("\n")
if (ctx.threshold !== undefined) searchParams.threshold = ctx.threshold
if (ctx.limit !== undefined) searchParams.limit = ctx.limit
if (ctx.rerank !== undefined) searchParams.rerank = ctx.rerank
if (ctx.rewriteQuery !== undefined)
searchParams.rewriteQuery = ctx.rewriteQuery
if (ctx.filters !== undefined) searchParams.filters = ctx.filters
if (ctx.include !== undefined) searchParams.include = ctx.include
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
memories = ctx.promptTemplate
? ctx.promptTemplate({
userMemories: "",
generalSearchMemories: formattedMemories,
searchResults,
const [response, profileResponse] = await Promise.all([
ctx.client.search(searchParams),
ctx.mode === "full"
? supermemoryProfileSearch(
ctx.containerTag,
"",
ctx.normalizedBaseUrl,
ctx.apiKey,
)
: Promise.resolve(undefined),
])
// Hybrid search returns both memory entries (`memory` field) and
// document chunks (`chunk` field). Normalize both for prompt templates.
const searchResults = response.results.flatMap((result) => {
const memory = getMemoryText(result)
if (!memory) {
return []
}
return [{ ...result, memory }]
})
const deduplicated = deduplicateMemoriesForMode(ctx.mode, {
static: profileResponse?.profile.static,
dynamic: profileResponse?.profile.dynamic,
searchResults,
})
const searchResultByKey = new Map<
string,
(typeof searchResults)[number]
>()
for (const result of searchResults) {
const key = normalizeMemoryFact(result.memory)
if (!searchResultByKey.has(key)) {
searchResultByKey.set(key, result)
}
}
const deduplicatedSearchResults = deduplicated.searchResults
.map((memory) => {
const original = searchResultByKey.get(normalizeMemoryFact(memory))
return original ? { ...original, memory } : undefined
})
: `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}`
} else {
memories = await buildMemoriesText({
.filter((result) => result !== undefined)
const userMemories = convertProfileToMarkdown({
profile: {
static: deduplicated.static,
dynamic: deduplicated.dynamic,
},
searchResults: { results: [] },
})
const generalSearchMemories =
deduplicated.searchResults.length > 0
? `Search results for user's recent message: \n${deduplicated.searchResults
.map((memory) => `- ${memory}`)
.join("\n")}`
: ""
ctx.logger.debug("Advanced memory deduplication completed", {
profileStatic: deduplicated.static.length,
profileDynamic: deduplicated.dynamic.length,
searchResults: deduplicated.searchResults.length,
})
return (ctx.promptTemplate ?? defaultPromptTemplate)({
userMemories,
generalSearchMemories,
searchResults: deduplicatedSearchResults,
})
}
return await buildMemoriesText({
containerTag: ctx.containerTag,
queryText,
mode: ctx.mode,
@ -345,7 +387,12 @@ export const enhanceMessagesWithMemories = async (
logger: ctx.logger,
promptTemplate: ctx.promptTemplate,
})
}
})().catch((error) => {
ctx.logger.error("Error fetching memories", {
error: error instanceof Error ? error.message : "Unknown error",
})
return ""
})
ctx.memoryCache.set(turnKey, memories)
ctx.logger.debug("Cached memories for turn", { turnKey })
@ -357,56 +404,117 @@ export const enhanceMessagesWithMemories = async (
* Injects memories into messages by appending to existing system prompt
* or creating a new one. Pure function - does not mutate the original messages.
*
* VoltAgent uses AI SDK v5's UIMessage format which requires `id` and `parts`
* VoltAgent uses AI SDK v6's UIMessage format which requires `id` and `parts`
* (not just `content`). We must conform to this format for messages to
* actually reach the LLM.
*/
type VoltAgentContentPart = {
type: string
text?: string
[key: string]: unknown
}
const replaceMemoryContextInParts = (
parts: VoltAgentContentPart[],
memories: string,
shouldInject: boolean,
fallbackText = "",
): VoltAgentContentPart[] => {
let injected = false
const updatedParts = parts.map((part) => {
if (part.type !== "text" || typeof part.text !== "string") {
return part
}
const text =
shouldInject && !injected
? replaceMemoryContext(part.text, memories)
: stripMemoryContext(part.text)
injected = injected || shouldInject
return { ...part, text }
})
if (shouldInject && !injected) {
const text = fallbackText
? replaceMemoryContext(fallbackText, memories)
: wrapMemoryContext(memories)
if (text) {
return [{ type: "text", text }, ...updatedParts]
}
}
return updatedParts
}
const updateSystemMessage = (
message: VoltAgentMessage,
memories: string,
shouldInject: boolean,
): VoltAgentMessage => {
const content = message.content
const nextContent =
typeof content === "string"
? shouldInject
? replaceMemoryContext(content, memories)
: stripMemoryContext(content)
: Array.isArray(content)
? replaceMemoryContextInParts(content, memories, shouldInject)
: undefined
const contentText =
typeof nextContent === "string"
? nextContent
: (nextContent ?? [])
.filter(
(part) => part.type === "text" && typeof part.text === "string",
)
.map((part) => part.text || "")
.join("\n")
const parts = message.parts
const nextParts = Array.isArray(parts)
? replaceMemoryContextInParts(parts, memories, shouldInject, contentText)
: shouldInject
? contentText || wrapMemoryContext(memories)
? [
{
type: "text",
text: contentText || wrapMemoryContext(memories),
},
]
: []
: undefined
return {
...message,
...(Object.hasOwn(message, "content") ? { content: nextContent } : {}),
...(Array.isArray(parts) || nextParts ? { parts: nextParts ?? [] } : {}),
}
}
const injectMemoriesIntoMessages = (
messages: VoltAgentMessage[],
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 updated = updateSystemMessage(message, memories, !injected)
injected = true
return updated
})
}
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,
]
@ -432,7 +540,9 @@ const convertToConversationMessages = (
typeof mediaType === "string" && mediaType.startsWith("image/")
? toConversationImageUrl(part.url ?? part.data, mediaType)
: null
if (url) return { type: "image_url", imageUrl: { url } }
if (url) {
return { type: "image_url", imageUrl: { url } }
}
}
if (part.type === "image") {

View file

@ -77,6 +77,7 @@ const createIntegrationMessageList = (): MessageList & {
const calls: { method: string; args: unknown[] }[] = []
return {
calls,
clearSystemMessages: vi.fn(),
addSystem: vi.fn((content: string, id?: string) => {
calls.push({ method: "addSystem", args: [content, id] })
}),

View file

@ -73,6 +73,7 @@ const createMockMessageList = (): MessageList & {
const calls: { method: string; args: unknown[] }[] = []
return {
calls,
clearSystemMessages: vi.fn(),
addSystem: vi.fn((content: string, _id?: string) => {
calls.push({ method: "addSystem", args: [content, _id] })
}),
@ -198,6 +199,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,65 @@
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(() =>
Object.assign(Promise.resolve({ choices: [] }), {
asResponse: async () => new Response(),
}),
)
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", () => {