mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(mcp): playground-style rich recall (formatMemories + profile) (#1101)
## What
Makes the MCP `recall` tool render memories the same way the console **playground** does — rich, scored, relation-aware output — instead of the old flat `### Memory N (NN% match)` list.
The playground pattern is: **profile + rich search → one shared `formatMemories` formatter → feed to the model.** This ports that into the standalone MCP server.
## Changes
- **`format.ts`** (new) — `formatMemories`, lifted verbatim from the playground. Pure, dependency-free. Renders similarity scores, `agg`/`chunk` markers, related-memory arrows (`←` parent, `→` child, `~` related), attached document summaries, and temporal context.
- **`client.ts`** — `SupermemoryClient.search()` now accepts the playground's retrieval knobs (`searchMode` / `rerank` / `rewriteQuery` / `include`) and **stops discarding** the rich response fields (`context`, `documents`, `metadata`, `isAggregated`) during normalization, so the formatter has data to render.
- **`server.ts` (`handleRecall`)** — retrieves memories via `client.search()` with `include: { documents, relatedMemories }`, prepends the user's profile (stable + recent facts) when `includeProfile` is set, and renders memories through `formatMemories`. Output capped at `MAX_RECALL_CHARS`.
## How it maps to the playground
| | Playground | MCP (this PR) |
|---|---|---|
| Profile | injected into its own system prompt (`<user_profile>`) | prepended to `recall` output / exposed via `context` prompt |
| Search | hook pre-fetch **or** tool | `recall` tool (model-triggered) |
| Formatting | `formatMemories` → system prompt | `formatMemories` → `recall` tool result |
## Testing
Verified end-to-end against a local API + local MCP, driven through a real MCP client (and Claude Desktop). `recall` now returns scored memory blocks with `Source:`/`Document:` lines and the legend header. Existing e2e assertions remain compatible (they match `## User Profile` / `## Relevant Memories`, both still emitted).
## Notes
- One deliberate gap vs the playground: `aggregate` — the public SDK's `search.memories` doesn't expose it, so no `agg` synthesis blocks. Relation arrows render only when memories actually have linked parent/child versions.
- Possible follow-up: expose the `include` / `rerank` knobs as `recall` tool params so callers can toggle relations/documents per call.
- Pre-existing `tsc` errors in `server.ts` (dual `@modelcontextprotocol/sdk` install) and `mcp-app.ts` (DOM `lib`) are unrelated to this change — confirmed by an unchanged baseline.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/c616fc88-a725-4961-9011-712bce740601)
- Requested by: Sreeram Sreedhar (sreeram@supermemory.com)
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
parent
81ae72224f
commit
0ae552037a
3 changed files with 242 additions and 77 deletions
|
|
@ -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<string, unknown> | null
|
||||
updatedAt?: string
|
||||
context?: Record<string, unknown>
|
||||
documents?: Array<Record<string, unknown>>
|
||||
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<string, unknown> | null
|
||||
updatedAt?: string
|
||||
context?: Record<string, unknown>
|
||||
documents?: Array<Record<string, unknown>>
|
||||
isAggregated?: boolean
|
||||
}
|
||||
|
||||
export class SupermemoryClient {
|
||||
|
|
@ -218,26 +243,32 @@ export class SupermemoryClient {
|
|||
query: string,
|
||||
limit = 10,
|
||||
threshold?: number,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult> {
|
||||
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,
|
||||
|
|
|
|||
157
apps/mcp/src/format.ts
Normal file
157
apps/mcp/src/format.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
export function formatMemories(
|
||||
response: { results?: Array<Record<string, unknown>>; 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<string, unknown> | 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<string, unknown> | undefined | null) => {
|
||||
if (!m) return ""
|
||||
const tags = [
|
||||
mime(m.mimeType as string | undefined),
|
||||
m.source as string | undefined,
|
||||
...temporal(m.temporalContext as Record<string, unknown> | undefined),
|
||||
].filter(Boolean)
|
||||
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
const renderRelations = (
|
||||
rels: Array<Record<string, unknown>> | undefined,
|
||||
arrow: string,
|
||||
root: string,
|
||||
) => {
|
||||
if (!rels?.length) return [] as string[]
|
||||
const seen = new Set<string>()
|
||||
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<string, unknown> | undefined)?.temporalContext as
|
||||
| Record<string, unknown>
|
||||
| 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<Record<string, unknown>> | 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<string, unknown> | null)}`.trimEnd(),
|
||||
...renderDocs(
|
||||
m.documents as Array<Record<string, unknown>> | undefined,
|
||||
),
|
||||
...text.split("\n").map((l: string) => ` ${l}`),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
|
||||
const ctx = (m.context ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>
|
||||
return [
|
||||
`${prefix}${memory}`,
|
||||
meta
|
||||
? ` Source: ${meta}`
|
||||
: day(m.updatedAt as string)
|
||||
? ` Source: updated ${day(m.updatedAt as string)}`
|
||||
: null,
|
||||
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
|
||||
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
})
|
||||
|
||||
return [header, "", blocks.join("\n\n")].join("\n")
|
||||
}
|
||||
|
|
@ -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<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
private cachedContainerTags: string[] = []
|
||||
|
|
@ -628,10 +631,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
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<Env, unknown, Props> {
|
|||
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<string, unknown>
|
||||
>,
|
||||
total: searchResult.total,
|
||||
},
|
||||
{ includeScores: true, includeLegend: true },
|
||||
),
|
||||
)
|
||||
|
||||
const endTime = Date.now()
|
||||
|
||||
// Track search event
|
||||
|
|
@ -715,22 +698,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
})
|
||||
.catch((error) => console.error("PostHog tracking error:", error))
|
||||
|
||||
if (searchResult.results.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No memories found." }],
|
||||
}
|
||||
const text = parts.join("\n")
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
text.length > MAX_RECALL_CHARS
|
||||
? `${text.slice(0, MAX_RECALL_CHARS)}...`
|
||||
: text,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
return { content: [{ type: "text" as const, text: parts.join("\n") }] }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue