mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(tools): harden cross-SDK memory middleware
This commit is contained in:
parent
7fa452b6b8
commit
60136ea47a
24 changed files with 1218 additions and 559 deletions
|
|
@ -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?")
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
2
bun.lock
2
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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, string | number | boolean>
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,11 @@ export class SupermemoryInputProcessor implements Processor {
|
|||
async processInput(args: ProcessInputArgs): Promise<ProcessInputResult> {
|
||||
const { messages, messageList, requestContext } = args
|
||||
|
||||
// Mastra owns tagged system messages by tag. Clear the previous value on
|
||||
// every invocation so empty, skipped, cached, fresh, and error paths cannot
|
||||
// leave stale Supermemory context behind.
|
||||
messageList.clearSystemMessages("supermemory")
|
||||
|
||||
try {
|
||||
const queryText = extractQueryText(
|
||||
messages as unknown as Array<{
|
||||
|
|
|
|||
|
|
@ -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 = <T>(
|
||||
start: () => Promise<{ request: APIPromise<T> }>,
|
||||
): APIPromise<T> => {
|
||||
const ready = start()
|
||||
|
||||
const responsePromise = ready.then(async ({ request }) => ({
|
||||
response: await request.asResponse(),
|
||||
options: {} as never,
|
||||
controller: new AbortController(),
|
||||
}))
|
||||
|
||||
return new APIPromise<T>(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<string, unknown> | null
|
||||
updatedAt: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface SupermemoryProfileSearch {
|
||||
profile: {
|
||||
static?: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
dynamic?: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
static?: string[]
|
||||
dynamic?: string[]
|
||||
buckets?: Record<string, string[]>
|
||||
}
|
||||
searchResults: {
|
||||
results: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
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 = <T>(input: T): T => {
|
||||
if (!Array.isArray(input)) return input
|
||||
|
||||
let inputChanged = false
|
||||
const cleanedInput = input.map((item) => {
|
||||
if (!item || typeof item !== "object") return item
|
||||
const message = item as { role?: unknown; content?: unknown }
|
||||
if (message.role !== "system" && message.role !== "developer") return item
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
const content = stripMemoryContext(message.content)
|
||||
if (content === message.content) return item
|
||||
inputChanged = true
|
||||
return { ...item, content }
|
||||
}
|
||||
|
||||
if (!Array.isArray(message.content)) return item
|
||||
let contentChanged = false
|
||||
const content = message.content.map((part) => {
|
||||
if (!part || typeof part !== "object") return part
|
||||
const textPart = part as { type?: unknown; text?: unknown }
|
||||
if (
|
||||
(textPart.type !== "text" && textPart.type !== "input_text") ||
|
||||
typeof textPart.text !== "string"
|
||||
) {
|
||||
return part
|
||||
}
|
||||
const text = stripMemoryContext(textPart.text)
|
||||
if (text === textPart.text) return part
|
||||
contentChanged = true
|
||||
return { ...part, text }
|
||||
})
|
||||
|
||||
if (!contentChanged) return item
|
||||
inputChanged = true
|
||||
return { ...item, content }
|
||||
})
|
||||
|
||||
return (inputChanged ? cleanedInput : input) as T
|
||||
}
|
||||
|
||||
const getSearchResultMemories = (
|
||||
results: SupermemoryProfileSearchResult[] | undefined,
|
||||
): string[] => {
|
||||
return (results ?? []).flatMap((result) => {
|
||||
for (const value of [result.memory, result.chunk]) {
|
||||
if (typeof value === "string" && value.trim()) return [value.trim()]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
type ChatInstructionMessage =
|
||||
| OpenAI.Chat.Completions.ChatCompletionDeveloperMessageParam
|
||||
| OpenAI.Chat.Completions.ChatCompletionSystemMessageParam
|
||||
|
||||
const isChatInstructionMessage = (
|
||||
message: OpenAI.Chat.Completions.ChatCompletionMessageParam,
|
||||
): message is ChatInstructionMessage =>
|
||||
message.role === "developer" || message.role === "system"
|
||||
|
||||
const updateInstructionMessageMemoryContext = (
|
||||
message: ChatInstructionMessage,
|
||||
memories?: string,
|
||||
): ChatInstructionMessage => {
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
memories === undefined
|
||||
? stripMemoryContext(message.content)
|
||||
: replaceMemoryContext(message.content, memories),
|
||||
}
|
||||
}
|
||||
|
||||
let injected = false
|
||||
const content = message.content.map((part) => {
|
||||
if (memories !== undefined && !injected) {
|
||||
injected = true
|
||||
return { ...part, text: replaceMemoryContext(part.text, memories) }
|
||||
}
|
||||
return { ...part, text: stripMemoryContext(part.text) }
|
||||
})
|
||||
|
||||
if (memories !== undefined && !injected) {
|
||||
const memoryContext = wrapMemoryContext(memories)
|
||||
if (memoryContext) content.push({ type: "text", text: memoryContext })
|
||||
}
|
||||
|
||||
return { ...message, content }
|
||||
}
|
||||
|
||||
const updateChatMemoryContexts = (
|
||||
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
||||
memories?: string,
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] => {
|
||||
const developerIndex = messages.findIndex(
|
||||
(message) => message.role === "developer",
|
||||
)
|
||||
const injectionIndex =
|
||||
developerIndex >= 0
|
||||
? developerIndex
|
||||
: messages.findIndex((message) => message.role === "system")
|
||||
|
||||
return messages.map((message, index) => {
|
||||
if (!isChatInstructionMessage(message)) return message
|
||||
return updateInstructionMessageMemoryContext(
|
||||
message,
|
||||
memories !== undefined && index === injectionIndex ? memories : undefined,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> => {
|
||||
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<typeof originalResponsesCreate>[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<unknown>[] = []
|
||||
|
||||
if (addMemory === "always" && input?.trim()) {
|
||||
const content = customId ? `Input: ${input}` : input
|
||||
const memoryCustomId = customId ? `conversation:${customId}` : undefined
|
||||
|
||||
operations.push(
|
||||
addMemoryTool(client, containerTag, content, memoryCustomId, logger),
|
||||
)
|
||||
}
|
||||
if (shouldPersist) operations.push(persistResponsesInput())
|
||||
|
||||
const queryText = mode !== "profile" ? input : ""
|
||||
operations.push(
|
||||
|
|
@ -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<typeof originalResponsesCreate>[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<unknown>[] = []
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Types
|
||||
export type {
|
||||
MemoryPromptData,
|
||||
MemorySearchResult,
|
||||
ProfileSearchResult,
|
||||
PromptTemplate,
|
||||
MemoryMode,
|
||||
AddMemoryMode,
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
const deduplicatedSearchResults = rawSearchResults.flatMap((result) => {
|
||||
const memory = getMemoryText(result)
|
||||
if (!memory) return []
|
||||
const key = normalizeMemoryFact(memory)
|
||||
if (!visibleSearchKeys.has(key) || seenSearchKeys.has(key)) return []
|
||||
seenSearchKeys.add(key)
|
||||
return [{ ...result, memory }]
|
||||
})
|
||||
|
||||
const promptData: MemoryPromptData = {
|
||||
userMemories,
|
||||
generalSearchMemories,
|
||||
searchResults: memoriesResponse.searchResults?.results ?? [],
|
||||
searchResults: deduplicatedSearchResults,
|
||||
}
|
||||
|
||||
const memories = promptTemplate(promptData)
|
||||
|
|
|
|||
|
|
@ -3,21 +3,28 @@ export const MEMORY_CONTEXT_START =
|
|||
export const MEMORY_CONTEXT_END = "</supermemory>"
|
||||
|
||||
const MEMORY_CONTEXT_PATTERN =
|
||||
/[ \t]*<supermemory context="user-memories" readonly>[\s\S]*?<\/supermemory>[ \t]*/g
|
||||
/(?:\r?\n)?<supermemory context="user-memories" readonly>[\s\S]*?<\/supermemory>/g
|
||||
|
||||
const SUPERMEMORY_TAG_PATTERN = /<\s*\/?\s*supermemory\b[^>]*>/gi
|
||||
|
||||
/** Prevent retrieved text from terminating or nesting the SDK-owned block. */
|
||||
function escapeMemoryContextDelimiters(memories: string): string {
|
||||
return memories.replace(SUPERMEMORY_TAG_PATTERN, (tag) =>
|
||||
tag.replace("<", "<").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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -14,11 +14,28 @@ export interface MemoryPromptData {
|
|||
*/
|
||||
generalSearchMemories: string
|
||||
/**
|
||||
* Raw search results from the API for the current query.
|
||||
* Use this to traverse, filter, or selectively include results based on metadata.
|
||||
* Empty array if mode is "profile" or when no search was performed.
|
||||
* Metadata-preserving search results that remain after cross-source deduplication.
|
||||
* Use this to traverse, filter, or selectively include visible results.
|
||||
* The runtime always supplies an array (empty in profile mode or when no search
|
||||
* was performed).
|
||||
*/
|
||||
searchResults: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
searchResults: MemorySearchResult[]
|
||||
}
|
||||
|
||||
/** A raw query result returned inside `/v4/profile.searchResults.results`. */
|
||||
export interface ProfileSearchResult {
|
||||
id: string
|
||||
memory?: string
|
||||
chunk?: string
|
||||
metadata: Record<string, unknown> | null
|
||||
updatedAt: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
/** A visible, deduplicated query result provided to prompt templates. */
|
||||
export interface MemorySearchResult
|
||||
extends Omit<ProfileSearchResult, "memory"> {
|
||||
memory: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -73,19 +90,23 @@ export interface ProfileStructure {
|
|||
* Core, stable facts about the user that rarely change.
|
||||
* Examples: name, profession, long-term preferences, goals.
|
||||
*/
|
||||
static?: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
static?: string[]
|
||||
/**
|
||||
* Recently learned or frequently updated information about the user.
|
||||
* Examples: current projects, recent interests, ongoing topics.
|
||||
*/
|
||||
dynamic?: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
dynamic?: string[]
|
||||
/** Memories grouped by custom profile bucket. */
|
||||
buckets?: Record<string, string[]>
|
||||
}
|
||||
searchResults: {
|
||||
searchResults?: {
|
||||
/**
|
||||
* Memories retrieved based on semantic similarity to the current query.
|
||||
* Most relevant to the immediate conversation context.
|
||||
*/
|
||||
results: Array<{ memory: string; metadata?: Record<string, unknown> }>
|
||||
results: ProfileSearchResult[]
|
||||
total: number
|
||||
timing: number
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +120,7 @@ export interface ProfileMarkdownData {
|
|||
/** Recently learned or updated information (current projects, interests) */
|
||||
dynamic?: string[]
|
||||
}
|
||||
searchResults: {
|
||||
searchResults?: {
|
||||
/** Query-relevant memories based on semantic similarity */
|
||||
results: Array<{ memory: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,13 +265,17 @@ function hasCompleteContainerTagScope(
|
|||
* Memory item interface representing a single memory with optional metadata
|
||||
*/
|
||||
export interface MemoryItem {
|
||||
memory: string
|
||||
metadata?: Record<string, unknown>
|
||||
memory?: string
|
||||
chunk?: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile data structure containing memory items from different sources.
|
||||
* API may return either MemoryItem objects or plain strings.
|
||||
* Profile data from `/v4/profile`.
|
||||
*
|
||||
* Current profile arrays contain plain strings and search results contain
|
||||
* MemoryItem objects. Object profile entries and string search entries remain
|
||||
* accepted for compatibility with older API responses and SDK fixtures.
|
||||
*/
|
||||
export interface ProfileWithMemories {
|
||||
static?: Array<MemoryItem | string>
|
||||
|
|
@ -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<string>()
|
||||
|
||||
for (const item of staticItems as Array<MemoryItem | string>) {
|
||||
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<MemoryItem | string>) {
|
||||
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<MemoryItem | string>) {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <T extends LanguageModel>(
|
|||
: "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 = <T extends LanguageModel>(
|
|||
// 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 = <T extends LanguageModel>(
|
|||
: "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 = <T extends LanguageModel>(
|
|||
controller.enqueue(chunk)
|
||||
},
|
||||
flush: async () => {
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (
|
||||
ctx.addMemory === "always" &&
|
||||
userMessage &&
|
||||
userMessage.trim()
|
||||
hasPersistableUserContent(params)
|
||||
) {
|
||||
saveMemoryAfterResponse(
|
||||
ctx.client,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<any>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Bridges mutually exclusive provider major declarations.
|
||||
doStream(...args: any[]): PromiseLike<any>
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -43,15 +43,15 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
* @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<T extends VoltAgentConfig>
|
|||
* ```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<T extends VoltAgentConfig>
|
|||
* 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<T extends VoltAgentConfig>
|
|||
* 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<T extends VoltAgentConfig>
|
|||
* 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<T extends VoltAgentConfig>
|
|||
* agentConfig: {
|
||||
* name: "my-agent",
|
||||
* instructions: "...",
|
||||
* llm: new VercelAIProvider(),
|
||||
* model: openai("gpt-4o"),
|
||||
* },
|
||||
* containerTag: "user-123",
|
||||
|
|
@ -138,7 +133,7 @@ interface WithSupermemoryOptions<T extends VoltAgentConfig>
|
|||
*/
|
||||
export function withSupermemory<T extends VoltAgentConfig>(
|
||||
options: WithSupermemoryOptions<T>,
|
||||
): T {
|
||||
): T & { hooks: NonNullable<VoltAgentConfig["hooks"]> } {
|
||||
const { agentConfig, containerTag, ...supermemoryOptions } = options
|
||||
|
||||
// Create Supermemory hooks (internally creates its own context, validates API key)
|
||||
|
|
|
|||
|
|
@ -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, unknown> }>
|
||||
}) => string
|
||||
promptTemplate?: PromptTemplate
|
||||
/**
|
||||
* Per-turn memory cache. Stores the injected memories string for each
|
||||
* user turn (keyed by turnKey) to avoid redundant API calls.
|
||||
|
|
@ -65,7 +72,6 @@ export interface SupermemoryMiddlewareContext {
|
|||
// Storage parameters
|
||||
metadata?: Record<string, string | number | boolean>
|
||||
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<string> => {
|
||||
if (useAdvancedSearch && ctx.mode !== "profile") {
|
||||
ctx.logger.info("Using advanced search with custom parameters")
|
||||
|
||||
if (useAdvancedSearch && ctx.mode !== "profile") {
|
||||
ctx.logger.info("Using advanced search with custom parameters")
|
||||
|
||||
const searchParams: Supermemory.SearchParams = {
|
||||
q: queryText,
|
||||
containerTag: ctx.containerTag,
|
||||
}
|
||||
|
||||
if (ctx.threshold !== undefined) searchParams.threshold = ctx.threshold
|
||||
if (ctx.limit !== undefined) searchParams.limit = ctx.limit
|
||||
if (ctx.rerank !== undefined) searchParams.rerank = ctx.rerank
|
||||
if (ctx.rewriteQuery !== undefined)
|
||||
searchParams.rewriteQuery = ctx.rewriteQuery
|
||||
if (ctx.filters !== undefined) searchParams.filters = ctx.filters
|
||||
if (ctx.include !== undefined) searchParams.include = ctx.include
|
||||
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
|
||||
|
||||
const response = await ctx.client.search(searchParams)
|
||||
|
||||
// Hybrid search returns both memory entries (`memory` field) and
|
||||
// document chunks (`chunk` field). Normalize both for prompt templates.
|
||||
const searchResults = response.results.flatMap((result) => {
|
||||
const memory = result.memory ?? result.chunk
|
||||
if (!memory) {
|
||||
return []
|
||||
const searchParams: Supermemory.SearchParams = {
|
||||
q: queryText,
|
||||
containerTag: ctx.containerTag,
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
memory,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
const formattedMemories = searchResults
|
||||
.map((result) => `- ${result.memory}`)
|
||||
.join("\n")
|
||||
if (ctx.threshold !== undefined) searchParams.threshold = ctx.threshold
|
||||
if (ctx.limit !== undefined) searchParams.limit = ctx.limit
|
||||
if (ctx.rerank !== undefined) searchParams.rerank = ctx.rerank
|
||||
if (ctx.rewriteQuery !== undefined)
|
||||
searchParams.rewriteQuery = ctx.rewriteQuery
|
||||
if (ctx.filters !== undefined) searchParams.filters = ctx.filters
|
||||
if (ctx.include !== undefined) searchParams.include = ctx.include
|
||||
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
|
||||
|
||||
memories = ctx.promptTemplate
|
||||
? ctx.promptTemplate({
|
||||
userMemories: "",
|
||||
generalSearchMemories: formattedMemories,
|
||||
searchResults,
|
||||
const [response, profileResponse] = await Promise.all([
|
||||
ctx.client.search(searchParams),
|
||||
ctx.mode === "full"
|
||||
? supermemoryProfileSearch(
|
||||
ctx.containerTag,
|
||||
"",
|
||||
ctx.normalizedBaseUrl,
|
||||
ctx.apiKey,
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
|
||||
// Hybrid search returns both memory entries (`memory` field) and
|
||||
// document chunks (`chunk` field). Normalize both for prompt templates.
|
||||
const searchResults = response.results.flatMap((result) => {
|
||||
const memory = getMemoryText(result)
|
||||
if (!memory) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ ...result, memory }]
|
||||
})
|
||||
const deduplicated = deduplicateMemoriesForMode(ctx.mode, {
|
||||
static: profileResponse?.profile.static,
|
||||
dynamic: profileResponse?.profile.dynamic,
|
||||
searchResults,
|
||||
})
|
||||
const searchResultByKey = new Map<
|
||||
string,
|
||||
(typeof searchResults)[number]
|
||||
>()
|
||||
for (const result of searchResults) {
|
||||
const key = normalizeMemoryFact(result.memory)
|
||||
if (!searchResultByKey.has(key)) {
|
||||
searchResultByKey.set(key, result)
|
||||
}
|
||||
}
|
||||
const deduplicatedSearchResults = deduplicated.searchResults
|
||||
.map((memory) => {
|
||||
const original = searchResultByKey.get(normalizeMemoryFact(memory))
|
||||
return original ? { ...original, memory } : undefined
|
||||
})
|
||||
: `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}`
|
||||
} else {
|
||||
memories = await buildMemoriesText({
|
||||
.filter((result) => result !== undefined)
|
||||
const userMemories = convertProfileToMarkdown({
|
||||
profile: {
|
||||
static: deduplicated.static,
|
||||
dynamic: deduplicated.dynamic,
|
||||
},
|
||||
searchResults: { results: [] },
|
||||
})
|
||||
const generalSearchMemories =
|
||||
deduplicated.searchResults.length > 0
|
||||
? `Search results for user's recent message: \n${deduplicated.searchResults
|
||||
.map((memory) => `- ${memory}`)
|
||||
.join("\n")}`
|
||||
: ""
|
||||
|
||||
ctx.logger.debug("Advanced memory deduplication completed", {
|
||||
profileStatic: deduplicated.static.length,
|
||||
profileDynamic: deduplicated.dynamic.length,
|
||||
searchResults: deduplicated.searchResults.length,
|
||||
})
|
||||
|
||||
return (ctx.promptTemplate ?? defaultPromptTemplate)({
|
||||
userMemories,
|
||||
generalSearchMemories,
|
||||
searchResults: deduplicatedSearchResults,
|
||||
})
|
||||
}
|
||||
|
||||
return await buildMemoriesText({
|
||||
containerTag: ctx.containerTag,
|
||||
queryText,
|
||||
mode: ctx.mode,
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
109
packages/tools/src/voltagent/options.ts
Normal file
109
packages/tools/src/voltagent/options.ts
Normal file
|
|
@ -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<string, string | number | boolean>
|
||||
|
||||
/**
|
||||
* 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<Supermemory.SearchParams["filters"]>
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
|
@ -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<string, string | number | boolean>
|
||||
|
||||
/**
|
||||
* 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<Supermemory.SearchParams["filters"]>
|
||||
|
||||
/**
|
||||
* 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<AgentOptions, "hooks"> & {
|
||||
hooks?: AgentHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* VoltAgent hooks interface (simplified).
|
||||
* Hooks allow intercepting agent lifecycle events.
|
||||
*/
|
||||
export interface VoltAgentHooks {
|
||||
onStart?: (args: HookStartArgs) => void | Promise<void>
|
||||
onPrepareMessages?: (
|
||||
args: HookPrepareMessagesArgs,
|
||||
) =>
|
||||
| { messages?: VoltAgentMessage[] }
|
||||
| Promise<{ messages?: VoltAgentMessage[] }>
|
||||
onEnd?: (args: HookEndArgs) => void | Promise<void>
|
||||
[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 }
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue