diff --git a/apps/docs/integrations/voltagent.mdx b/apps/docs/integrations/voltagent.mdx index b5447938..5afb000c 100644 --- a/apps/docs/integrations/voltagent.mdx +++ b/apps/docs/integrations/voltagent.mdx @@ -18,7 +18,7 @@ Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent), ## Installation ```bash -npm install @supermemory/tools @voltagent/core +npm install @supermemory/tools @voltagent/core ai@^6 @ai-sdk/openai@^3 ``` Set up your API key as an environment variable: @@ -52,9 +52,7 @@ const configWithMemory = withSupermemory({ const agent = new Agent(configWithMemory) // Memories are automatically injected and saved -const result = await agent.generateText({ - messages: [{ role: "user", content: "What's my name?" }], -}) +const result = await agent.generateText("What's my name?") ``` @@ -131,14 +129,13 @@ const configWithMemory = withSupermemory({ // Search tuning searchMode: "hybrid", // "memories" | "documents" | "hybrid" - threshold: 0.1, // 0.0-1.0 (higher = more accurate) - limit: 10, // Max results to return + threshold: 0.6, // 0.0-1.0 (higher = more accurate) + limit: 10, // Integer from 1 to 100 rerank: true, // Rerank for best relevance rewriteQuery: false, // AI-rewrite query (+400ms latency) // Context - entityContext: "This is John, a software engineer", // Guides memory extraction (max 1500 chars) - metadata: { source: "voltagent" }, // Attached to saved conversations + metadata: { source: "voltagent" }, // Attached to saved conversations // API apiKey: "sk-...", // Falls back to SUPERMEMORY_API_KEY env var @@ -154,14 +151,16 @@ const configWithMemory = withSupermemory({ | `addMemory` | string | `"always"` | Whether to save conversations after each response | | `customId` | string | **required** | Custom ID to group messages into a conversation | | `searchMode` | string | — | `"memories"`, `"documents"`, or `"hybrid"` | -| `threshold` | number | `0.1` | Similarity threshold (0 = more results, 1 = more accurate) | -| `limit` | number | `10` | Maximum number of memory results | +| `threshold` | number | — | Similarity threshold (0 = more results, 1 = more accurate) | +| `limit` | number | — | Maximum number of memory results (integer from 1 to 100) | | `rerank` | boolean | `false` | Rerank results for relevance | | `rewriteQuery` | boolean | `false` | AI-rewrite query for better results (+400ms) | -| `entityContext` | string | — | Context for memory extraction (max 1500 chars) | +| `entityContext` | string | — | Deprecated and ignored. [Configure it on the container tag instead](/concepts/customization#entity-context). | | `metadata` | object | — | Custom metadata attached to saved conversations | | `promptTemplate` | function | — | Custom function to format memory data into prompt | +When `threshold` or `limit` is omitted, the selected Supermemory backend route applies its own default. Set them explicitly when you need consistent search tuning across modes. + ## Search Modes The `searchMode` option controls what type of results are searched: @@ -171,4 +170,3 @@ The `searchMode` option controls what type of results are searched: | `"memories"` | Search only memory entries (atomic facts about the user) | | `"documents"` | Search only document chunks | | `"hybrid"` | Search both memories AND document chunks (recommended) | - diff --git a/bun.lock b/bun.lock index 767a3b2f..98b63a16 100644 --- a/bun.lock +++ b/bun.lock @@ -361,7 +361,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 997f9f68..05f6b6f2 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "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", diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index 92f067f3..4a458e3a 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -14,10 +14,53 @@ export interface ConversationMessage { tool_call_id?: string } -export interface ContentPart { - type: "text" | "image_url" - text?: string - image_url?: { url: string } +export type ContentPart = + | { type: "text"; text: string } + | { type: "image_url"; imageUrl: { url: string } } + +const BASE64_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +const encodeBase64 = (bytes: Uint8Array): string => { + let encoded = "" + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0 + const second = bytes[index + 1] + const third = bytes[index + 2] + const value = (first << 16) | ((second ?? 0) << 8) | (third ?? 0) + encoded += BASE64_ALPHABET[(value >> 18) & 63] + encoded += BASE64_ALPHABET[(value >> 12) & 63] + encoded += second === undefined ? "=" : BASE64_ALPHABET[(value >> 6) & 63] + encoded += third === undefined ? "=" : BASE64_ALPHABET[value & 63] + } + return encoded +} + +/** Normalize supported SDK image representations for `/v4/conversations`. */ +export const toConversationImageUrl = ( + value: unknown, + mediaType = "image/jpeg", +): string | null => { + if (typeof URL !== "undefined" && value instanceof URL) { + return value.toString() + } + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return null + return /^[a-z][a-z\d+.-]*:/i.test(trimmed) + ? trimmed + : `data:${mediaType};base64,${trimmed}` + } + + const bytes = + value instanceof Uint8Array + ? value + : value instanceof ArrayBuffer + ? new Uint8Array(value) + : null + return bytes && bytes.length > 0 + ? `data:${mediaType};base64,${encodeBase64(bytes)}` + : null } export interface ToolCall { @@ -34,7 +77,6 @@ export interface AddConversationParams { messages: ConversationMessage[] containerTags?: string[] metadata?: Record - entityContext?: string apiKey: string baseUrl?: string } @@ -89,7 +131,6 @@ export async function addConversation( messages: params.messages, containerTags: params.containerTags, metadata: params.metadata, - entityContext: params.entityContext, }), redirect: "error", signal: AbortSignal.timeout(CONVERSATION_REQUEST_TIMEOUT_MS), diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index b1a43d9b..e7bef409 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -2,7 +2,7 @@ export type { SupermemoryToolsConfig } from "./types" export type { OpenAIMiddlewareOptions } from "./openai" -export type { SupermemoryVoltAgent } from "./voltagent" +export type { SupermemoryVoltAgent } from "./voltagent/options" export { TOOL_DESCRIPTIONS, diff --git a/packages/tools/src/mastra/processor.ts b/packages/tools/src/mastra/processor.ts index 37df4ddd..412d1743 100644 --- a/packages/tools/src/mastra/processor.ts +++ b/packages/tools/src/mastra/processor.ts @@ -139,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<{ diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 740d496d..4fc33272 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,6 +1,12 @@ import type OpenAI from "openai" +import { APIPromise } from "openai/core" import Supermemory from "supermemory" -import { addConversation } from "../conversations-client" +import { + addConversation, + type ContentPart as ConversationContentPart, + type ConversationMessage, + toConversationImageUrl, +} from "../conversations-client" import { replaceMemoryContext, stripMemoryContext, @@ -17,6 +23,23 @@ const normalizeBaseUrl = (url?: string): string => { const PROFILE_REQUEST_TIMEOUT_MS = 30_000 +const deferAPIPromise = ( + start: () => Promise<{ request: APIPromise }>, +): APIPromise => { + const ready = start() + + const responsePromise = ready.then(async ({ request }) => ({ + response: await request.asResponse(), + options: {} as never, + controller: new AbortController(), + })) + + return new APIPromise(responsePromise, async () => { + const { request } = await ready + return await request + }) +} + export interface OpenAIMiddlewareOptions { /** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */ containerTag: string @@ -30,16 +53,337 @@ export interface OpenAIMiddlewareOptions { baseUrl?: string } +interface SupermemoryProfileSearchResult { + id: string + memory?: string + chunk?: string + metadata: Record | null + updatedAt: string + similarity: number +} + interface SupermemoryProfileSearch { profile: { - static?: Array<{ memory: string; metadata?: Record }> - dynamic?: Array<{ memory: string; metadata?: Record }> + static?: string[] + dynamic?: string[] + buckets?: Record } - searchResults: { - results: Array<{ memory: string; metadata?: 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[] => { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + + const converted: ConversationContentPart[] = [] + for (const value of content) { + if (!value || typeof value !== "object") continue + const part = value as { + type?: unknown + text?: unknown + image_url?: { url?: unknown } + } + if (part.type === "text" && typeof part.text === "string") { + converted.push({ type: "text", text: part.text }) + } else if ( + part.type === "image_url" && + typeof part.image_url?.url === "string" + ) { + converted.push({ + type: "image_url", + imageUrl: { url: part.image_url.url }, + }) + } + } + + return converted +} + +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 }), + })) +} + +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) + } + } + + 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, + ) + }) +} + /** * Extracts the last user message from an array of chat completion messages. * @@ -70,9 +414,7 @@ const getLastUserMessage = ( .reverse() .find((msg) => msg.role === "user") - return typeof lastUserMessage?.content === "string" - ? lastUserMessage.content - : "" + return extractTextContent(lastUserMessage?.content) } /** @@ -105,9 +447,11 @@ const supermemoryProfileSearch = async ( ? JSON.stringify({ q: queryText, containerTag: containerTag, + include: ["static", "dynamic"], }) : JSON.stringify({ containerTag: containerTag, + include: ["static", "dynamic"], }) try { @@ -174,7 +518,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) : "" @@ -200,7 +544,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", { @@ -229,7 +575,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")}` @@ -244,18 +590,9 @@ const addSystemPrompt = async ( }) } - if (systemPromptExists) { - logger.debug("Replaced Supermemory context in existing system prompt") - let injected = false - return messages.map((msg) => { - if (msg.role !== "system") return msg - const content = typeof msg.content === "string" ? msg.content : "" - if (!injected) { - injected = true - return { ...msg, content: replaceMemoryContext(content, memories) } - } - return { ...msg, content: stripMemoryContext(content) } - }) + if (instructionPromptExists) { + logger.debug("Replaced Supermemory context in existing instruction prompt") + return updateChatMemoryContexts(messages, memories) } logger.debug( @@ -295,7 +632,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") @@ -315,7 +652,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) @@ -340,37 +677,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 = messages.map((msg) => ({ - role: msg.role as "user" | "assistant" | "system" | "tool", - content: - typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content - .filter((c) => c.type === "text") - .map((c) => ({ - type: "text" as const, - text: (c as { type: "text"; text: string }).text, - })) - : "", - ...("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, @@ -382,7 +696,7 @@ const addMemoryTool = async ( logger.info("Conversation saved successfully via /v4/conversations", { containerTag, customId, - messageCount: messages.length, + messageCount: conversationMessages.length, responseId: response.id, }) return @@ -504,7 +818,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`, { @@ -533,7 +849,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")}` @@ -551,7 +867,7 @@ export function createOpenAIMiddleware( return memories } - const createResponsesWithMemory = async ( + const prepareResponsesWithMemory = async ( params: Parameters[0], requestOptions?: OpenAI.RequestOptions, ) => { @@ -561,15 +877,48 @@ 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") - return originalResponsesCreate.call( - openaiClient.responses, - params, - requestOptions, + 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, + cleanedParams, + requestOptions, + ), + } } logger.info("Starting memory search for Responses API", { @@ -580,14 +929,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( @@ -600,40 +942,89 @@ 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 = replaceMemoryContext( - params.instructions || "", - typeof memories === "string" ? memories : "", - ) + 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 originalResponsesCreate.call( - openaiClient.responses, - { - ...params, - instructions: enhancedInstructions, - }, - requestOptions, - ) + return { + request: originalResponsesCreate.call( + openaiClient.responses, + { + ...params, + input: cleanedInput, + instructions: enhancedInstructions, + }, + requestOptions, + ), + } } - const createWithMemory = async ( + const createResponsesWithMemory = ( + params: Parameters[0], + requestOptions?: OpenAI.RequestOptions, + ) => deferAPIPromise(() => prepareResponsesWithMemory(params, requestOptions)) + + const prepareCreateWithMemory = async ( params: OpenAI.Chat.Completions.ChatCompletionCreateParams, requestOptions?: OpenAI.RequestOptions, ) => { const messages = Array.isArray(params.messages) ? params.messages : [] + const userMessage = getLastUserMessage(messages) + const conversationMessages = convertChatConversationMessages( + updateChatMemoryContexts(messages), + ) + const shouldPersist = + addMemory === "always" && + (customId + ? hasPersistableUserConversationMessage(conversationMessages) + : Boolean(userMessage.trim())) + const memoryContent = customId + ? getConversationContent(messages) + : userMessage + const memoryCustomId = customId ? `conversation:${customId}` : undefined - if (mode !== "profile") { - const userMessage = getLastUserMessage(messages) - if (!userMessage) { - logger.debug("No user message found, skipping memory search") - return originalCreate.call( - openaiClient.chat.completions, - params, - requestOptions, + if (mode !== "profile" && !userMessage) { + if (shouldPersist) { + await addMemoryTool( + client, + containerTag, + memoryContent, + memoryCustomId, + logger, + conversationMessages, + apiKey, + baseUrl, ) } + logger.debug("No textual user message found, skipping memory search") + return { + request: originalCreate.call( + openaiClient.chat.completions, + { + ...params, + messages: updateChatMemoryContexts(messages), + }, + requestOptions, + ), + } } logger.info("Starting memory search", { @@ -644,48 +1035,58 @@ export function createOpenAIMiddleware( const operations: Promise[] = [] - if (addMemory === "always") { - const userMessage = getLastUserMessage(messages) - if (userMessage?.trim()) { - const content = customId - ? getConversationContent(messages) - : userMessage - const memoryCustomId = customId ? `conversation:${customId}` : undefined - - operations.push( - addMemoryTool( - client, - containerTag, - content, - memoryCustomId, - logger, - messages, - apiKey, - baseUrl, - ), - ) - } + if (shouldPersist) { + operations.push( + addMemoryTool( + client, + containerTag, + memoryContent, + memoryCustomId, + logger, + conversationMessages, + apiKey, + baseUrl, + ), + ) } operations.push( 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 originalCreate.call( - openaiClient.chat.completions, - { - ...params, - messages: enhancedMessages, - }, - requestOptions, - ) + return { + request: originalCreate.call( + openaiClient.chat.completions, + { + ...params, + messages: enhancedMessages, + }, + requestOptions, + ), + } } + const createWithMemory = ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParams, + requestOptions?: OpenAI.RequestOptions, + ) => deferAPIPromise(() => prepareCreateWithMemory(params, requestOptions)) + openaiClient.chat.completions.create = createWithMemory as typeof originalCreate diff --git a/packages/tools/src/shared/index.ts b/packages/tools/src/shared/index.ts index 889a5729..602d9fe3 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, diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 9f2d73a7..5097f7a1 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, @@ -32,9 +36,11 @@ export const supermemoryProfileSearch = async ( ? JSON.stringify({ q: queryText, containerTag: containerTag, + include: ["static", "dynamic"], }) : JSON.stringify({ containerTag: containerTag, + include: ["static", "dynamic"], }) try { @@ -119,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", { @@ -151,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 index 84116c33..79fce72a 100644 --- a/packages/tools/src/shared/memory-context.ts +++ b/packages/tools/src/shared/memory-context.ts @@ -3,21 +3,28 @@ export const MEMORY_CONTEXT_START = export const MEMORY_CONTEXT_END = "" const MEMORY_CONTEXT_PATTERN = - /[ \t]*[\s\S]*?<\/supermemory>[ \t]*/g + /(?:\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, "") - .replace(/\n{3,}/g, "\n\n") - .trim() + 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 "" - return `${MEMORY_CONTEXT_START}\n${normalized}\n${MEMORY_CONTEXT_END}` + const escaped = escapeMemoryContextDelimiters(normalized) + return `${MEMORY_CONTEXT_START}\n${escaped}\n${MEMORY_CONTEXT_END}` } /** Replace prior middleware context while preserving caller-authored instructions. */ @@ -28,5 +35,7 @@ export function replaceMemoryContext( const preserved = stripMemoryContext(content) const memoryContext = wrapMemoryContext(memories) if (!memoryContext) return preserved - return preserved ? `${preserved}\n\n${memoryContext}` : memoryContext + // 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 a68d2882..0dddfd42 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 421785f5..f8551e12 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.ts b/packages/tools/src/tools-shared.ts index edb39168..fe2db9ba 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -265,13 +265,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 @@ -291,12 +295,29 @@ export interface DeduplicatedMemories { /** 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 @@ -307,8 +328,8 @@ export function normalizeMemoryFact(memory: string): string { * @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: @@ -326,25 +347,13 @@ 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) + const memory = getMemoryText(item) const key = memory === null ? null : normalizeMemoryFact(memory) - if (memory !== null && key !== null && !seenMemories.has(key)) { + if (memory !== null && key && !seenMemories.has(key)) { staticMemories.push(memory) seenMemories.add(key) } @@ -353,9 +362,9 @@ export function deduplicateMemories( const dynamicMemories: string[] = [] for (const item of dynamicItems as Array) { - const memory = getMemoryString(item) + const memory = getMemoryText(item) const key = memory === null ? null : normalizeMemoryFact(memory) - if (memory !== null && key !== null && !seenMemories.has(key)) { + if (memory !== null && key && !seenMemories.has(key)) { dynamicMemories.push(memory) seenMemories.add(key) } @@ -364,9 +373,9 @@ export function deduplicateMemories( const searchMemories: string[] = [] for (const item of searchItems as Array) { - const memory = getMemoryString(item) + const memory = getMemoryText(item) const key = memory === null ? null : normalizeMemoryFact(memory) - if (memory !== null && key !== null && !seenMemories.has(key)) { + if (memory !== null && key && !seenMemories.has(key)) { searchMemories.push(memory) seenMemories.add(key) } diff --git a/packages/tools/src/vercel/index.ts b/packages/tools/src/vercel/index.ts index 7726ba2e..e5b98a9a 100644 --- a/packages/tools/src/vercel/index.ts +++ b/packages/tools/src/vercel/index.ts @@ -2,7 +2,7 @@ import { type LanguageModel, type LanguageModelCallOptions, type LanguageModelStreamPart, - getLastUserMessage, + hasPersistableUserContent, } from "./util" import { createSupermemoryContext, @@ -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: @@ -182,11 +183,9 @@ const wrapVercelLanguageModel = ( // biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3 const result = await target.doGenerate(modelParams as any) - const userMessage = getLastUserMessage(params) if ( ctx.addMemory === "always" && - userMessage && - userMessage.trim() + hasPersistableUserContent(params) ) { const assistantResponseText = extractAssistantResponseText( result.content as unknown[], @@ -232,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: @@ -261,11 +260,9 @@ const wrapVercelLanguageModel = ( controller.enqueue(chunk) }, flush: async () => { - const userMessage = getLastUserMessage(params) if ( ctx.addMemory === "always" && - userMessage && - userMessage.trim() + hasPersistableUserContent(params) ) { saveMemoryAfterResponse( ctx.client, diff --git a/packages/tools/src/vercel/middleware.ts b/packages/tools/src/vercel/middleware.ts index ac1227ab..6064f286 100644 --- a/packages/tools/src/vercel/middleware.ts +++ b/packages/tools/src/vercel/middleware.ts @@ -3,6 +3,7 @@ import { addConversation, type ContentPart, type ConversationMessage, + toConversationImageUrl, } from "../conversations-client" import { createLogger, @@ -105,13 +106,12 @@ export const convertToConversationMessages = ( }) } else if ( content.type === "file" && - typeof content.data === "string" && content.mediaType.startsWith("image/") ) { - contentParts.push({ - type: "image_url", - image_url: { url: content.data }, - }) + const url = toConversationImageUrl(content.data, content.mediaType) + if (url) { + contentParts.push({ type: "image_url", imageUrl: { url } }) + } } else if ( includeToolCalls && content.type === "tool-call" && @@ -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/vercel/util.ts b/packages/tools/src/vercel/util.ts index 49ab30c5..97b2c3e8 100644 --- a/packages/tools/src/vercel/util.ts +++ b/packages/tools/src/vercel/util.ts @@ -3,11 +3,8 @@ import type { LanguageModelV2CallOptions, LanguageModelV2Message, LanguageModelV2StreamPart, - LanguageModelV3, - LanguageModelV3CallOptions, - LanguageModelV3Message, - LanguageModelV3StreamPart, } from "@ai-sdk/provider" +import { toConversationImageUrl } from "../conversations-client" // Re-export shared types for backward compatibility export type { @@ -15,17 +12,23 @@ export type { ProfileMarkdownData, } from "../shared" -// Union types for dual SDK version support (V2 = SDK 5, V3 = SDK 6) -export type LanguageModel = LanguageModelV2 | LanguageModelV3 -export type LanguageModelCallOptions = - | LanguageModelV2CallOptions - | LanguageModelV3CallOptions -export type LanguageModelMessage = - | LanguageModelV2Message - | LanguageModelV3Message -export type LanguageModelStreamPart = - | LanguageModelV2StreamPart - | LanguageModelV3StreamPart +// Provider v2 does not export V3 names, so keep the public declaration on the +// common V2 surface and structurally accept V3 models at the wrapper boundary. +type LanguageModelV3Compat = Omit< + LanguageModelV2, + "specificationVersion" | "doGenerate" | "doStream" +> & { + readonly specificationVersion: "v3" + // biome-ignore lint/suspicious/noExplicitAny: Bridges mutually exclusive provider major declarations. + doGenerate(...args: any[]): PromiseLike + // biome-ignore lint/suspicious/noExplicitAny: Bridges mutually exclusive provider major declarations. + doStream(...args: any[]): PromiseLike +} + +export type LanguageModel = LanguageModelV2 | LanguageModelV3Compat +export type LanguageModelCallOptions = LanguageModelV2CallOptions +export type LanguageModelMessage = LanguageModelV2Message +export type LanguageModelStreamPart = LanguageModelV2StreamPart export type OutputContentItem = | { type: "text"; text: string } @@ -73,6 +76,38 @@ export const getLastUserMessage = ( .join(" ") } +/** Whether the prompt contains user content that `/v4/conversations` can store. */ +export const hasPersistableUserContent = ( + params: LanguageModelCallOptions, +): boolean => { + return params.prompt.some((message) => { + if (message.role !== "user") return false + const content: unknown = message.content + if (typeof content === "string") { + return Boolean(content.trim()) + } + if (!Array.isArray(content)) return false + return content.some((value) => { + if (!value || typeof value !== "object") return false + const part = value as { + type?: unknown + text?: unknown + mediaType?: unknown + data?: unknown + } + if (part.type === "text" && typeof part.text === "string") { + return Boolean(part.text.trim()) + } + return ( + part.type === "file" && + typeof part.mediaType === "string" && + part.mediaType.startsWith("image/") && + toConversationImageUrl(part.data, part.mediaType) !== null + ) + }) + }) +} + export const filterOutSupermemories = (content: string) => { return content.split("User Supermemories: ")[0] } diff --git a/packages/tools/src/voltagent/hooks.ts b/packages/tools/src/voltagent/hooks.ts index 87c78831..49553cd4 100644 --- a/packages/tools/src/voltagent/hooks.ts +++ b/packages/tools/src/voltagent/hooks.ts @@ -18,6 +18,32 @@ import { saveConversation, } from "./middleware" +const getInputMessages = (input: unknown): VoltAgentMessage[] => { + if (typeof input === "string") { + return input.trim() ? [{ role: "user", content: input }] : [] + } + if (Array.isArray(input)) return input as VoltAgentMessage[] + if ( + input && + typeof input === "object" && + "messages" in input && + Array.isArray(input.messages) + ) { + return input.messages as VoltAgentMessage[] + } + return [] +} + +const getOutputText = (output: unknown): string => { + if (typeof output === "string") return output + if (!output || typeof output !== "object") return "" + if ("text" in output && typeof output.text === "string") return output.text + if ("content" in output && typeof output.content === "string") { + return output.content + } + return "" +} + /** * Creates Supermemory hooks for VoltAgent agents. * @@ -41,7 +67,6 @@ import { * const agent = new Agent({ * name: "my-agent", * instructions: "You are a helpful assistant", - * llm: new VercelAIProvider(), * model: openai("gpt-4o"), * hooks * }) @@ -54,16 +79,12 @@ export function createSupermemoryHooks( const ctx = createSupermemoryContext(containerTag, options) return { - onPrepareMessages: async ( - args: HookPrepareMessagesArgs, - ): Promise<{ messages: VoltAgentMessage[] }> => { + onPrepareMessages: async (args: HookPrepareMessagesArgs) => { try { - // VoltAgent passes user messages in args.context.input.messages - // and the prepared messages (system + conversation) in args.messages - const contextInput = args.context?.input as - | { messages?: VoltAgentMessage[] } - | undefined - const inputMessages = contextInput?.messages || [] + // VoltAgent 2.x supplies canonical UI messages directly on the hook. + const inputMessages = (args.rawMessages ?? + args.messages) as unknown as VoltAgentMessage[] + const preparedMessages = args.messages as unknown as VoltAgentMessage[] ctx.logger.debug("onPrepareMessages called", { messageCount: args.messages.length, @@ -74,7 +95,7 @@ export function createSupermemoryHooks( const enhancedMessages = await enhanceMessagesWithMemories( inputMessages, ctx, - args.messages, + preparedMessages, ) ctx.logger.debug("Messages enhanced with memories", { @@ -82,7 +103,9 @@ export function createSupermemoryHooks( enhancedCount: enhancedMessages.length, }) - return { messages: enhancedMessages } + return { + messages: enhancedMessages as unknown as typeof args.messages, + } } catch (error) { ctx.logger.error("Error in onPrepareMessages", { error: error instanceof Error ? error.message : "Unknown error", @@ -102,19 +125,8 @@ export function createSupermemoryHooks( let messages: VoltAgentMessage[] = [] if (args.context?.input && args.output) { - const inputData = args.context.input as - | { messages?: VoltAgentMessage[] } - | undefined - const inputMessages = inputData?.messages || [] - - const outputData = args.output as - | string - | { text?: string; content?: string } - | undefined - const outputText = - typeof outputData === "string" - ? outputData - : outputData?.text || outputData?.content + const inputMessages = getInputMessages(args.context.input) + const outputText = getOutputText(args.output) if (inputMessages.length > 0 && outputText) { messages = [ diff --git a/packages/tools/src/voltagent/index.ts b/packages/tools/src/voltagent/index.ts index 9b834691..4eb6a0a8 100644 --- a/packages/tools/src/voltagent/index.ts +++ b/packages/tools/src/voltagent/index.ts @@ -43,15 +43,15 @@ interface WithSupermemoryOptions * @param options.apiKey - Supermemory API key (falls back to SUPERMEMORY_API_KEY env var) * @param options.baseUrl - Custom Supermemory API base URL * @param options.promptTemplate - Custom function to format memory data into prompt - * @param options.threshold - Search sensitivity: 0 (more results) to 1 (more accurate). Default: 0.1 - * @param options.limit - Maximum number of memory results to return. Default: 10 + * @param options.threshold - Search sensitivity: 0 (more results) to 1 (more accurate) + * @param options.limit - Maximum number of memory results to return (integer from 1 to 100) * @param options.rerank - If true, rerank results for relevance. Default: false * @param options.rewriteQuery - If true, AI-rewrite query for better results (+400ms latency). Default: false * @param options.filters - Advanced AND/OR filters for search * @param options.include - Control what additional data to include (chunks, documents, etc.) * @param options.metadata - Optional metadata to attach to saved conversations * @param options.searchMode - Search mode: "memories" (atomic facts), "documents" (chunks), or "hybrid" (both) - * @param options.entityContext - Context for memory extraction (max 1500 chars), guides how memories are understood + * @param options.entityContext - Deprecated and ignored; configure entity context on the container tag instead * @returns Enhanced agent config with Supermemory hooks injected * * @example @@ -59,14 +59,12 @@ interface WithSupermemoryOptions * ```typescript * import { withSupermemory } from "@supermemory/tools/voltagent" * import { Agent } from "@voltagent/core" - * import { VercelAIProvider } from "@voltagent/vercel-ai" * import { openai } from "@ai-sdk/openai" * * const configWithMemory = withSupermemory({ * agentConfig: { * name: "my-agent", * instructions: "You are a helpful assistant", - * llm: new VercelAIProvider(), * model: openai("gpt-4o"), * }, * containerTag: "user-123", @@ -83,7 +81,6 @@ interface WithSupermemoryOptions * agentConfig: { * name: "my-agent", * instructions: "You are a helpful assistant", - * llm: new VercelAIProvider(), * model: openai("gpt-4o"), * }, * containerTag: "user-123", // Required: user/project ID @@ -94,7 +91,6 @@ interface WithSupermemoryOptions * limit: 15, // Max results to return * rerank: true, // Rerank for best relevance * searchMode: "hybrid", // "memories" | "documents" | "hybrid" - * entityContext: "This is John, a software engineer saving technical discussions", * metadata: { // Custom metadata * source: "voltagent", * version: "1.0" @@ -104,9 +100,9 @@ interface WithSupermemoryOptions * const agent = new Agent(configWithMemory) * * // Use the agent - memories are automatically injected - * const result = await agent.generateText({ - * messages: [{ role: "user", content: "What's my favorite programming language?" }] - * }) + * const result = await agent.generateText( + * "What's my favorite programming language?", + * ) * ``` * * @example @@ -116,7 +112,6 @@ interface WithSupermemoryOptions * agentConfig: { * name: "my-agent", * instructions: "...", - * llm: new VercelAIProvider(), * model: openai("gpt-4o"), * }, * containerTag: "user-123", @@ -138,7 +133,7 @@ interface WithSupermemoryOptions */ export function withSupermemory( options: WithSupermemoryOptions, -): T { +): T & { hooks: NonNullable } { const { agentConfig, containerTag, ...supermemoryOptions } = options // Create Supermemory hooks (internally creates its own context, validates API key) diff --git a/packages/tools/src/voltagent/middleware.ts b/packages/tools/src/voltagent/middleware.ts index a87285b6..de4b2e8c 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -7,20 +7,31 @@ import Supermemory from "supermemory" import { addConversation, + type ContentPart as ConversationContentPart, type ConversationMessage, + toConversationImageUrl, } from "../conversations-client" import { createLogger, 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, @@ -39,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. @@ -65,7 +72,6 @@ export interface SupermemoryMiddlewareContext { // Storage parameters metadata?: Record searchMode?: "memories" | "documents" | "hybrid" - entityContext?: string } /** @@ -96,7 +102,6 @@ export const createSupermemoryContext = ( include, metadata, searchMode, - entityContext, verbose = false, } = options @@ -106,8 +111,25 @@ export const createSupermemoryContext = ( "customId is required and must be a non-empty string — provide it via `options.customId`", ) } + if ( + threshold !== undefined && + (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) + ) { + throw new Error("threshold must be between 0 and 1") + } + if ( + limit !== undefined && + (!Number.isInteger(limit) || limit < 1 || limit > 100) + ) { + throw new Error("limit must be an integer between 1 and 100") + } const logger = createLogger(verbose) + if (options.entityContext !== undefined) { + logger.warn( + "entityContext is not supported by /v4/conversations and will be ignored; configure it on the container tag instead.", + ) + } const normalizedBaseUrl = normalizeBaseUrl(baseUrl) const client = new Supermemory({ @@ -136,7 +158,6 @@ export const createSupermemoryContext = ( include, metadata, searchMode, - entityContext, } } @@ -163,6 +184,15 @@ const isNewUserTurn = (messages: VoltAgentMessage[]): boolean => { return lastMessage?.role === "user" } +const getMessageContent = ( + message: VoltAgentMessage, +): string | VoltAgentContentPart[] => { + if (typeof message.content === "string" || Array.isArray(message.content)) { + return message.content + } + return Array.isArray(message.parts) ? message.parts : "" +} + /** * Extracts the last user message text from messages array. */ @@ -176,7 +206,7 @@ const getLastUserMessage = (messages: VoltAgentMessage[]): string => { return "" } - const content = lastUserMessage.content + const content = getMessageContent(lastUserMessage) if (typeof content === "string") { return content @@ -212,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 || "") @@ -237,7 +267,7 @@ export const enhanceMessagesWithMemories = async ( const genericMessages = messages.map((msg) => ({ role: msg.role, - content: msg.content, + content: getMessageContent(msg), })) const queryText = extractQueryText(genericMessages, ctx.mode) @@ -260,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") + 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, @@ -317,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 }) @@ -329,10 +404,92 @@ 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, @@ -343,25 +500,9 @@ const injectMemoriesIntoMessages = ( let injected = false return messages.map((message) => { if (message.role !== "system") return message - const parts = ( - message as { parts?: Array<{ type: string; text?: string }> } - ).parts - const partContent = parts - ?.filter((part) => part.type === "text") - .map((part) => part.text || "") - .join("\n") - const existingContent = - partContent || - (typeof message.content === "string" ? message.content : "") - const newContent = !injected - ? replaceMemoryContext(existingContent, memories) - : stripMemoryContext(existingContent) + const updated = updateSystemMessage(message, memories, !injected) injected = true - return { - ...message, - content: newContent, - parts: [{ type: "text", text: newContent }], - } as VoltAgentMessage + return updated }) } @@ -386,40 +527,60 @@ const convertToConversationMessages = ( messages: VoltAgentMessage[], ): ConversationMessage[] => { const conversationMessages: ConversationMessage[] = [] + const convertPart = ( + part: VoltAgentContentPart, + ): ConversationContentPart | null => { + if (part.type === "text" && typeof part.text === "string" && part.text) { + return { type: "text", text: part.text } + } + + if (part.type === "file") { + const mediaType = part.mediaType + const url = + typeof mediaType === "string" && mediaType.startsWith("image/") + ? toConversationImageUrl(part.url ?? part.data, mediaType) + : null + if (url) { + return { type: "image_url", imageUrl: { url } } + } + } + + if (part.type === "image") { + const mediaType = + typeof part.mediaType === "string" ? part.mediaType : "image/jpeg" + const url = toConversationImageUrl(part.image, mediaType) + if (url) return { type: "image_url", imageUrl: { url } } + } + + if (part.type === "image_url") { + const imageUrl = + typeof part.imageUrl === "object" && part.imageUrl + ? (part.imageUrl as { url?: unknown }) + : typeof part.image_url === "object" && part.image_url + ? (part.image_url as { url?: unknown }) + : undefined + if (typeof imageUrl?.url === "string") { + return { type: "image_url", imageUrl: { url: imageUrl.url } } + } + } + + return null + } for (const msg of messages) { if (msg.role === "system") { continue } - if (typeof msg.content === "string") { - if (msg.content) { - conversationMessages.push({ - role: msg.role as "user" | "assistant" | "tool", - content: msg.content, - }) - } - } else if (Array.isArray(msg.content)) { - const contentParts = msg.content - .map((c) => { - if (c.type === "text" && c.text) { - return { - type: "text" as const, - text: c.text, - } - } - // Handle image URLs if present - if (c.type === "image_url" && typeof c.image_url === "object") { - const imageUrl = c.image_url as { url?: string } - if (imageUrl.url) { - return { - type: "image_url" as const, - image_url: { url: imageUrl.url }, - } - } - } - return null - }) + const structuredParts = Array.isArray(msg.parts) + ? msg.parts + : Array.isArray(msg.content) + ? msg.content + : undefined + + if (structuredParts) { + const contentParts = structuredParts + .map(convertPart) .filter((part) => part !== null) if (contentParts.length > 0) { @@ -428,6 +589,13 @@ const convertToConversationMessages = ( content: contentParts, }) } + } else if (typeof msg.content === "string") { + if (msg.content) { + conversationMessages.push({ + role: msg.role as "user" | "assistant" | "tool", + content: msg.content, + }) + } } } @@ -458,7 +626,6 @@ export const saveConversation = async ( messages: conversationMessages, containerTags: [ctx.containerTag], metadata: ctx.metadata, - entityContext: ctx.entityContext, apiKey: ctx.apiKey, baseUrl: ctx.normalizedBaseUrl, }) diff --git a/packages/tools/src/voltagent/options.ts b/packages/tools/src/voltagent/options.ts new file mode 100644 index 00000000..21f3f395 --- /dev/null +++ b/packages/tools/src/voltagent/options.ts @@ -0,0 +1,109 @@ +/** + * Peer-free configuration types for the VoltAgent integration. + * + * This module intentionally avoids importing @voltagent/core so the root + * @supermemory/tools declarations remain usable when the optional peer is absent. + */ + +import type Supermemory from "supermemory" +import type { SupermemoryBaseOptions } from "../shared" + +/** + * Configuration options for the Supermemory VoltAgent integration. + * Extends base options with VoltAgent-specific settings. + */ +export interface SupermemoryVoltAgent extends SupermemoryBaseOptions { + /** + * Custom ID to group messages into a single document. + * Ensures related messages are added to the same document for that conversation. + */ + customId: string + + /** + * Threshold / sensitivity for memory selection. 0 is least sensitive (returns + * most memories, more results), 1 is most sensitive (returns fewer memories, + * more accurate results). When omitted, the selected backend route applies + * its own default. + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + threshold?: number + + /** + * Maximum number of memory results to return. Must be an integer between 1 + * and 100. When omitted, the selected backend route applies its own default. + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + limit?: number + + /** + * If true, rerank the results based on the query. This helps ensure the most + * relevant results are returned. Default: false + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + rerank?: boolean + + /** + * If true, rewrites the query to make it easier to find memories. This increases + * latency by about 400ms. Default: false + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + rewriteQuery?: boolean + + /** + * Advanced filters to apply to the search using AND/OR logic. + * Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] } + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + filters?: SearchFilters + + /** + * Control what additional data to include in search results. + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + include?: IncludeOptions + + /** + * Optional metadata to attach to saved documents/conversations. + * Can include strings, numbers, or booleans. + */ + metadata?: Record + + /** + * Search mode controlling what type of results to search. + * - "memories": Search only memory entries (atomic facts) + * - "documents": Search only document chunks + * - "hybrid": Search both memories AND document chunks (recommended) + * + * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. + */ + searchMode?: "memories" | "documents" | "hybrid" + + /** + * @deprecated The conversations API does not accept per-request entity context. + * Configure entity context on the container tag instead. + */ + entityContext?: string +} + +/** Advanced search filters using AND/OR logic. */ +export type SearchFilters = NonNullable + +/** Options for including additional data in search results. */ +export interface IncludeOptions { + /** Fetch chunks from documents associated with found memories. */ + chunks?: boolean + /** Include full document information in results. */ + documents?: boolean + /** Include explicitly forgotten or expired memories. */ + forgottenMemories?: boolean + /** Include parent/child memories from the memory graph. */ + relatedMemories?: boolean + /** Include document summaries in results. */ + summaries?: boolean +} diff --git a/packages/tools/src/voltagent/types.ts b/packages/tools/src/voltagent/types.ts index e6524ebc..e773f1d8 100644 --- a/packages/tools/src/voltagent/types.ts +++ b/packages/tools/src/voltagent/types.ts @@ -5,220 +5,49 @@ * Supermemory by providing hooks that inject memories before LLM calls. */ -import type Supermemory from "supermemory" +import type { + AgentHooks, + AgentOptions, + OnEndHookArgs, + OnPrepareMessagesHookArgs, + OnStartHookArgs, +} from "@voltagent/core" import type { PromptTemplate, MemoryMode, AddMemoryMode, MemoryPromptData, - SupermemoryBaseOptions, } from "../shared" /** - * Configuration options for the Supermemory VoltAgent integration. - * Extends base options with VoltAgent-specific settings. - */ -export interface SupermemoryVoltAgent extends SupermemoryBaseOptions { - /** - * Custom ID to group messages into a single document. - * Ensures related messages are added to the same document for that conversation. - */ - customId: string - - /** - * Threshold / sensitivity for memory selection. 0 is least sensitive (returns - * most memories, more results), 1 is most sensitive (returns fewer memories, - * more accurate results). Default: 0.1 - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - threshold?: number - - /** - * Maximum number of memory results to return. Default: 10 - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - limit?: number - - /** - * If true, rerank the results based on the query. This helps ensure the most - * relevant results are returned. Default: false - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - rerank?: boolean - - /** - * If true, rewrites the query to make it easier to find memories. This increases - * latency by about 400ms. Default: false - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - rewriteQuery?: boolean - - /** - * Advanced filters to apply to the search using AND/OR logic. - * Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] } - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - filters?: SearchFilters - - /** - * Control what additional data to include in search results - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - include?: IncludeOptions - - /** - * Optional metadata to attach to saved documents/conversations. - * Can include strings, numbers, or booleans. - */ - metadata?: Record - - /** - * Search mode controlling what type of results to search. - * - "memories": Search only memory entries (atomic facts) - * - "documents": Search only document chunks - * - "hybrid": Search both memories AND document chunks (recommended) - * - * Note: Only effective when mode is "query" or "full". Ignored in "profile" mode. - */ - searchMode?: "memories" | "documents" | "hybrid" - - /** - * Context for memory extraction when saving conversations. - * Helps guide how memories are extracted and understood from content. - * Max 1500 characters. - * Example: "This is John, saving items in a personal knowledge management system" - */ - entityContext?: string -} - -/** - * Advanced search filters using AND/OR logic - */ -export type SearchFilters = NonNullable - -/** - * Options for including additional data in search results - */ -export interface IncludeOptions { - /** - * If true, fetch and return chunks from documents associated with found memories. - * Performs vector search on chunks within those documents. - */ - chunks?: boolean - - /** - * If true, include full document information in results - */ - documents?: boolean - - /** - * If true, include forgotten memories in search results. Forgotten memories are - * memories that have been explicitly forgotten or have passed their expiration date. - */ - forgottenMemories?: boolean - - /** - * If true, include related memories (parents/children in the memory graph) - */ - relatedMemories?: boolean - - /** - * If true, include document summaries in results - */ - summaries?: boolean -} - -/** - * VoltAgent message format (simplified to avoid direct dependency). - * Compatible with VoltAgent's Message type. + * VoltAgent message format used internally by the integration. + * Compatible with current UI and model message shapes. */ export interface VoltAgentMessage { role: "system" | "user" | "assistant" | "tool" - content: + content?: | string | Array<{ type: string; text?: string; [key: string]: unknown }> + parts?: Array<{ type: string; text?: string; [key: string]: unknown }> [key: string]: unknown } -/** - * Minimal VoltAgent AgentConfig interface representing properties we enhance. - * This avoids a direct dependency on @voltagent/core while staying type-safe. - */ -export interface VoltAgentConfig { - name: string - instructions?: string - model?: unknown - llm?: unknown - hooks?: VoltAgentHooks - [key: string]: unknown +/** VoltAgent agent configuration accepted by the integration. */ +export type VoltAgentConfig = Omit & { + hooks?: AgentHooks } -/** - * VoltAgent hooks interface (simplified). - * Hooks allow intercepting agent lifecycle events. - */ -export interface VoltAgentHooks { - onStart?: (args: HookStartArgs) => void | Promise - onPrepareMessages?: ( - args: HookPrepareMessagesArgs, - ) => - | { messages?: VoltAgentMessage[] } - | Promise<{ messages?: VoltAgentMessage[] }> - onEnd?: (args: HookEndArgs) => void | Promise - [key: string]: unknown -} +/** Current VoltAgent peer types used by the public integration contract. */ +export type VoltAgentHooks = AgentHooks +export type HookStartArgs = OnStartHookArgs +export type HookPrepareMessagesArgs = OnPrepareMessagesHookArgs +export type HookEndArgs = OnEndHookArgs -/** - * Arguments passed to onStart hook. - */ -export interface HookStartArgs { - agent: { - name: string - [key: string]: unknown - } - context?: { - messages?: VoltAgentMessage[] - [key: string]: unknown - } - [key: string]: unknown -} - -/** - * Arguments passed to onPrepareMessages hook. - */ -export interface HookPrepareMessagesArgs { - messages: VoltAgentMessage[] - agent: { - name: string - [key: string]: unknown - } - context?: { - [key: string]: unknown - } - [key: string]: unknown -} - -/** - * Arguments passed to onEnd hook. - */ -export interface HookEndArgs { - agent: { - name: string - [key: string]: unknown - } - context?: { - input?: unknown - [key: string]: unknown - } - output?: unknown - [key: string]: unknown -} +export type { + IncludeOptions, + SearchFilters, + SupermemoryVoltAgent, +} from "./options" // Re-export shared types for convenience export type { PromptTemplate, MemoryMode, AddMemoryMode, MemoryPromptData } diff --git a/packages/tools/test/mastra/integration.test.ts b/packages/tools/test/mastra/integration.test.ts index 58b79a61..cf6402a2 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 6f0f1102..e4fd8263 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] }) }), diff --git a/packages/tools/test/openai-middleware.unit.test.ts b/packages/tools/test/openai-middleware.unit.test.ts index b68e1e79..e3249c8d 100644 --- a/packages/tools/test/openai-middleware.unit.test.ts +++ b/packages/tools/test/openai-middleware.unit.test.ts @@ -26,7 +26,11 @@ describe("OpenAI middleware memory context", () => { }), }), ) - const originalCreate = vi.fn().mockResolvedValue({ choices: [] }) + const originalCreate = vi.fn(() => + Object.assign(Promise.resolve({ choices: [] }), { + asResponse: async () => new Response(), + }), + ) const client = { chat: { completions: { create: originalCreate } }, } as unknown as OpenAI