mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-11-05 07:39:44 +00:00
385 lines
9.6 KiB
TypeScript
385 lines
9.6 KiB
TypeScript
import { Supermemory } from "supermemory"
|
|
import {
|
|
addConversation,
|
|
type ContentPart,
|
|
type ConversationMessage,
|
|
} from "../conversations-client"
|
|
import {
|
|
createLogger,
|
|
normalizeBaseUrl,
|
|
MemoryCache,
|
|
buildMemoriesText,
|
|
type Logger,
|
|
type PromptTemplate,
|
|
type MemoryMode,
|
|
} from "../shared"
|
|
import {
|
|
type LanguageModelCallOptions,
|
|
getLastUserMessage,
|
|
imageFilePartUrl,
|
|
} from "./util"
|
|
import { extractQueryText, injectMemoriesIntoParams } from "./memory-prompt"
|
|
|
|
const safeJsonStringify = (value: unknown): string => {
|
|
try {
|
|
return JSON.stringify(value) ?? ""
|
|
} catch {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
const serializeToolOutput = (output: unknown): string => {
|
|
if (typeof output === "string") return output
|
|
if (typeof output !== "object" || output === null) {
|
|
return safeJsonStringify(output)
|
|
}
|
|
|
|
const wrapper = output as {
|
|
type?: unknown
|
|
value?: unknown
|
|
reason?: unknown
|
|
}
|
|
|
|
if (
|
|
(wrapper.type === "text" || wrapper.type === "error-text") &&
|
|
typeof wrapper.value === "string"
|
|
) {
|
|
return wrapper.value
|
|
}
|
|
if (
|
|
wrapper.type === "json" ||
|
|
wrapper.type === "error-json" ||
|
|
wrapper.type === "content"
|
|
) {
|
|
return safeJsonStringify(wrapper.value)
|
|
}
|
|
if (wrapper.type === "execution-denied") {
|
|
return typeof wrapper.reason === "string" && wrapper.reason
|
|
? wrapper.reason
|
|
: "Tool execution denied"
|
|
}
|
|
|
|
return safeJsonStringify(output)
|
|
}
|
|
|
|
export const convertToConversationMessages = (
|
|
params: LanguageModelCallOptions,
|
|
assistantResponseText: string,
|
|
includeToolCalls = false,
|
|
): 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,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
let contentParts: ContentPart[] = []
|
|
let toolCalls: NonNullable<ConversationMessage["tool_calls"]> = []
|
|
|
|
// Flush any pending assistant/user content accumulated so far. Called
|
|
// before each tool-result so a tool result never jumps ahead of the
|
|
// text/tool-calls that preceded it (or behind text that follows it),
|
|
// preserving the original chronology for memory extraction.
|
|
const flushContent = () => {
|
|
if (contentParts.length > 0 || toolCalls.length > 0) {
|
|
messages.push({
|
|
role: msg.role as "user" | "assistant" | "tool",
|
|
content: contentParts.length > 0 ? contentParts : "",
|
|
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
})
|
|
contentParts = []
|
|
toolCalls = []
|
|
}
|
|
}
|
|
|
|
for (const content of msg.content) {
|
|
if (content.type === "text" && content.text) {
|
|
contentParts.push({
|
|
type: "text",
|
|
text: content.text,
|
|
})
|
|
} else if (content.type === "file") {
|
|
const url = imageFilePartUrl(content)
|
|
if (url) {
|
|
contentParts.push({ type: "image_url", imageUrl: { url } })
|
|
}
|
|
} else if (
|
|
includeToolCalls &&
|
|
content.type === "tool-call" &&
|
|
msg.role === "assistant"
|
|
) {
|
|
toolCalls.push({
|
|
id: content.toolCallId,
|
|
type: "function",
|
|
function: {
|
|
name: content.toolName,
|
|
arguments: safeJsonStringify(content.input) || "{}",
|
|
},
|
|
})
|
|
} else if (includeToolCalls && content.type === "tool-result") {
|
|
flushContent()
|
|
messages.push({
|
|
role: "tool",
|
|
content: serializeToolOutput(content.output),
|
|
tool_call_id: content.toolCallId,
|
|
})
|
|
}
|
|
}
|
|
|
|
flushContent()
|
|
}
|
|
|
|
if (assistantResponseText) {
|
|
messages.push({
|
|
role: "assistant",
|
|
content: assistantResponseText,
|
|
})
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
export const saveMemoryAfterResponse = async (
|
|
_client: Supermemory,
|
|
namespace: string,
|
|
id: string,
|
|
assistantResponseText: string,
|
|
params: LanguageModelCallOptions,
|
|
logger: Logger,
|
|
apiKey: string,
|
|
baseUrl: string,
|
|
includeToolCalls = false,
|
|
): Promise<void> => {
|
|
try {
|
|
const conversationMessages = convertToConversationMessages(
|
|
params,
|
|
assistantResponseText,
|
|
includeToolCalls,
|
|
)
|
|
|
|
const response = await addConversation({
|
|
id,
|
|
messages: conversationMessages,
|
|
namespace,
|
|
apiKey,
|
|
baseUrl,
|
|
})
|
|
|
|
logger.info("Conversation saved successfully", {
|
|
namespace,
|
|
id,
|
|
messageCount: conversationMessages.length,
|
|
responseId: 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 {
|
|
/** Namespace for memory search (e.g., user ID, project ID) */
|
|
namespace: string
|
|
/** Supermemory API key */
|
|
apiKey: string
|
|
/** ID that groups messages into a single document. Required. */
|
|
id: 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?: MemoryMode
|
|
/**
|
|
* 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
|
|
/**
|
|
* Persist assistant tool calls and tool results as part of the saved
|
|
* conversation. Off by default: tool payloads are often large and
|
|
* low-signal, and would pollute memory extraction.
|
|
*/
|
|
includeToolCalls?: boolean
|
|
/** Custom function to format memory data into the system prompt */
|
|
promptTemplate?: PromptTemplate
|
|
/** Max wait (ms) for the pre-LLM profile retrieval. Omit for no limit (e.g. tests). `withSupermemory` sets this internally. */
|
|
memoryRetrievalTimeoutMs?: number
|
|
}
|
|
|
|
interface SupermemoryMiddlewareContext {
|
|
client: Supermemory
|
|
logger: Logger
|
|
namespace: string
|
|
id: string
|
|
mode: MemoryMode
|
|
addMemory: "always" | "never"
|
|
includeToolCalls: boolean
|
|
normalizedBaseUrl: string
|
|
apiKey: string
|
|
promptTemplate?: PromptTemplate
|
|
memoryRetrievalTimeoutMs?: number
|
|
/**
|
|
* Per-turn memory cache. Stores the injected memories string for each
|
|
* user turn (keyed by turnKey) to avoid redundant API calls during tool-call
|
|
*/
|
|
memoryCache: MemoryCache<string>
|
|
}
|
|
|
|
export const createSupermemoryContext = (
|
|
options: SupermemoryMiddlewareOptions,
|
|
): SupermemoryMiddlewareContext => {
|
|
const {
|
|
namespace,
|
|
apiKey,
|
|
id,
|
|
verbose = false,
|
|
mode = "profile",
|
|
addMemory = "always",
|
|
baseUrl,
|
|
includeToolCalls = false,
|
|
promptTemplate,
|
|
memoryRetrievalTimeoutMs,
|
|
} = options
|
|
|
|
const logger = createLogger(verbose)
|
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
|
|
|
|
const client = new Supermemory({
|
|
apiKey,
|
|
...(normalizedBaseUrl !== "https://api.supermemory.ai"
|
|
? { baseUrl: normalizedBaseUrl }
|
|
: {}),
|
|
})
|
|
|
|
return {
|
|
client,
|
|
logger,
|
|
namespace,
|
|
id,
|
|
mode,
|
|
addMemory,
|
|
includeToolCalls,
|
|
normalizedBaseUrl,
|
|
apiKey,
|
|
promptTemplate,
|
|
...(memoryRetrievalTimeoutMs !== undefined
|
|
? { memoryRetrievalTimeoutMs }
|
|
: {}),
|
|
memoryCache: new MemoryCache<string>(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a cache key for the current turn based on context and user message.
|
|
* Uses the shared MemoryCache.makeTurnKey implementation.
|
|
*/
|
|
const makeTurnKey = (
|
|
ctx: SupermemoryMiddlewareContext,
|
|
userMessage: string,
|
|
): string => {
|
|
return MemoryCache.makeTurnKey(ctx.namespace, ctx.id, ctx.mode, userMessage)
|
|
}
|
|
|
|
/**
|
|
* 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 and clearing stale context",
|
|
)
|
|
return injectMemoriesIntoParams(params, "", ctx.logger)
|
|
}
|
|
}
|
|
|
|
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", {
|
|
namespace: ctx.namespace,
|
|
id: ctx.id,
|
|
mode: ctx.mode,
|
|
isNewTurn,
|
|
cacheHit: false,
|
|
})
|
|
|
|
const queryText = extractQueryText(params, ctx.mode)
|
|
|
|
let fetchSignal: AbortSignal | undefined
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
const timeoutMs = ctx.memoryRetrievalTimeoutMs
|
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
const controller = new AbortController()
|
|
fetchSignal = controller.signal
|
|
timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
}
|
|
|
|
let memories: string
|
|
try {
|
|
memories = await buildMemoriesText({
|
|
namespace: ctx.namespace,
|
|
queryText,
|
|
mode: ctx.mode,
|
|
baseUrl: ctx.normalizedBaseUrl,
|
|
apiKey: ctx.apiKey,
|
|
logger: ctx.logger,
|
|
promptTemplate: ctx.promptTemplate,
|
|
...(fetchSignal ? { signal: fetchSignal } : {}),
|
|
})
|
|
} finally {
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
|
|
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("")
|
|
}
|