From d0f53b0d6465f2ce5d2b319e44f44fcbbe58d728 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Tue, 1 Sep 2026 06:10:36 +0000 Subject: [PATCH] feat(tools): SDK-level cross-source memory deduplication (#1531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` 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) --- > [!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 `` block:** New `shared/memory-context.ts` wraps memories in ``, 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. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2fa2e0d85cc02d9859b4c7b9b13ed41e2291b950. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- bun.lock | 2 +- packages/tools/package.json | 4 +- packages/tools/src/mastra/processor.ts | 10 +- packages/tools/src/openai/middleware.ts | 502 +++++++++++++++--- packages/tools/src/shared/index.ts | 11 + packages/tools/src/shared/memory-client.ts | 25 +- packages/tools/src/shared/memory-context.ts | 41 ++ packages/tools/src/shared/prompt-builder.ts | 6 +- packages/tools/src/shared/types.ts | 39 +- packages/tools/src/tools-shared.test.ts | 17 + packages/tools/src/tools-shared.ts | 75 ++- packages/tools/src/vercel/index.ts | 5 +- packages/tools/src/vercel/memory-prompt.ts | 29 +- packages/tools/src/vercel/middleware.ts | 6 +- packages/tools/src/voltagent/middleware.ts | 298 +++++++---- .../tools/test/mastra/integration.test.ts | 1 + packages/tools/test/mastra/unit.test.ts | 4 + .../tools/test/openai-middleware.unit.test.ts | 65 +++ packages/tools/test/voltagent.unit.test.ts | 50 ++ .../tools/test/with-supermemory/unit.test.ts | 115 ++++ 20 files changed, 1069 insertions(+), 236 deletions(-) create mode 100644 packages/tools/src/shared/memory-context.ts create mode 100644 packages/tools/test/openai-middleware.unit.test.ts create mode 100644 packages/tools/test/voltagent.unit.test.ts diff --git a/bun.lock b/bun.lock index a9fa8ab36..ab8d515d7 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/tools/package.json b/packages/tools/package.json index 74f2f5bca..05f6b6f26 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -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": { diff --git a/packages/tools/src/mastra/processor.ts b/packages/tools/src/mastra/processor.ts index e7a39e96b..412d1743e 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, @@ -138,6 +139,11 @@ export class SupermemoryInputProcessor implements Processor { async processInput(args: ProcessInputArgs): Promise { 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, }) diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 8edf9adc5..518db547a 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -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 = ( }) } +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 | null + updatedAt: string + similarity: number +} + +interface SupermemoryProfileSearch { + profile: { + static?: string[] + dynamic?: string[] + buckets?: Record + } + 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 }> - dynamic?: Array<{ memory: string; metadata?: Record }> +const convertResponsesConversationMessages = ( + input: unknown, +): ConversationMessage[] => { + if (typeof input === "string") { + return input.trim() ? [{ role: "user", content: input }] : [] + } + 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) + } } - searchResults: { - results: Array<{ memory: string; metadata?: Record }> + + return "" +} + +const stripResponsesInputMemoryContexts = (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 => { 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[] = [] - 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( diff --git a/packages/tools/src/shared/index.ts b/packages/tools/src/shared/index.ts index 5a6e0f7ba..602d9fe3d 100644 --- a/packages/tools/src/shared/index.ts +++ b/packages/tools/src/shared/index.ts @@ -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" diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index c477b36d4..5097f7a14 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -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() + 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) diff --git a/packages/tools/src/shared/memory-context.ts b/packages/tools/src/shared/memory-context.ts new file mode 100644 index 000000000..79fce72a2 --- /dev/null +++ b/packages/tools/src/shared/memory-context.ts @@ -0,0 +1,41 @@ +export const MEMORY_CONTEXT_START = + '' +export const MEMORY_CONTEXT_END = "" + +const MEMORY_CONTEXT_PATTERN = + /(?:\r?\n)?[\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("<", "<").replace(">", ">"), + ) +} + +/** 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 +} diff --git a/packages/tools/src/shared/prompt-builder.ts b/packages/tools/src/shared/prompt-builder.ts index a68d28820..0dddfd424 100644 --- a/packages/tools/src/shared/prompt-builder.ts +++ b/packages/tools/src/shared/prompt-builder.ts @@ -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. diff --git a/packages/tools/src/shared/types.ts b/packages/tools/src/shared/types.ts index 421785f52..f8551e123 100644 --- a/packages/tools/src/shared/types.ts +++ b/packages/tools/src/shared/types.ts @@ -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 }> + searchResults: MemorySearchResult[] +} + +/** A raw query result returned inside `/v4/profile.searchResults.results`. */ +export interface ProfileSearchResult { + id: string + memory?: string + chunk?: string + metadata: Record | null + updatedAt: string + similarity: number +} + +/** A visible, deduplicated query result provided to prompt templates. */ +export interface MemorySearchResult + extends Omit { + 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 }> + static?: string[] /** * Recently learned or frequently updated information about the user. * Examples: current projects, recent interests, ongoing topics. */ - dynamic?: Array<{ memory: string; metadata?: Record }> + dynamic?: string[] + /** Memories grouped by custom profile bucket. */ + buckets?: Record } - 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 }> + 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 }> } diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 82fb9dcce..3cc070ba7 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -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" }], diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index 14b0f274d..f4d150ca1 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -291,13 +291,17 @@ function hasCompleteContainerTagScope( * Memory item interface representing a single memory with optional metadata */ export interface MemoryItem { - memory: string - metadata?: Record + memory?: string + chunk?: string + metadata?: Record | 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 @@ -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() for (const item of staticItems as Array) { - 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) { - 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) { - 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) } } diff --git a/packages/tools/src/vercel/index.ts b/packages/tools/src/vercel/index.ts index 6de60e446..e5b98a9ad 100644 --- a/packages/tools/src/vercel/index.ts +++ b/packages/tools/src/vercel/index.ts @@ -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 = ( : "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 = ( : "Unknown error", }, ) - modelParams = params + modelParams = injectMemoriesIntoParams(params, "", ctx.logger) } else { ctx.logger.error("Error during memory retrieval for stream", { error: diff --git a/packages/tools/src/vercel/memory-prompt.ts b/packages/tools/src/vercel/memory-prompt.ts index 0f928aa28..54b7a7425 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/vercel/middleware.ts b/packages/tools/src/vercel/middleware.ts index 412eed922..6064f286f 100644 --- a/packages/tools/src/vercel/middleware.ts +++ b/packages/tools/src/vercel/middleware.ts @@ -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) } } diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index 591e7dcf5..6d88d7169 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -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 + 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 => { + 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") - - memories = ctx.promptTemplate - ? ctx.promptTemplate({ - userMemories: "", - generalSearchMemories: formattedMemories, - searchResults, + 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, 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") { diff --git a/packages/tools/test/mastra/integration.test.ts b/packages/tools/test/mastra/integration.test.ts index 58b79a61d..cf6402a26 100644 --- a/packages/tools/test/mastra/integration.test.ts +++ b/packages/tools/test/mastra/integration.test.ts @@ -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] }) }), diff --git a/packages/tools/test/mastra/unit.test.ts b/packages/tools/test/mastra/unit.test.ts index 82b0cac6c..e4fd82638 100644 --- a/packages/tools/test/mastra/unit.test.ts +++ b/packages/tools/test/mastra/unit.test.ts @@ -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( + '', + ) 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 000000000..e3249c8d3 --- /dev/null +++ b/packages/tools/test/openai-middleware.unit.test.ts @@ -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\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 000000000..559f1fa1c --- /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 2653b3454..50900a6f5 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", () => {