supermemory/packages/tools/src/shared/memory-client.ts

291 lines
7 KiB
TypeScript

import { deduplicateMemoriesForMode } from "../tools-shared"
import type {
Logger,
MemoryMode,
MemoryPromptData,
ProfileStructure,
PromptTemplate,
} from "./types"
import {
convertProfileToMarkdown,
defaultPromptTemplate,
} from "./prompt-builder"
/**
* Fetches profile and search results from the Supermemory API.
*
* @param containerTag - The container tag/user ID for scoping memories
* @param queryText - Optional query text for semantic search
* @param baseUrl - The API base URL
* @param apiKey - The API key for authentication
* @param signal - Optional AbortSignal to cancel the request (e.g. retrieval timeout)
* @returns The profile structure with static, dynamic, and search results
*/
export const supermemoryProfileSearch = async (
containerTag: string,
queryText: string,
baseUrl: string,
apiKey: string,
signal?: AbortSignal,
): Promise<ProfileStructure> => {
const payload = queryText
? JSON.stringify({
q: queryText,
containerTag: containerTag,
})
: JSON.stringify({
containerTag: containerTag,
})
try {
const response = await fetch(`${baseUrl}/v4/profile`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: payload,
...(signal ? { signal } : {}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Supermemory profile search failed: ${response.status} ${response.statusText}. ${errorText}`,
)
}
return await response.json()
} catch (error) {
if (error instanceof Error) {
throw error
}
throw new Error(`Supermemory API request failed: ${error}`)
}
}
/**
* Options for building memories text.
*/
export interface BuildMemoriesTextOptions {
containerTag: string
queryText: string
mode: MemoryMode
baseUrl: string
apiKey: string
logger: Logger
promptTemplate?: PromptTemplate
signal?: AbortSignal
}
/**
* Fetches memories from the API, deduplicates them, and formats them into
* the final string to be injected into the system prompt.
*
* @param options - Configuration for building memories text
* @returns The final formatted memories string ready for injection
*/
export const buildMemoriesText = async (
options: BuildMemoriesTextOptions,
): Promise<string> => {
const {
containerTag,
queryText,
mode,
baseUrl,
apiKey,
logger,
promptTemplate = defaultPromptTemplate,
signal,
} = options
const memoriesResponse = await supermemoryProfileSearch(
containerTag,
queryText,
baseUrl,
apiKey,
signal,
)
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
const memoryCountDynamic = memoriesResponse.profile.dynamic?.length || 0
logger.info("Memory search completed", {
containerTag,
memoryCountStatic,
memoryCountDynamic,
queryText:
queryText.substring(0, 100) + (queryText.length > 100 ? "..." : ""),
mode,
})
const deduplicated = deduplicateMemoriesForMode(mode, {
static: memoriesResponse.profile.static,
dynamic: memoriesResponse.profile.dynamic,
searchResults: memoriesResponse.searchResults?.results,
})
logger.debug("Memory deduplication completed", {
static: {
original: memoryCountStatic,
deduplicated: deduplicated.static.length,
},
dynamic: {
original: memoryCountDynamic,
deduplicated: deduplicated.dynamic.length,
},
searchResults: {
original: memoriesResponse.searchResults?.results?.length,
deduplicated: deduplicated.searchResults?.length,
},
})
const userMemories =
mode !== "query"
? convertProfileToMarkdown({
profile: {
static: deduplicated.static,
dynamic: deduplicated.dynamic,
},
searchResults: { results: [] },
})
: ""
const generalSearchMemories =
mode !== "profile"
? `Search results for user's recent message: \n${deduplicated.searchResults
.map((memory) => `- ${memory}`)
.join("\n")}`
: ""
const promptData: MemoryPromptData = {
userMemories,
generalSearchMemories,
searchResults: memoriesResponse.searchResults?.results ?? [],
}
const memories = promptTemplate(promptData)
if (memories) {
logger.debug("Memory content preview", {
content: memories,
fullLength: memories.length,
})
}
return memories
}
/**
* Generic interface for a message with role and content.
* Framework-agnostic to support both Vercel AI SDK and Mastra.
*/
export interface GenericMessage {
role: string
content: string | Array<{ type: string; text?: string }>
}
/**
* Extracts the query text from messages based on mode.
* For "profile" mode, returns empty string (no query needed).
* For "query" or "full" mode, extracts the last user message text.
*
* This is a framework-agnostic version that works with any message array.
*
* @param messages - Array of messages with role and content
* @param mode - The memory retrieval mode
* @returns The query text for memory search
*/
export const extractQueryText = (
messages: GenericMessage[],
mode: MemoryMode,
): string => {
if (mode === "profile") {
return ""
}
const userMessage = messages
.slice()
.reverse()
.find((msg) => msg.role === "user")
const content = userMessage?.content
if (!content) return ""
if (typeof content === "string") {
return content
}
if (Array.isArray(content)) {
return content
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join(" ")
}
const objContent = content as unknown as {
content?: string
parts?: Array<{ type: string; text?: string }>
}
if (typeof objContent === "object" && objContent !== null) {
if ("content" in objContent && typeof objContent.content === "string") {
return objContent.content
}
if ("parts" in objContent && Array.isArray(objContent.parts)) {
return objContent.parts
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join(" ")
}
}
return ""
}
/**
* Extracts text content from the last user message in a message array.
*
* @param messages - Array of messages with role and content
* @returns The last user message text, or undefined if not found
*/
export const getLastUserMessageText = (
messages: GenericMessage[],
): string | undefined => {
const lastUserMessage = messages
.slice()
.reverse()
.find((msg) => msg.role === "user")
if (!lastUserMessage) {
return undefined
}
const content = lastUserMessage.content
if (typeof content === "string") {
return content
}
if (Array.isArray(content)) {
return content
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join(" ")
}
const objContent = content as unknown as {
content?: string
parts?: Array<{ type: string; text?: string }>
}
if (typeof objContent === "object" && objContent !== null) {
if ("content" in objContent && typeof objContent.content === "string") {
return objContent.content
}
if ("parts" in objContent && Array.isArray(objContent.parts)) {
return objContent.parts
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join(" ")
}
}
return undefined
}