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/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/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index bc4d7108..d3a273be 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -1,6 +1,11 @@ import type OpenAI from "openai" +import { APIPromise } from "openai/core" import Supermemory from "supermemory" -import { addConversation } from "../conversations-client" +import { + addConversation, + type ContentPart as ConversationContentPart, + type ConversationMessage, +} from "../conversations-client" import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" @@ -12,6 +17,53 @@ 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 + }) +} + +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 +} + export interface OpenAIMiddlewareOptions { /** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */ containerTag: string @@ -100,9 +152,11 @@ const supermemoryProfileSearch = async ( ? JSON.stringify({ q: queryText, containerTag: containerTag, + include: ["static", "dynamic"], }) : JSON.stringify({ containerTag: containerTag, + include: ["static", "dynamic"], }) try { @@ -336,27 +390,24 @@ const addMemoryTool = async ( 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 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, @@ -538,7 +589,7 @@ export function createOpenAIMiddleware( return memories } - const createResponsesWithMemory = async ( + const prepareResponsesWithMemory = async ( params: Parameters[0], requestOptions?: OpenAI.RequestOptions, ) => { @@ -552,11 +603,13 @@ export function createOpenAIMiddleware( if (mode !== "profile" && !input) { logger.debug("No input found for Responses API, skipping memory search") - return originalResponsesCreate.call( - openaiClient.responses, - params, - requestOptions, - ) + return { + request: originalResponsesCreate.call( + openaiClient.responses, + params, + requestOptions, + ), + } } logger.info("Starting memory search for Responses API", { @@ -594,31 +647,58 @@ export function createOpenAIMiddleware( ? `${params.instructions || ""}\n\n${memories}`.trim() : params.instructions - return originalResponsesCreate.call( - openaiClient.responses, - { - ...params, - instructions: enhancedInstructions, - }, - requestOptions, - ) + return { + request: originalResponsesCreate.call( + openaiClient.responses, + { + ...params, + 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 hasUserMessage = messages.some((message) => message.role === "user") + const shouldPersist = + addMemory === "always" && + (customId ? hasUserMessage : 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( + if (mode !== "profile" && !userMessage) { + if (shouldPersist) { + await addMemoryTool( + client, + containerTag, + memoryContent, + memoryCustomId, + logger, + messages, + apiKey, + baseUrl, + ) + } + logger.debug("No textual user message found, skipping memory search") + return { + request: originalCreate.call( openaiClient.chat.completions, params, requestOptions, - ) + ), } } @@ -630,27 +710,19 @@ 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, + messages, + apiKey, + baseUrl, + ), + ) } operations.push( @@ -658,18 +730,27 @@ export function createOpenAIMiddleware( ) const results = await Promise.all(operations) - const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last + const enhancedMessages = results[ + results.length - 1 + ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last - return originalCreate.call( - openaiClient.chat.completions, - { - ...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/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 9f2d73a7..c477b36d 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -32,9 +32,11 @@ export const supermemoryProfileSearch = async ( ? JSON.stringify({ q: queryText, containerTag: containerTag, + include: ["static", "dynamic"], }) : JSON.stringify({ containerTag: containerTag, + include: ["static", "dynamic"], }) try { diff --git a/packages/tools/src/vercel/index.ts b/packages/tools/src/vercel/index.ts index 7726ba2e..6de60e44 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, @@ -182,11 +182,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[], @@ -261,11 +259,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..412eed92 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" && 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 7e788c3d..b5cf5aa7 100644 --- a/packages/tools/src/voltagent/middleware.ts +++ b/packages/tools/src/voltagent/middleware.ts @@ -7,7 +7,9 @@ import Supermemory from "supermemory" import { addConversation, + type ContentPart as ConversationContentPart, type ConversationMessage, + toConversationImageUrl, } from "../conversations-client" import { createLogger, @@ -62,7 +64,6 @@ export interface SupermemoryMiddlewareContext { // Storage parameters metadata?: Record searchMode?: "memories" | "documents" | "hybrid" - entityContext?: string } /** @@ -93,7 +94,6 @@ export const createSupermemoryContext = ( include, metadata, searchMode, - entityContext, verbose = false, } = options @@ -103,8 +103,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({ @@ -133,7 +150,6 @@ export const createSupermemoryContext = ( include, metadata, searchMode, - entityContext, } } @@ -160,6 +176,21 @@ const isNewUserTurn = (messages: VoltAgentMessage[]): boolean => { return lastMessage?.role === "user" } +type VoltAgentContentPart = { + type: string + text?: string + [key: string]: unknown +} + +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. */ @@ -173,7 +204,7 @@ const getLastUserMessage = (messages: VoltAgentMessage[]): string => { return "" } - const content = lastUserMessage.content + const content = getMessageContent(lastUserMessage) if (typeof content === "string") { return content @@ -234,7 +265,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) @@ -388,40 +419,58 @@ 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) { @@ -430,6 +479,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, + }) + } } } @@ -460,7 +516,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 }