mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-27 06:15:15 +00:00
fix(mcp): forget memories by ID instead of LLM matching (#1820)
forget_memory called the semantic forget-matching endpoint (two LLM calls plus a widening loop) to pick one memory, so most calls hit the 30s timeout since the v5 move. It now takes ids from search_memory and forgets them with the exact-ID endpoint (~0.5s on prod). - Text-only calls forget only an exact text match; otherwise nothing is deleted and the closest memories come back with their IDs. - Legacy add_memory action=forget keeps working through the same path.
This commit is contained in:
parent
23983bffae
commit
ccf0485869
4 changed files with 137 additions and 45 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {},
|
||||
) {
|
||||
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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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<typeof addMemoryOutputSchema>
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue