Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions packages/tools/src/mastra/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -72,7 +94,6 @@ function createProcessorContext(
addMemory: options.addMemory ?? "always",
logger,
promptTemplate: options.promptTemplate,
memoryCache: new MemoryCache<string>(),
}
}

Expand Down Expand Up @@ -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(
Expand All @@ -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
}

Expand All @@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/tools/src/openai/middleware.empty-memory.test.ts
Original file line number Diff line number Diff line change
@@ -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"),
}),
]),
}),
)
})
})
24 changes: 23 additions & 1 deletion packages/tools/src/openai/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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
Expand All @@ -31,7 +44,6 @@ interface SupermemoryProfileSearch {
results: Array<{ memory: string; metadata?: Record<string, unknown> }>
}
}

/**
* Extracts the last user message from an array of chat completion messages.
*
Expand Down Expand Up @@ -205,6 +217,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({
Expand Down Expand Up @@ -492,6 +509,11 @@ export function createOpenAIMiddleware(
},
})

if (!hasRelevantMemories(mode, deduplicated)) {
logger.debug(`No memories found for ${context} API prompt injection`)
return ""
}

const profileData =
mode !== "query"
? convertProfileToMarkdown({
Expand Down
43 changes: 43 additions & 0 deletions packages/tools/src/shared/memory-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions packages/tools/src/shared/memory-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions packages/tools/src/vercel/memory-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
4 changes: 2 additions & 2 deletions packages/tools/src/vercel/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
Loading