diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index 8fdb6748d..ee35fcf1a 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -3,21 +3,29 @@ import Supermemory from "supermemory" const MAX_CHARS = 200000 // ~50k tokens (character-based limit) const DEFAULT_PROJECT_ID = "sm_project_default" +interface MemoryRichFields { + metadata?: Record | null + updatedAt?: string + context?: Record + documents?: Array> + isAggregated?: boolean +} + export type Memory = - | { + | ({ id: string memory: string similarity: number title?: string content?: string - } - | { + } & MemoryRichFields) + | ({ id: string chunk: string similarity: number title?: string content?: string - } + } & MemoryRichFields) export interface SearchResult { results: Memory[] @@ -25,6 +33,19 @@ export interface SearchResult { timing: number } +export interface SearchOptions { + searchMode?: "memories" | "hybrid" | "documents" + rerank?: boolean + rewriteQuery?: boolean + include?: { + documents?: boolean + relatedMemories?: boolean + summaries?: boolean + chunks?: boolean + forgottenMemories?: boolean + } +} + export interface Profile { static: string[] dynamic: string[] @@ -98,7 +119,11 @@ interface SDKResult { content?: string similarity: number title?: string - context?: string + metadata?: Record | null + updatedAt?: string + context?: Record + documents?: Array> + isAggregated?: boolean } export class SupermemoryClient { @@ -218,26 +243,32 @@ export class SupermemoryClient { query: string, limit = 10, threshold?: number, + options?: SearchOptions, ): Promise { try { const result = await this.client.search.memories({ q: query, limit, containerTag: this.containerTag, - searchMode: "hybrid", + searchMode: options?.searchMode ?? "hybrid", threshold, // Optional threshold parameter + rerank: options?.rerank, + rewriteQuery: options?.rewriteQuery, + include: options?.include, }) - // 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 text = limitByChars(r.content || r.memory || r.chunk || "") const base = { id: r.id, similarity: r.similarity, title: r.title, content: r.content, + metadata: r.metadata, + updatedAt: r.updatedAt, + context: r.context, + documents: r.documents, + isAggregated: r.isAggregated, } if (r.chunk && !r.memory) { return { ...base, chunk: text } @@ -273,9 +304,7 @@ export class SupermemoryClient { 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 text = limitByChars(r.content || r.memory || r.chunk || "") const base = { id: r.id, similarity: r.similarity, diff --git a/apps/mcp/src/format.ts b/apps/mcp/src/format.ts new file mode 100644 index 000000000..cbd074cf9 --- /dev/null +++ b/apps/mcp/src/format.ts @@ -0,0 +1,157 @@ +export function formatMemories( + response: { results?: Array>; total?: number }, + opts: { + minSimilarity?: number + maxRelations?: number + maxDocuments?: number + maxChunkLength?: number + includeScores?: boolean + includeLegend?: boolean + } = {}, +) { + const { + minSimilarity = 0, + maxRelations = 4, + maxDocuments = 3, + maxChunkLength = Number.POSITIVE_INFINITY, + includeScores = true, + includeLegend = true, + } = opts + + const day = (s: string | null | undefined) => s?.slice(0, 10) ?? "" + const mime = (m: string | undefined) => + !m + ? "" + : m === "application/pdf" + ? "pdf" + : m.includes("spreadsheet") + ? "xlsx" + : m.includes("presentation") + ? "pptx" + : m.includes("document") + ? "doc" + : (m.split("/").pop() ?? "") + + const temporal = (tc: Record | undefined) => { + if (!tc) return [] as string[] + const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean) + return [ + tc.documentDate && `doc ${day(tc.documentDate as string)}`, + ev.length === 1 && `event ${ev[0]}`, + ev.length > 1 && `event ${ev[0]} → ${ev.at(-1)}`, + ].filter(Boolean) as string[] + } + + const describeMeta = (m: Record | undefined | null) => { + if (!m) return "" + const tags = [ + mime(m.mimeType as string | undefined), + m.source as string | undefined, + ...temporal(m.temporalContext as Record | undefined), + ].filter(Boolean) + return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`] + .filter(Boolean) + .join(" ") + } + + const renderRelations = ( + rels: Array> | undefined, + arrow: string, + root: string, + ) => { + if (!rels?.length) return [] as string[] + const seen = new Set() + const items = rels.filter((r) => { + const k = (r.memory as string).trim() + if (k === root.trim() || seen.has(k)) return false + seen.add(k) + return true + }) + const shown = items.slice(0, maxRelations) + const lines = shown.map((r) => { + const t = temporal( + (r.metadata as Record | undefined)?.temporalContext as + | Record + | undefined, + ) + const when = t.length ? t.join(", ") : day(r.updatedAt as string) + return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}` + }) + if (items.length > shown.length) + lines.push(` ${arrow} … +${items.length - shown.length} more`) + return lines + } + + const renderDocs = (ds: Array> | undefined) => + (ds ?? []).slice(0, maxDocuments).map((d) => { + const title = d.title ? `"${d.title}"` : "(untitled)" + const type = d.type ? ` (${d.type})` : "" + const summary = d.summary ? ` — ${d.summary}` : "" + return ` Document: ${title}${type}${summary}` + }) + + const results = (response.results ?? []).filter( + (m) => ((m.similarity as number) ?? 0) >= minSimilarity, + ) + if (!results.length) return "No relevant memories found." + + const total = response.total ?? results.length + const header = [ + `${results.length} memor${results.length === 1 ? "y" : "ies"}` + + (total !== results.length ? ` of ${total}` : "") + + ", ranked by relevance.", + includeLegend && + "Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.", + ] + .filter(Boolean) + .join(" ") + + const arrows = [ + ["parents", "←"], + ["children", "→"], + ["related", "~"], + ] as const + + const blocks = results.map((m) => { + const score = (m.similarity as number)?.toFixed(2) ?? "—" + const prefix = includeScores ? `${score} ` : "" + const memory = (m.memory as string) ?? "" + + if (m.isAggregated) return `${prefix}agg ${memory}` + + if (m.chunk != null && m.memory == null) { + const body = (m.chunk as string).replace(/\s+$/, "") + const text = + body.length > maxChunkLength + ? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]` + : body + return [ + `${prefix}chunk ${describeMeta(m.metadata as Record | null)}`.trimEnd(), + ...renderDocs( + m.documents as Array> | undefined, + ), + ...text.split("\n").map((l: string) => ` ${l}`), + ].join("\n") + } + + const meta = describeMeta(m.metadata as Record | null) + const ctx = (m.context ?? {}) as Record< + string, + Array> + > + return [ + `${prefix}${memory}`, + meta + ? ` Source: ${meta}` + : day(m.updatedAt as string) + ? ` Source: updated ${day(m.updatedAt as string)}` + : null, + ...renderDocs(m.documents as Array> | undefined), + ...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)), + ] + .filter(Boolean) + .join("\n") + }) + + return [header, "", blocks.join("\n\n")].join("\n") +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 510c1481a..de54deff2 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -5,7 +5,8 @@ import { registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server" -import { SupermemoryClient, getMemoryText } from "./client" +import { SupermemoryClient } from "./client" +import { formatMemories } from "./format" import { initPosthog, posthog } from "./posthog" import { z } from "zod" import mcpAppHtml from "../dist/mcp-app.html" @@ -26,6 +27,8 @@ type Props = { const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000 +const MAX_RECALL_CHARS = 200000 + export class SupermemoryMCP extends McpAgent { private clientInfo: { name: string; version?: string } | null = null private cachedContainerTags: string[] = [] @@ -628,10 +631,21 @@ export class SupermemoryMCP extends McpAgent { const clientInfo = await this.getClientInfo() const startTime = Date.now() - if (includeProfile) { - const profileResult = await client.getProfile(query) - const parts: string[] = [] + const searchResult = await client.search(query, 10, undefined, { + searchMode: "hybrid", + include: { + documents: true, + relatedMemories: true, + summaries: false, + chunks: false, + forgottenMemories: false, + }, + }) + const parts: string[] = [] + + if (includeProfile) { + const profileResult = await client.getProfile() if ( profileResult.profile.static.length > 0 || profileResult.profile.dynamic.length > 0 @@ -649,54 +663,23 @@ export class SupermemoryMCP extends McpAgent { parts.push(`- ${fact}`) } } - } - - if (profileResult.searchResults?.results.length) { - parts.push("\n## Relevant Memories") - for (const [ - i, - memory, - ] of profileResult.searchResults.results.entries()) { - parts.push( - `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`, - ) - if (memory.title) parts.push(`**${memory.title}**`) - parts.push(getMemoryText(memory)) - } - } - - const endTime = Date.now() - - // Track search event - posthog - .memorySearch({ - query_length: query.length, - results_count: profileResult.searchResults?.results.length || 0, - search_duration_ms: endTime - startTime, - container_tags_count: 1, - source: "mcp", - userId: this.props?.userId || "unknown", - mcp_client_name: clientInfo?.name, - mcp_client_version: clientInfo?.version, - sessionId: this.getMcpSessionId(), - containerTag: containerTag || this.props?.containerTag, - }) - .catch((error) => console.error("PostHog tracking error:", error)) - - return { - content: [ - { - type: "text" as const, - text: - parts.length > 0 - ? parts.join("\n") - : "No memories or profile found.", - }, - ], + parts.push("") } } - const searchResult = await client.search(query, 10) + parts.push("## Relevant Memories") + parts.push( + formatMemories( + { + results: searchResult.results as unknown as Array< + Record + >, + total: searchResult.total, + }, + { includeScores: true, includeLegend: true }, + ), + ) + const endTime = Date.now() // Track search event @@ -715,22 +698,18 @@ export class SupermemoryMCP extends McpAgent { }) .catch((error) => console.error("PostHog tracking error:", error)) - if (searchResult.results.length === 0) { - return { - content: [{ type: "text" as const, text: "No memories found." }], - } - } - - const parts = ["## Relevant Memories"] - for (const [i, memory] of searchResult.results.entries()) { - parts.push( - `\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`, - ) - if (memory.title) parts.push(`**${memory.title}**`) - parts.push(getMemoryText(memory)) + const text = parts.join("\n") + return { + content: [ + { + type: "text" as const, + text: + text.length > MAX_RECALL_CHARS + ? `${text.slice(0, MAX_RECALL_CHARS)}...` + : text, + }, + ], } - - return { content: [{ type: "text" as const, text: parts.join("\n") }] } } catch (error) { const message = error instanceof Error ? error.message : "An unexpected error occurred" diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 42bfce24b..64ee0299e 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -275,6 +275,7 @@ export default function BrainOnboardingPage() { void }) { const [spaceName, setSpaceName] = useState("") + const [spaceContext, setSpaceContext] = useState("") + const [showContext, setShowContext] = useState(false) const [emoji, setEmoji] = useState("📁") const [isEmojiOpen, setIsEmojiOpen] = useState(false) const { createProjectMutation } = useProjectMutations() @@ -79,6 +101,8 @@ export function AddSpaceModal({ const handleClose = () => { onClose() setSpaceName("") + setSpaceContext("") + setShowContext(false) setEmoji("📁") } @@ -89,11 +113,18 @@ export function AddSpaceModal({ createProjectMutation.mutate( { name: trimmedName, emoji: emoji || undefined }, { - onSuccess: (data) => { + onSuccess: async (data) => { analytics.spaceCreated() - if (data?.containerTag) { - onCreated?.(data.containerTag) + const tag = data?.containerTag + const context = showContext ? spaceContext.trim() : "" + if (tag && context) { + try { + await $fetch(`@patch/container-tags/${tag}`, { + body: { entityContext: context }, + }) + } catch {} } + if (tag) onCreated?.(tag) handleClose() }, }, @@ -132,21 +163,21 @@ export function AddSpaceModal({
-

- Create new space -

+ New space +

- Create spaces to organize your memories and documents and create - a context rich environment + Group related memories and give Nova context for this space.

+ {!showContext ? ( + + ) : ( +
+
+ + What to remember + + + Optional + +
+