This commit is contained in:
abhinav7x94 2026-08-26 04:01:27 +05:30 committed by GitHub
commit 363650cc44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 579 additions and 25 deletions

View file

@ -50,7 +50,29 @@ interface ProcessorContext {
addMemory: "always" | "never"
logger: Logger
promptTemplate?: PromptTemplate
memoryCache: MemoryCache<string>
}
const REQUEST_MEMORY_CACHE_KEY = "supermemory.memoryCache"
/**
* Gets the cache scoped to the current Mastra request.
*
* Mastra processor instances can be reused across requests, while `state` is
* created for one request and shared across that request's processor calls.
*/
function getRequestMemoryCache(
state?: Record<string, unknown>,
): MemoryCache<string> {
const existingCache = state?.[REQUEST_MEMORY_CACHE_KEY]
if (existingCache instanceof MemoryCache) {
return existingCache
}
const memoryCache = new MemoryCache<string>()
if (state) {
state[REQUEST_MEMORY_CACHE_KEY] = memoryCache
}
return memoryCache
}
/**
@ -72,7 +94,6 @@ function createProcessorContext(
addMemory: options.addMemory ?? "always",
logger,
promptTemplate: options.promptTemplate,
memoryCache: new MemoryCache<string>(),
}
}
@ -136,7 +157,7 @@ export class SupermemoryInputProcessor implements Processor {
}
async processInput(args: ProcessInputArgs): Promise<ProcessInputResult> {
const { messages, messageList, requestContext } = args
const { messages, messageList, requestContext, state } = args
try {
const queryText = extractQueryText(
@ -159,11 +180,14 @@ export class SupermemoryInputProcessor implements Processor {
this.ctx.mode,
queryText || "",
)
const memoryCache = getRequestMemoryCache(state)
const cachedMemories = this.ctx.memoryCache.get(turnKey)
if (cachedMemories) {
if (memoryCache.has(turnKey)) {
const cachedMemories = memoryCache.get(turnKey) ?? ""
this.ctx.logger.debug("Using cached memories", { turnKey })
messageList.addSystem(cachedMemories, "supermemory")
if (cachedMemories) {
messageList.addSystem(cachedMemories, "supermemory")
}
return messageList
}
@ -183,8 +207,8 @@ export class SupermemoryInputProcessor implements Processor {
promptTemplate: this.ctx.promptTemplate,
})
memoryCache.set(turnKey, memories)
if (memories) {
this.ctx.memoryCache.set(turnKey, memories)
messageList.addSystem(memories, "supermemory")
this.ctx.logger.debug("Injected memories into system prompt", {
length: memories.length,

View file

@ -0,0 +1,146 @@
import type OpenAI from "openai"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { withSupermemory } from "./index"
const emptyProfile = {
profile: { static: [], dynamic: [] },
searchResults: { results: [] },
}
const originalApiKey = process.env.SUPERMEMORY_API_KEY
function createClient() {
const chatCreate = vi.fn(async () => ({}))
const responsesCreate = vi.fn(async () => ({}))
const client = {
chat: { completions: { create: chatCreate } },
responses: { create: responsesCreate },
} as unknown as OpenAI
return { client, chatCreate, responsesCreate }
}
function mockProfileResponse(body: unknown) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => body,
}),
)
}
beforeEach(() => {
process.env.SUPERMEMORY_API_KEY = "test-api-key"
})
afterEach(() => {
if (originalApiKey === undefined) {
delete process.env.SUPERMEMORY_API_KEY
} else {
process.env.SUPERMEMORY_API_KEY = originalApiKey
}
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe("OpenAI middleware memory injection", () => {
it.each([
"profile",
"query",
"full",
] as const)("leaves chat messages unchanged for empty %s results", async (mode) => {
mockProfileResponse(emptyProfile)
const { client, chatCreate } = createClient()
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "user", content: "Hello" },
]
const wrapped = withSupermemory(client, {
containerTag: "user-123",
customId: "conversation-123",
mode,
addMemory: "never",
})
await wrapped.chat.completions.create({ model: "gpt-4o", messages })
expect(chatCreate).toHaveBeenCalledWith({ model: "gpt-4o", messages })
})
it("does not append whitespace to an existing system message", async () => {
mockProfileResponse(emptyProfile)
const { client, chatCreate } = createClient()
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
]
const wrapped = withSupermemory(client, {
containerTag: "user-123",
customId: "conversation-123",
mode: "full",
addMemory: "never",
})
await wrapped.chat.completions.create({ model: "gpt-4o", messages })
expect(chatCreate).toHaveBeenCalledWith({ model: "gpt-4o", messages })
})
it.each([
"query",
"full",
] as const)("keeps Responses API instructions unchanged for empty %s results", async (mode) => {
mockProfileResponse(emptyProfile)
const { client, responsesCreate } = createClient()
const wrapped = withSupermemory(client, {
containerTag: "user-123",
customId: "conversation-123",
mode,
addMemory: "never",
})
await wrapped.responses.create({
model: "gpt-4o",
input: "Hello",
instructions: "You are helpful.",
})
expect(responsesCreate).toHaveBeenCalledWith({
model: "gpt-4o",
input: "Hello",
instructions: "You are helpful.",
})
})
it("still injects non-empty memories", async () => {
mockProfileResponse({
profile: {
static: [{ memory: "User likes TypeScript" }],
dynamic: [],
},
searchResults: { results: [] },
})
const { client, chatCreate } = createClient()
const wrapped = withSupermemory(client, {
containerTag: "user-123",
customId: "conversation-123",
mode: "profile",
addMemory: "never",
})
await wrapped.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Hello" }],
})
expect(chatCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "system",
content: expect.stringContaining("User likes TypeScript"),
}),
]),
}),
)
})
})

View file

@ -12,6 +12,19 @@ const normalizeBaseUrl = (url?: string): string => {
return url.endsWith("/") ? url.slice(0, -1) : url
}
const hasRelevantMemories = (
mode: "profile" | "query" | "full",
memories: { static: string[]; dynamic: string[]; searchResults: string[] },
): boolean => {
const hasProfileMemories =
mode !== "query" &&
(memories.static.length > 0 || memories.dynamic.length > 0)
const hasSearchMemories =
mode !== "profile" && memories.searchResults.length > 0
return hasProfileMemories || hasSearchMemories
}
export interface OpenAIMiddlewareOptions {
/** Container tag/identifier for memory search (e.g., user ID, project ID). Required. */
containerTag: string
@ -33,7 +46,6 @@ interface SupermemoryProfileSearch {
results: Array<{ memory: string; metadata?: Record<string, unknown> }>
}
}
/**
* Extracts the last user message from an array of chat completion messages.
*
@ -216,6 +228,11 @@ const addSystemPrompt = async (
},
})
if (!hasRelevantMemories(mode, deduplicated)) {
logger.debug("No memories found for chat API prompt injection")
return messages
}
const profileData =
mode !== "query"
? convertProfileToMarkdown({
@ -506,6 +523,11 @@ export function createOpenAIMiddleware(
},
})
if (!hasRelevantMemories(mode, deduplicated)) {
logger.debug(`No memories found for ${context} API prompt injection`)
return ""
}
const profileData =
mode !== "query"
? convertProfileToMarkdown({

View file

@ -23,6 +23,49 @@ afterEach(() => {
})
describe("buildMemoriesText", () => {
it.each([
"profile",
"query",
"full",
] as const)("returns no prompt when %s mode finds no memories", async (mode) => {
mockProfileResponse({
profile: { static: [], dynamic: [] },
searchResults: { results: [] },
})
const memories = await buildMemoriesText({
containerTag: CONTAINER_TAG,
queryText: mode === "profile" ? "" : "what do you know about me?",
mode,
baseUrl: BASE_URL,
apiKey: API_KEY,
logger,
})
expect(memories).toBe("")
})
it("does not invoke a custom template when no memories are found", async () => {
mockProfileResponse({
profile: { static: [], dynamic: [] },
searchResults: { results: [] },
})
const promptTemplate = vi.fn(() => "<user_memories></user_memories>")
const memories = await buildMemoriesText({
containerTag: CONTAINER_TAG,
queryText: "what do you know about me?",
mode: "full",
baseUrl: BASE_URL,
apiKey: API_KEY,
logger,
promptTemplate,
})
expect(memories).toBe("")
expect(promptTemplate).not.toHaveBeenCalled()
})
// The profile is not injected in "query" mode. Deduplicating the search
// results against it would drop a fact present in both, leaving the model
// with nothing.

View file

@ -140,6 +140,17 @@ export const buildMemoriesText = async (
},
})
const hasProfileMemories =
mode !== "query" &&
(deduplicated.static.length > 0 || deduplicated.dynamic.length > 0)
const hasSearchMemories =
mode !== "profile" && deduplicated.searchResults.length > 0
if (!hasProfileMemories && !hasSearchMemories) {
logger.debug("No memories found for prompt injection")
return ""
}
const userMemories =
mode !== "query"
? convertProfileToMarkdown({

View file

@ -61,6 +61,11 @@ export const injectMemoriesIntoParams = (
memories: string,
logger: Logger,
): LanguageModelCallOptions => {
if (!memories.trim()) {
logger.debug("No memories to inject")
return params
}
const systemPromptExists = params.prompt.some(
(prompt) => prompt.role === "system",
)

View file

@ -330,8 +330,8 @@ export const transformParamsWithMemory = async (
const isNewTurn = isNewUserTurn(params)
// Check if we can use cached memories
const cachedMemories = ctx.memoryCache.get(turnKey)
if (!isNewTurn && cachedMemories) {
if (!isNewTurn && ctx.memoryCache.has(turnKey)) {
const cachedMemories = ctx.memoryCache.get(turnKey) ?? ""
ctx.logger.debug("Using cached memories: ", {
turnKey,
})

View file

@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import {
createSupermemoryContext,
enhanceMessagesWithMemories,
} from "./middleware"
import type { VoltAgentMessage } from "./types"
const userMessage: VoltAgentMessage = {
role: "user",
content: "Hello",
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe("enhanceMessagesWithMemories", () => {
it("caches an empty profile result without injecting a system message", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
profile: { static: [], dynamic: [] },
searchResults: { results: [] },
}),
})
vi.stubGlobal("fetch", fetchMock)
const context = createSupermemoryContext("user-123", {
apiKey: "test-api-key",
customId: "conversation-123",
mode: "profile",
addMemory: "never",
})
const firstResult = await enhanceMessagesWithMemories(
[userMessage],
context,
)
const continuation: VoltAgentMessage[] = [
userMessage,
{ role: "assistant", content: "Hi there!" },
]
const secondResult = await enhanceMessagesWithMemories(
continuation,
context,
)
expect(firstResult).toEqual([userMessage])
expect(secondResult).toEqual(continuation)
expect(context.memoryCache.size).toBe(1)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it("does not inject a placeholder for an empty advanced search", async () => {
const promptTemplate = vi.fn(() => "<memories></memories>")
const context = createSupermemoryContext("user-123", {
apiKey: "test-api-key",
customId: "conversation-123",
mode: "query",
addMemory: "never",
limit: 5,
promptTemplate,
})
const search = vi
.spyOn(context.client.search, "memories")
.mockResolvedValue({ results: [] } as never)
const result = await enhanceMessagesWithMemories([userMessage], context)
const continuation: VoltAgentMessage[] = [
userMessage,
{ role: "assistant", content: "Hi there!" },
]
const continuationResult = await enhanceMessagesWithMemories(
continuation,
context,
)
expect(result).toEqual([userMessage])
expect(continuationResult).toEqual(continuation)
expect(search).toHaveBeenCalledTimes(1)
expect(promptTemplate).not.toHaveBeenCalled()
})
it("still injects non-empty advanced search results", async () => {
const context = createSupermemoryContext("user-123", {
apiKey: "test-api-key",
customId: "conversation-123",
mode: "query",
addMemory: "never",
searchMode: "hybrid",
})
vi.spyOn(context.client.search, "memories").mockResolvedValue({
results: [{ chunk: "A relevant document chunk" }],
} as never)
const result = await enhanceMessagesWithMemories([userMessage], context)
expect(result[0]).toEqual(
expect.objectContaining({
role: "system",
content: expect.stringContaining("A relevant document chunk"),
}),
)
})
})

View file

@ -211,8 +211,8 @@ export const enhanceMessagesWithMemories = async (
const turnKey = makeTurnKey(ctx, userMessage || "")
const isNewTurn = isNewUserTurn(messages)
const cachedMemories = ctx.memoryCache.get(turnKey)
if (!isNewTurn && cachedMemories) {
if (!isNewTurn && ctx.memoryCache.has(turnKey)) {
const cachedMemories = ctx.memoryCache.get(turnKey) ?? ""
ctx.logger.debug("Using cached memories", { turnKey })
return injectMemoriesIntoMessages(
messagesToEnhance,
@ -300,21 +300,23 @@ export const enhanceMessagesWithMemories = async (
const formattedMemories = response.results
.map((result: SearchResult) => {
const text = result.memory || result.chunk
return text ? `- ${text}` : null
return text?.trim() ? `- ${text}` : null
})
.filter(Boolean)
.join("\n")
memories = ctx.promptTemplate
? ctx.promptTemplate({
userMemories: "",
generalSearchMemories: formattedMemories,
searchResults: response.results as Array<{
memory: string
metadata?: Record<string, unknown>
}>,
})
: `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}`
memories = formattedMemories
? ctx.promptTemplate
? ctx.promptTemplate({
userMemories: "",
generalSearchMemories: formattedMemories,
searchResults: response.results as Array<{
memory: string
metadata?: Record<string, unknown>
}>,
})
: `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({
containerTag: ctx.containerTag,
@ -346,6 +348,11 @@ const injectMemoriesIntoMessages = (
memories: string,
logger: Logger,
): VoltAgentMessage[] => {
if (!memories.trim()) {
logger.debug("No memories to inject")
return messages
}
const systemMessageIndex = messages.findIndex((msg) => msg.role === "system")
if (systemMessageIndex !== -1) {

View file

@ -212,7 +212,7 @@ describe.skipIf(!shouldRunIntegration)(
fetchSpy.mockRestore()
})
it("should cache memories for repeated calls with same message", async () => {
it("should cache memories for repeated calls within a request", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryInputProcessor({
@ -226,11 +226,13 @@ describe.skipIf(!shouldRunIntegration)(
const messages: MastraDBMessage[] = [
createMessage("user", "Cache test message"),
]
const state: Record<string, unknown> = {}
const args1: ProcessInputArgs = {
messages,
systemMessages: [],
messageList: createIntegrationMessageList(),
state,
abort: vi.fn() as never,
retryCount: 0,
}
@ -245,6 +247,7 @@ describe.skipIf(!shouldRunIntegration)(
messages,
systemMessages: [],
messageList: createIntegrationMessageList(),
state,
abort: vi.fn() as never,
retryCount: 0,
}

View file

@ -201,7 +201,46 @@ describe("SupermemoryInputProcessor", () => {
expect(systemCall?.args[1]).toBe("supermemory")
})
it("should use cached memories on second call with same message", async () => {
it("should cache an empty result without injecting a system message", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(createMockProfileResponse()),
})
const processor = new SupermemoryInputProcessor({
containerTag: TEST_CONFIG.containerTag,
customId: TEST_CONFIG.customId,
apiKey: TEST_CONFIG.apiKey,
mode: "profile",
})
const messages: MastraDBMessage[] = [createMessage("user", "Hello")]
const firstMessageList = createMockMessageList()
const secondMessageList = createMockMessageList()
const state: Record<string, unknown> = {}
await processor.processInput({
messages,
systemMessages: [],
messageList: firstMessageList,
abort: vi.fn() as never,
retryCount: 0,
state,
})
await processor.processInput({
messages,
systemMessages: [],
messageList: secondMessageList,
abort: vi.fn() as never,
retryCount: 0,
state,
})
expect(firstMessageList.addSystem).not.toHaveBeenCalled()
expect(secondMessageList.addSystem).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it("should use cached memories within the same request", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () =>
@ -216,11 +255,13 @@ describe("SupermemoryInputProcessor", () => {
})
const messages: MastraDBMessage[] = [createMessage("user", "Hello")]
const state: Record<string, unknown> = {}
const args1: ProcessInputArgs = {
messages,
systemMessages: [],
messageList: createMockMessageList(),
state,
abort: vi.fn() as never,
retryCount: 0,
}
@ -232,6 +273,7 @@ describe("SupermemoryInputProcessor", () => {
messages,
systemMessages: [],
messageList: createMockMessageList(),
state,
abort: vi.fn() as never,
retryCount: 0,
}
@ -240,6 +282,104 @@ describe("SupermemoryInputProcessor", () => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it("should not reuse an empty profile result across requests", async () => {
let callCount = 0
fetchMock.mockImplementation(() => {
const currentCall = ++callCount
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
currentCall === 1
? createMockProfileResponse()
: createMockProfileResponse(["Profile from second request"]),
),
})
})
const processor = new SupermemoryInputProcessor({
containerTag: TEST_CONFIG.containerTag,
customId: TEST_CONFIG.customId,
apiKey: TEST_CONFIG.apiKey,
mode: "profile",
})
const messages: MastraDBMessage[] = [createMessage("user", "Hello")]
const firstMessageList = createMockMessageList()
const secondMessageList = createMockMessageList()
await processor.processInput({
messages,
systemMessages: [],
messageList: firstMessageList,
state: {},
abort: vi.fn() as never,
retryCount: 0,
})
await processor.processInput({
messages,
systemMessages: [],
messageList: secondMessageList,
state: {},
abort: vi.fn() as never,
retryCount: 0,
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(firstMessageList.addSystem).not.toHaveBeenCalled()
expect(secondMessageList.calls[0]?.args[0]).toContain(
"Profile from second request",
)
})
it("should not share a cache when request state is unavailable", async () => {
let callCount = 0
fetchMock.mockImplementation(() => {
const currentCall = ++callCount
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
createMockProfileResponse([`Profile from call ${currentCall}`]),
),
})
})
const processor = new SupermemoryInputProcessor({
containerTag: TEST_CONFIG.containerTag,
customId: TEST_CONFIG.customId,
apiKey: TEST_CONFIG.apiKey,
mode: "profile",
})
const messages: MastraDBMessage[] = [createMessage("user", "Hello")]
const firstMessageList = createMockMessageList()
const secondMessageList = createMockMessageList()
await processor.processInput({
messages,
systemMessages: [],
messageList: firstMessageList,
state: undefined as never,
abort: vi.fn() as never,
retryCount: 0,
})
await processor.processInput({
messages,
systemMessages: [],
messageList: secondMessageList,
state: undefined as never,
abort: vi.fn() as never,
retryCount: 0,
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(firstMessageList.calls[0]?.args[0]).toContain(
"Profile from call 1",
)
expect(secondMessageList.calls[0]?.args[0]).toContain(
"Profile from call 2",
)
})
it("should refetch memories for different user message", async () => {
let callCount = 0
fetchMock.mockImplementation(() => {
@ -259,11 +399,13 @@ describe("SupermemoryInputProcessor", () => {
apiKey: TEST_CONFIG.apiKey,
mode: "query",
})
const state: Record<string, unknown> = {}
const args1: ProcessInputArgs = {
messages: [createMessage("user", "First message")],
systemMessages: [],
messageList: createMockMessageList(),
state,
abort: vi.fn() as never,
retryCount: 0,
}
@ -275,6 +417,7 @@ describe("SupermemoryInputProcessor", () => {
messages: [createMessage("user", "Different message")],
systemMessages: [],
messageList: createMockMessageList(),
state,
abort: vi.fn() as never,
retryCount: 0,
}

View file

@ -358,6 +358,51 @@ describe("Unit: withSupermemory", () => {
expect(fetchMock).not.toHaveBeenCalled()
})
it("should leave the prompt unchanged when no memories are found", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(createMockProfileResponse()),
})
const ctx = createSupermemoryContext({
containerTag: TEST_CONFIG.containerTag,
apiKey: TEST_CONFIG.apiKey,
customId: "test-id",
mode: "profile",
})
const params: LanguageModelV2CallOptions = {
prompt: [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
],
}
const result = await transformParamsWithMemory(params, ctx)
expect(result).toEqual(params)
expect(ctx.memoryCache.size).toBe(1)
const continuationParams: LanguageModelV2CallOptions = {
prompt: [
...params.prompt,
{
role: "assistant",
content: [{ type: "text", text: "Hi there!" }],
},
],
}
const continuationResult = await transformParamsWithMemory(
continuationParams,
ctx,
)
expect(continuationResult).toEqual(continuationParams)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it("should handle user message with empty content array in query mode", async () => {
const ctx = createSupermemoryContext({
containerTag: TEST_CONFIG.containerTag,