supermemory/apps/mcp/src/client.ts

416 lines
9.7 KiB
TypeScript

import Supermemory from "supermemory"
const MAX_CHARS = 200000 // ~50k tokens (character-based limit)
const DEFAULT_PROJECT_ID = "sm_project_default"
export type Memory =
| {
id: string
memory: string
similarity: number
title?: string
content?: string
}
| {
id: string
chunk: string
similarity: number
title?: string
content?: string
}
export interface SearchResult {
results: Memory[]
total: number
timing: number
}
export interface Profile {
static: string[]
dynamic: string[]
}
export interface ProfileResponse {
profile: Profile
searchResults?: SearchResult
}
export interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental: boolean
documentCount?: number
}
// Documents API types
export interface DocumentMemoryEntry {
id: string
memory: string
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
}
export interface DocumentWithMemories {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: DocumentMemoryEntry[]
}
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export function getMemoryText(m: Memory): string {
return "memory" in m ? m.memory : m.chunk
}
function limitByChars(text: string, maxChars = MAX_CHARS): string {
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
// Type for SDK search result item
interface SDKResult {
id: string
memory?: string
chunk?: string
content?: string
similarity: number
title?: string
context?: string
}
export class SupermemoryClient {
private client: Supermemory
private containerTag: string
private bearerToken: string
private apiUrl: string
constructor(
bearerToken: string,
containerTag?: string,
apiUrl = "https://api.supermemory.ai",
) {
this.bearerToken = bearerToken
this.apiUrl = apiUrl
this.client = new Supermemory({
apiKey: bearerToken,
baseURL: apiUrl,
})
this.containerTag = containerTag || DEFAULT_PROJECT_ID
}
// Create memory using SDK
async createMemory(
content: string,
): Promise<{ id: string; status: string; containerTag: string }> {
try {
const result = await this.client.add({
content,
containerTag: this.containerTag,
metadata: {
sm_source: "mcp",
},
})
return {
id: result.id,
status: "queued",
containerTag: this.containerTag,
}
} catch (error) {
this.handleError(error)
}
}
// Delete/forget memory - try exact match first, then semantic search
async forgetMemory(
content: string,
): Promise<{ success: boolean; message: string; containerTag: string }> {
try {
// Try exact content matching first
try {
const result = await this.client.memories.forget({
content: content,
containerTag: this.containerTag,
})
return {
success: true,
message: `Successfully forgot memory (exact match) with ID: ${result.id}`,
containerTag: this.containerTag,
}
} catch (error: unknown) {
// If not 404, it's a real error - re-throw it
const status =
error && typeof error === "object" && "status" in error
? (error as Record<string, unknown>).status
: undefined
if (status !== 404) {
throw error
}
// Otherwise continue to semantic search fallback
}
// Fallback to semantic search if exact match fails
const SIMILARITY_THRESHOLD = 0.85 // High threshold - only very similar memories
const searchResult = await this.search(content, 5, SIMILARITY_THRESHOLD)
if (searchResult.results.length === 0) {
return {
success: false,
message: `No matching memory found to forget. Tried exact match and semantic search with similarity threshold ${SIMILARITY_THRESHOLD}.`,
containerTag: this.containerTag,
}
}
// Only actual memories (not chunks) can be forgotten
const memoryToDelete = searchResult.results.find((r) => "memory" in r)
if (!memoryToDelete) {
return {
success: false,
message:
"No matching memory found to forget (only document chunks matched in semantic search).",
containerTag: this.containerTag,
}
}
// Delete using the ID from semantic search
await this.client.memories.forget({
id: memoryToDelete.id,
containerTag: this.containerTag,
})
const memoryText =
getMemoryText(memoryToDelete) || memoryToDelete.content || ""
return {
success: true,
message: `Forgot similar memory (semantic match, similarity: ${memoryToDelete.similarity.toFixed(2)}): "${limitByChars(memoryText, 100)}"`,
containerTag: this.containerTag,
}
} catch (error) {
this.handleError(error)
}
}
// Search memories using SDK
async search(
query: string,
limit = 10,
threshold?: number,
): Promise<SearchResult> {
try {
const result = await this.client.search.memories({
q: query,
limit,
containerTag: this.containerTag,
searchMode: "hybrid",
threshold, // Optional threshold parameter
})
// Normalize and limit response size — preserve memory vs chunk distinction
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
return { ...base, memory: text }
})
return {
results,
total: result.total,
timing: result.timing,
}
} catch (error) {
this.handleError(error)
}
}
// Get user profile using SDK
async getProfile(query?: string): Promise<ProfileResponse> {
try {
const result = await this.client.profile({
containerTag: this.containerTag,
q: query,
})
const response: ProfileResponse = {
profile: {
static: result.profile?.static || [],
dynamic: result.profile?.dynamic || [],
},
}
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
}
return { ...base, memory: text }
}),
total: result.searchResults.total,
timing: result.searchResults.timing,
}
}
return response
} catch (error) {
this.handleError(error)
}
}
// Get projects list
async getProjects(): Promise<string[]> {
try {
const response = await fetch(`${this.apiUrl}/v3/projects`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
})
if (!response.ok) {
if (response.status === 401) {
throw new Error("Authentication failed. Please re-authenticate.")
}
throw new Error(`Failed to fetch projects: ${response.statusText}`)
}
const data = (await response.json()) as {
projects: Project[]
}
return data.projects?.map((p) => p.containerTag) || []
} catch (error) {
this.handleError(error)
}
}
// Fetch documents with their memory entries
async getDocuments(
containerTags?: string[],
page = 1,
limit = 10,
): Promise<DocumentsApiResponse> {
try {
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
page,
limit,
sort: "createdAt",
order: "desc",
containerTags,
}),
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch documents"), {
status: response.status,
})
}
return (await response.json()) as DocumentsApiResponse
} catch (error) {
this.handleError(error)
}
}
private handleError(error: unknown): never {
// Handle network/fetch errors
if (error instanceof TypeError) {
if (
error.message.includes("fetch") ||
error.message.includes("network")
) {
throw new Error(
"Network error. Please check your connection and try again.",
)
}
}
// Handle HTTP status errors from SDK/fetch
if (error && typeof error === "object" && "status" in error) {
const status = (error as { status: number }).status
const message =
"message" in error ? (error as { message: string }).message : undefined
switch (status) {
case 400:
case 422:
throw new Error(
message || "Invalid request parameters. Please check your input.",
)
case 401:
throw new Error("Authentication failed. Please re-authenticate.")
case 402:
throw new Error("Memory limit reached. Upgrade at supermemory.ai")
case 403:
throw new Error(
"Access forbidden. Your account may be restricted or blocked.",
)
case 404:
throw new Error("Memory not found. It may have been deleted.")
case 429:
throw new Error(
"Rate limit exceeded. Please wait a moment and try again.",
)
default:
if (status >= 500) {
throw new Error(
"Server error. The service may be temporarily unavailable. Please try again later.",
)
}
}
}
// Re-throw Error instances as-is
if (error instanceof Error) {
throw error
}
// Wrap unknown errors
throw new Error(`An unexpected error occurred: ${String(error)}`)
}
}