supermemory/packages/tools/src/vercel/middleware.ts

326 lines
8.1 KiB
TypeScript

import Supermemory from "supermemory"
import {
addConversation,
type ConversationMessage,
} from "../conversations-client"
import { createLogger, type Logger } from "./logger"
import {
type LanguageModelCallOptions,
getLastUserMessage,
filterOutSupermemories,
} from "./util"
import {
buildMemoriesText,
extractQueryText,
injectMemoriesIntoParams,
normalizeBaseUrl,
type PromptTemplate,
} from "./memory-prompt"
const getConversationContent = (params: LanguageModelCallOptions) => {
return params.prompt
.filter((msg) => msg.role !== "system" && msg.role !== "tool")
.map((msg) => {
const role = msg.role === "user" ? "User" : "Assistant"
if (typeof msg.content === "string") {
return `${role}: ${filterOutSupermemories(msg.content)}`
}
const content = msg.content
.filter((c) => c.type === "text")
.map((c) => (c.type === "text" ? filterOutSupermemories(c.text) : ""))
.join(" ")
return `${role}: ${content}`
})
.join("\n\n")
}
const convertToConversationMessages = (
params: LanguageModelCallOptions,
assistantResponseText: string,
): ConversationMessage[] => {
const messages: ConversationMessage[] = []
for (const msg of params.prompt) {
if (msg.role === "system") {
continue
}
if (typeof msg.content === "string") {
if (msg.content) {
messages.push({
role: msg.role as "user" | "assistant" | "tool",
content: msg.content,
})
}
} else {
const contentParts = msg.content
.map((c) => {
if (c.type === "text" && c.text) {
return {
type: "text" as const,
text: c.text,
}
}
if (
c.type === "file" &&
typeof c.data === "string" &&
c.mediaType.startsWith("image/")
) {
return {
type: "image_url" as const,
image_url: { url: c.data },
}
}
return null
})
.filter((part) => part !== null)
if (contentParts.length > 0) {
messages.push({
role: msg.role as "user" | "assistant" | "tool",
content: contentParts,
})
}
}
}
if (assistantResponseText) {
messages.push({
role: "assistant",
content: assistantResponseText,
})
}
return messages
}
export const saveMemoryAfterResponse = async (
client: Supermemory,
containerTag: string,
conversationId: string | undefined,
assistantResponseText: string,
params: LanguageModelCallOptions,
logger: Logger,
apiKey: string,
baseUrl: string,
): Promise<void> => {
const customId = conversationId ? `conversation:${conversationId}` : undefined
try {
if (customId && conversationId) {
const conversationMessages = convertToConversationMessages(
params,
assistantResponseText,
)
const response = await addConversation({
conversationId,
messages: conversationMessages,
containerTags: [containerTag],
apiKey,
baseUrl,
})
logger.info("Conversation saved successfully via /v4/conversations", {
containerTag,
conversationId,
messageCount: conversationMessages.length,
responseId: response.id,
})
return
}
const userMessage = getLastUserMessage(params)
const content = conversationId
? `${getConversationContent(params)} \n\n Assistant: ${assistantResponseText}`
: `User: ${userMessage} \n\n Assistant: ${assistantResponseText}`
const response = await client.add({
content,
containerTags: [containerTag],
customId,
})
logger.info("Memory saved successfully via /v3/documents", {
containerTag,
customId,
content,
contentLength: content.length,
memoryId: response.id,
})
} catch (error) {
logger.error("Error saving memory", {
error: error instanceof Error ? error.message : "Unknown error",
})
}
}
/**
* Configuration options for the Supermemory middleware.
*/
interface SupermemoryMiddlewareOptions {
/** Container tag/identifier for memory search (e.g., user ID, project ID) */
containerTag: string
/** Supermemory API key */
apiKey: string
/** Optional conversation ID to group messages for contextual memory generation */
conversationId?: string
/** Enable detailed logging of memory search and injection */
verbose?: boolean
/**
* Memory retrieval mode:
* - "profile": Retrieves user profile memories (static + dynamic) without query filtering
* - "query": Searches memories based on semantic similarity to the user's message
* - "full": Combines both profile and query-based results
*/
mode?: "profile" | "query" | "full"
/**
* Memory persistence mode:
* - "always": Automatically save conversations as memories
* - "never": Only retrieve memories, don't store new ones
*/
addMemory?: "always" | "never"
/** Custom Supermemory API base URL */
baseUrl?: string
/** Custom function to format memory data into the system prompt */
promptTemplate?: PromptTemplate
}
/**
* Cached memories string for a user turn.
*/
type MemoryCache = string
interface SupermemoryMiddlewareContext {
client: Supermemory
logger: Logger
containerTag: string
conversationId?: string
mode: "profile" | "query" | "full"
addMemory: "always" | "never"
normalizedBaseUrl: string
apiKey: string
promptTemplate?: PromptTemplate
/**
* Per-turn memory cache map. Stores the injected memories string for each
* user turn (keyed by turnKey) to avoid redundant API calls during tool-call
*/
memoryCache: Map<string, MemoryCache>
}
export const createSupermemoryContext = (
options: SupermemoryMiddlewareOptions,
): SupermemoryMiddlewareContext => {
const {
containerTag,
apiKey,
conversationId,
verbose = false,
mode = "profile",
addMemory = "never",
baseUrl,
promptTemplate,
} = options
const logger = createLogger(verbose)
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
const client = new Supermemory({
apiKey,
...(normalizedBaseUrl !== "https://api.supermemory.ai"
? { baseURL: normalizedBaseUrl }
: {}),
})
return {
client,
logger,
containerTag,
conversationId,
mode,
addMemory,
normalizedBaseUrl,
apiKey,
promptTemplate,
memoryCache: new Map<string, MemoryCache>(),
}
}
/**
* Generates a cache key for the current turn based on context and user message.
* Normalizes the user message by trimming and collapsing whitespace.
*/
const makeTurnKey = (
ctx: SupermemoryMiddlewareContext,
userMessage: string,
): string => {
const normalizedMessage = userMessage.trim().replace(/\s+/g, " ")
return `${ctx.containerTag}:${ctx.conversationId || ""}:${ctx.mode}:${normalizedMessage}`
}
/**
* Checks if this is a new user turn (last message is from user)
*/
const isNewUserTurn = (params: LanguageModelCallOptions): boolean => {
const lastMessage = params.prompt.at(-1)
return lastMessage?.role === "user"
}
export const transformParamsWithMemory = async (
params: LanguageModelCallOptions,
ctx: SupermemoryMiddlewareContext,
): Promise<LanguageModelCallOptions> => {
const userMessage = getLastUserMessage(params)
if (ctx.mode !== "profile") {
if (!userMessage) {
ctx.logger.debug("No user message found, skipping memory search")
return params
}
}
const turnKey = makeTurnKey(ctx, userMessage || "")
const isNewTurn = isNewUserTurn(params)
// Check if we can use cached memories
const cachedMemories = ctx.memoryCache.get(turnKey)
if (!isNewTurn && cachedMemories) {
ctx.logger.debug("Using cached memories: ", {
turnKey,
})
return injectMemoriesIntoParams(params, cachedMemories, ctx.logger)
}
ctx.logger.info("Starting memory search", {
containerTag: ctx.containerTag,
conversationId: ctx.conversationId,
mode: ctx.mode,
isNewTurn,
cacheHit: false,
})
const queryText = extractQueryText(params, ctx.mode)
const memories = await buildMemoriesText({
containerTag: ctx.containerTag,
queryText,
mode: ctx.mode,
baseUrl: ctx.normalizedBaseUrl,
apiKey: ctx.apiKey,
logger: ctx.logger,
promptTemplate: ctx.promptTemplate,
})
ctx.memoryCache.set(turnKey, memories)
ctx.logger.debug("Cached memories for turn", { turnKey })
return injectMemoriesIntoParams(params, memories, ctx.logger)
}
export const extractAssistantResponseText = (content: unknown[]): string => {
return (content as Array<{ type: string; text?: string }>)
.map((item) => (item.type === "text" ? item.text || "" : ""))
.join("")
}