diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 39256cf7..cd520905 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -207,39 +207,24 @@ export class SupermemoryClient { } } - // v5 has no exact-content forget: preview semantic matches, then forget the best one by ID. - async forgetMemory( - content: string, - ): Promise<{ success: boolean; message: string; namespace: string }> { + // Retrying a committed mutation can turn its success into a misleading not-found result. + async forgetByIds(ids: string[]): Promise<{ + forgotten: { id: string; memory: string }[] + errors: { id: string; error: string }[] + namespace: string + }> { try { - const preview = await this.client.memories.forgetMatching( + const result = await this.client.memories.forget( this.namespace, - { query: content, dryRun: true }, + { ids }, + { maxRetries: 0 }, ) - const match = preview.matches[0] - if (!match) { - return { - success: false, - message: "No matching memory found to forget.", - namespace: this.namespace, - } - } - - const result = await this.client.memories.forget(this.namespace, { - ids: [match.id], - }) - if (result.count === 0) { - const reason = result.errors[0]?.error ?? "unknown error" - return { - success: false, - message: `Could not forget memory ${match.id}: ${reason}`, - namespace: this.namespace, - } - } - return { - success: true, - message: `Forgot memory (ID: ${match.id}): "${limitByChars(match.memory, 100)}"`, + forgotten: result.matches.map((m) => ({ + id: m.id, + memory: limitByChars(m.memory, 200), + })), + errors: result.errors, namespace: this.namespace, } } catch (error) { @@ -252,7 +237,7 @@ export class SupermemoryClient { const result = await this.client.search(this.namespace, { query, limit, - searchMode: "hybrid", + searchMode: "memories", threshold: SEARCH_THRESHOLD, }) const results = mapSearchResults(result.results) diff --git a/apps/mcp/src/server/tools/forget-memory.ts b/apps/mcp/src/server/tools/forget-memory.ts index 1369fbde..3441d874 100644 --- a/apps/mcp/src/server/tools/forget-memory.ts +++ b/apps/mcp/src/server/tools/forget-memory.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { getMemoryText } from "../client" import { optionalNamespaceSchema } from "../namespace" import { MEMORY_TOOL_ANNOTATIONS } from "./annotations" import { @@ -9,22 +10,110 @@ import { import { forgetMemoryOutputSchema } from "./output-schemas" import { textContent, type ToolDeps } from "./types" -export async function forgetMemory( - deps: ToolDeps, - args: { content: string; namespace?: string; containerTag?: string }, +const MAX_IDS = 100 +const CANDIDATE_LIMIT = 5 + +type ForgetArgs = { + ids?: string[] + content?: string + namespace?: string + containerTag?: string +} + +function forgetResult( + namespace: string, + success: boolean, + message: string, + extra: Record = {}, ) { + return { + content: [textContent(message)], + structuredContent: withLegacyNamespace({ + action: "forget" as const, + success, + namespace, + message, + ...extra, + }), + } +} + +async function forgetIds(deps: ToolDeps, namespace: string, ids: string[]) { + const result = await deps.getClient(namespace).forgetByIds(ids) + const lines = result.forgotten.map((m) => `- ${m.id}: "${m.memory}"`) + for (const e of result.errors) + lines.push(`- ${e.id}: not forgotten (${e.error})`) + const count = result.forgotten.length + const head = + count > 0 + ? `Forgot ${count} memor${count === 1 ? "y" : "ies"}.` + : "No memories were forgotten." + return forgetResult( + result.namespace, + count > 0, + [head, ...lines].join("\n"), + { + id: result.forgotten[0]?.id, + forgotten: result.forgotten, + errors: result.errors, + }, + ) +} + +export async function forgetMemory(deps: ToolDeps, args: ForgetArgs) { try { const namespace = await deps.resolveNamespace(namespaceArg(args)) - const result = await deps.getClient(namespace).forgetMemory(args.content) - return { - content: [textContent(result.message)], - structuredContent: withLegacyNamespace({ - action: "forget" as const, - success: result.success, - namespace: result.namespace, - message: result.message, - }), + if (args.ids !== undefined) { + const ids = [...new Set(args.ids.map((id) => id.trim()))] + if (ids.length === 0 || ids.length > MAX_IDS || ids.some((id) => !id)) { + throw new Error(`Pass between 1 and ${MAX_IDS} nonempty memory IDs.`) + } + return await forgetIds(deps, namespace, ids) } + + const content = args.content + if (!content?.trim()) { + return forgetResult( + namespace, + false, + "Pass ids (from search_memory) to forget memories.", + ) + } + + const search = await deps + .getClient(namespace) + .search(content, CANDIDATE_LIMIT) + const candidates = search.results + .filter((r) => "memory" in r) + .map((r) => ({ + id: r.id, + text: getMemoryText(r), + similarity: r.similarity, + })) + const exact = candidates.find((c) => c.text === content) + if (exact) return await forgetIds(deps, namespace, [exact.id]) + + if (candidates.length === 0) { + return forgetResult( + namespace, + false, + "Nothing was forgotten. No matching memory found.", + { candidates }, + ) + } + const lines = candidates.map( + (c) => `- ${c.id} [${(c.similarity * 100).toFixed(0)}%] ${c.text}`, + ) + return forgetResult( + namespace, + false, + [ + "Nothing was forgotten yet. Closest memories:", + ...lines, + "Call forget_memory again with the ids to forget.", + ].join("\n"), + { candidates }, + ) } catch (error) { return deps.errorResult(error) } @@ -35,12 +124,21 @@ export function register(deps: ToolDeps) { "forget_memory", { description: - "Forget a memory matching the supplied content in the user's ACTIVE space. Use when information is outdated or the user asks to remove it. The target space is the one selected via select-space; pass namespace only to override it.", + "Forget memories by ID in the user's ACTIVE space. Find the IDs with search_memory first, then pass them in ids. If you pass only content, an exact text match is forgotten; otherwise nothing is forgotten and the closest memories are returned with their IDs so you can choose. Use when information is outdated or the user asks to remove it. Pass namespace only to override the active space.", inputSchema: z.object({ + ids: z + .array(z.string().trim().min(1)) + .min(1) + .max(MAX_IDS) + .optional() + .describe("IDs of the memories to forget, from search_memory"), content: z .string() .max(200000, "Content exceeds maximum length") - .describe("The memory content to forget"), + .optional() + .describe( + "Text of the memory to forget. Used only when ids is not given.", + ), namespace: optionalNamespaceSchema, ...legacyNamespaceInput, }), diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 0fe1e96e..bc7cbc73 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -25,6 +25,15 @@ export const addMemoryOutputSchema = z.object({ message: z.string(), id: z.string().optional(), status: z.string().optional(), + forgotten: z + .array(z.object({ id: z.string(), memory: z.string() })) + .optional(), + errors: z.array(z.object({ id: z.string(), error: z.string() })).optional(), + candidates: z + .array( + z.object({ id: z.string(), text: z.string(), similarity: z.number() }), + ) + .optional(), }) export type AddMemoryOutput = z.infer diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index e4a257a5..1a88a3f2 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -50,7 +50,7 @@ export function register(deps: ToolDeps) { for (const result of searchResult.results) { const text = getMemoryText(result) const similarity = (result.similarity * 100).toFixed(0) - parts.push(`- [${similarity}%] ${text}`) + parts.push(`- ${result.id} [${similarity}%] ${text}`) } } else { parts.push("No matching memories found.")