diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts new file mode 100644 index 000000000..da54ecb24 --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -0,0 +1,694 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { DEFAULT_PROJECT_ID, getMemoryText, SupermemoryClient } from "./index" + +const { sdk, sdkOptions } = vi.hoisted(() => ({ + sdk: { + add: vi.fn(), + profile: vi.fn(), + memories: { forget: vi.fn() }, + search: { memories: vi.fn() }, + documents: { list: vi.fn(), get: vi.fn() }, + }, + sdkOptions: vi.fn(), +})) + +vi.mock("supermemory", () => ({ + default: class { + constructor(options: unknown) { + sdkOptions(options) + Object.assign(this, sdk) + } + }, +})) + +const API_URL = "https://api.example.com" +const TOKEN = "sm_test_token" +const FORBIDDEN_FALLBACK = "read-only or scoped to specific spaces" + +const pagination = { + currentPage: 1, + limit: 50, + totalItems: 1, + totalPages: 1, +} + +const documentsPayload = { + documents: [ + { + id: "doc_1", + title: "Notes", + summary: null, + type: "text", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + memoryEntries: [], + }, + ], + pagination, +} + +function client(containerTag?: string) { + return new SupermemoryClient(TOKEN, containerTag, API_URL) +} + +function apiError(message: string, status: number) { + return Object.assign(new Error(message), { status }) +} + +describe("SupermemoryClient", () => { + let fetchMock = vi.fn() + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + describe("configuration", () => { + it("identifies itself to the API with a source header and a timeout", () => { + client("work") + + expect(sdkOptions).toHaveBeenCalledWith({ + apiKey: TOKEN, + baseURL: API_URL, + timeout: 30_000, + defaultHeaders: { "x-sm-source": "supermemory-mcp" }, + }) + }) + + it("writes to the default project when no space is configured", async () => { + sdk.add.mockResolvedValue({ id: "doc_1" }) + + await expect(client().createMemory("remember this")).resolves.toEqual({ + id: "doc_1", + status: "queued", + containerTag: DEFAULT_PROJECT_ID, + }) + expect(sdk.add).toHaveBeenCalledWith({ + content: "remember this", + containerTag: DEFAULT_PROJECT_ID, + metadata: { sm_source: "supermemory-mcp" }, + }) + }) + + it("pins an explicit title through metadata", async () => { + sdk.add.mockResolvedValue({ id: "doc_2" }) + + await client("work").createMemory("remember this", { + title: " Quarterly planning notes ", + }) + + expect(sdk.add).toHaveBeenCalledWith({ + content: "remember this", + containerTag: "work", + metadata: { + sm_source: "supermemory-mcp", + title: "Quarterly planning notes", + }, + }) + }) + + it("leaves the title out when it is blank or absent", async () => { + sdk.add.mockResolvedValue({ id: "doc_3" }) + + await client("work").createMemory("a", { title: " " }) + await client("work").createMemory("b", {}) + + for (const call of sdk.add.mock.calls) { + expect(call[0].metadata).toEqual({ sm_source: "supermemory-mcp" }) + } + }) + + it("treats an empty space string as an unscoped connection", async () => { + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await client("").search("query") + + expect(sdk.search.memories.mock.calls[0][0]).not.toHaveProperty( + "containerTag", + ) + }) + }) + + describe("search", () => { + it("omits the space filter when the connection is unscoped", async () => { + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await client().search("query") + + expect(sdk.search.memories.mock.calls[0][0]).not.toHaveProperty( + "containerTag", + ) + }) + + it("sends the configured space and honours an explicit override", async () => { + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await client("work").search("query") + expect(sdk.search.memories).toHaveBeenLastCalledWith({ + q: "query", + limit: 10, + containerTag: "work", + searchMode: "hybrid", + threshold: undefined, + }) + + await client("work").search("query", 3, 0.5, "personal") + expect(sdk.search.memories).toHaveBeenLastCalledWith({ + q: "query", + limit: 3, + containerTag: "personal", + searchMode: "hybrid", + threshold: 0.5, + }) + + await client().search("query", 10, undefined, "personal") + expect(sdk.search.memories.mock.calls[2][0]).toMatchObject({ + containerTag: "personal", + }) + }) + + it("normalises every result shape the API can return", async () => { + sdk.search.memories.mockResolvedValue({ + results: [ + { id: "a", memory: "remembered", similarity: 0.9, title: "Title" }, + { id: "b", chunk: "chunked", similarity: 0.8 }, + { id: "c", context: "context only", similarity: 0.7 }, + { id: "d", similarity: 0.6 }, + { id: "e", content: "full body", chunk: "excerpt", similarity: 0.5 }, + ], + total: 5, + timing: 12, + }) + + await expect(client("work").search("query")).resolves.toEqual({ + results: [ + { id: "a", memory: "remembered", similarity: 0.9, title: "Title" }, + { id: "b", chunk: "chunked", similarity: 0.8 }, + { id: "c", memory: "context only", similarity: 0.7 }, + { id: "d", memory: "", similarity: 0.6 }, + { + id: "e", + chunk: "full body", + content: "full body", + similarity: 0.5, + }, + ], + total: 5, + timing: 12, + }) + }) + + it("truncates oversized memory text", async () => { + sdk.search.memories.mockResolvedValue({ + results: [{ id: "a", memory: "x".repeat(200_001), similarity: 1 }], + total: 1, + timing: 1, + }) + + const { results } = await client("work").search("query") + const text = getMemoryText(results[0]) + + expect(text).toHaveLength(200_003) + expect(text.endsWith("...")).toBe(true) + }) + + it("rejects results that do not match the API contract", async () => { + sdk.search.memories.mockResolvedValue({ + results: [{ id: "a", memory: "no similarity" }], + total: 1, + timing: 1, + }) + + await expect(client("work").search("query")).rejects.toThrow( + "Search request failed", + ) + }) + }) + + describe("profile", () => { + it("returns an empty profile without calling the API when unscoped", async () => { + await expect(client().getProfile("who am i")).resolves.toEqual({ + profile: { static: [], dynamic: [] }, + }) + expect(sdk.profile).not.toHaveBeenCalled() + }) + + it("maps profile search results when the API returns them", async () => { + sdk.profile.mockResolvedValue({ + profile: { static: ["vegetarian"], dynamic: ["lives in Berlin"] }, + searchResults: { + results: [{ id: "a", memory: "likes pizza", similarity: 0.4 }], + total: 1, + timing: 3, + }, + }) + + await expect(client("work").getProfile("who am i")).resolves.toEqual({ + profile: { static: ["vegetarian"], dynamic: ["lives in Berlin"] }, + searchResults: { + results: [{ id: "a", memory: "likes pizza", similarity: 0.4 }], + total: 1, + timing: 3, + }, + }) + expect(sdk.profile).toHaveBeenCalledWith({ + containerTag: "work", + q: "who am i", + }) + }) + + it("defaults missing profile sections to empty lists", async () => { + sdk.profile.mockResolvedValue({ profile: { static: null } }) + + await expect(client("work").getProfile()).resolves.toEqual({ + profile: { static: [], dynamic: [] }, + }) + }) + + it("unwraps the API message from a forbidden profile request", async () => { + sdk.profile.mockRejectedValue( + apiError(JSON.stringify({ error: "Profile is disabled" }), 403), + ) + + await expect(client("work").getProfile()).rejects.toThrow( + "Profile request failed: Profile is disabled", + ) + }) + }) + + describe("forgetMemory", () => { + it("forgets an exact match without searching", async () => { + sdk.memories.forget.mockResolvedValue({ id: "mem_1" }) + + await expect(client("work").forgetMemory("likes pizza")).resolves.toEqual( + { + success: true, + message: "Successfully forgot memory (exact match) with ID: mem_1", + containerTag: "work", + }, + ) + expect(sdk.search.memories).not.toHaveBeenCalled() + }) + + it("falls back to a similarity search when there is no exact match", async () => { + sdk.memories.forget + .mockRejectedValueOnce(apiError("not found", 404)) + .mockResolvedValueOnce({ id: "mem_2" }) + sdk.search.memories.mockResolvedValue({ + results: [{ id: "mem_2", memory: "likes pizza", similarity: 0.912 }], + total: 1, + timing: 2, + }) + + await expect(client("work").forgetMemory("pizza")).resolves.toEqual({ + success: true, + message: 'Forgot similar memory (similarity: 0.91): "likes pizza"', + containerTag: "work", + }) + expect(sdk.search.memories).toHaveBeenCalledWith({ + q: "pizza", + limit: 5, + containerTag: "work", + searchMode: "hybrid", + threshold: 0.85, + }) + expect(sdk.memories.forget).toHaveBeenLastCalledWith({ + id: "mem_2", + containerTag: "work", + }) + }) + + it("truncates the memory text quoted back in the confirmation", async () => { + sdk.memories.forget + .mockRejectedValueOnce(apiError("not found", 404)) + .mockResolvedValueOnce({ id: "mem_2" }) + sdk.search.memories.mockResolvedValue({ + results: [{ id: "mem_2", memory: "y".repeat(150), similarity: 0.9 }], + total: 1, + timing: 2, + }) + + const result = await client("work").forgetMemory("pizza") + + expect(result.message).toBe( + `Forgot similar memory (similarity: 0.90): "${"y".repeat(100)}..."`, + ) + }) + + it("reports when nothing matches", async () => { + sdk.memories.forget.mockRejectedValue(apiError("not found", 404)) + sdk.search.memories.mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + + await expect(client("work").forgetMemory("pizza")).resolves.toEqual({ + success: false, + message: "No matching memory found to forget.", + containerTag: "work", + }) + }) + + it("refuses to delete when only chunks matched", async () => { + sdk.memories.forget.mockRejectedValue(apiError("not found", 404)) + sdk.search.memories.mockResolvedValue({ + results: [{ id: "chunk_1", chunk: "pizza night", similarity: 0.9 }], + total: 1, + timing: 1, + }) + + await expect(client("work").forgetMemory("pizza")).resolves.toEqual({ + success: false, + message: "No matching memory found (only chunks matched).", + containerTag: "work", + }) + expect(sdk.memories.forget).toHaveBeenCalledOnce() + }) + + it("does not fall back when the failure is not a missing memory", async () => { + sdk.memories.forget.mockRejectedValue(apiError("Key is read-only", 403)) + + await expect(client("work").forgetMemory("pizza")).rejects.toThrow( + "Forget memory request failed: Key is read-only", + ) + expect(sdk.search.memories).not.toHaveBeenCalled() + }) + }) + + describe("documents", () => { + it("posts the space filter and pagination", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify(documentsPayload)), + ) + + await expect(client("work").getDocuments(["work"])).resolves.toEqual( + documentsPayload, + ) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${API_URL}/v3/documents/documents`) + expect(init.method).toBe("POST") + expect(init.headers.Authorization).toBe(`Bearer ${TOKEN}`) + expect(init.headers["x-sm-source"]).toBe("supermemory-mcp") + expect(JSON.parse(init.body)).toEqual({ + page: 1, + limit: 200, + sort: "createdAt", + order: "desc", + containerTags: ["work"], + }) + }) + + it("uses a caller supplied abort signal and pagination", async () => { + const controller = new AbortController() + fetchMock.mockResolvedValue( + new Response(JSON.stringify(documentsPayload)), + ) + + await client("work").getDocuments(undefined, 2, 10, { + signal: controller.signal, + }) + + const init = fetchMock.mock.calls[0][1] + expect(init.signal).toBe(controller.signal) + expect(JSON.parse(init.body)).toEqual({ + page: 2, + limit: 10, + sort: "createdAt", + order: "desc", + }) + }) + + it("rejects a payload that does not match the API contract", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ documents: [], pagination: {} })), + ) + + await expect(client("work").getDocuments()).rejects.toThrow() + }) + + it("lists SDK documents for the active space without content", async () => { + sdk.documents.list.mockResolvedValue({ + memories: [{ id: "doc_1" }], + pagination, + }) + + await expect(client("work").listDocuments()).resolves.toEqual({ + documents: [{ id: "doc_1" }], + pagination, + }) + expect(sdk.documents.list).toHaveBeenCalledWith({ + containerTags: ["work"], + page: 1, + limit: 50, + sort: "createdAt", + order: "desc", + includeContent: false, + }) + }) + + it("defaults to an empty list when the API omits memories", async () => { + sdk.documents.list.mockResolvedValue({ pagination }) + + await expect(client("work").listDocuments()).resolves.toEqual({ + documents: [], + pagination, + }) + }) + + it("translates a missing document without an operation prefix", async () => { + sdk.documents.get.mockRejectedValue(apiError("gone", 404)) + + await expect(client("work").getDocument("doc_1")).rejects.toThrow( + "Not found.", + ) + }) + }) + + describe("memory entries", () => { + it("lists memory entries for the active space", async () => { + const payload = { + memoryEntries: [ + { + id: "mem_1", + memory: "likes pizza", + version: 1, + isLatest: true, + isForgotten: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ], + pagination, + } + fetchMock.mockResolvedValue(new Response(JSON.stringify(payload))) + + await expect(client("work").listMemoryEntries()).resolves.toEqual(payload) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${API_URL}/v4/memories/list`) + expect(JSON.parse(init.body)).toEqual({ + containerTags: ["work"], + page: 1, + limit: 50, + sort: "createdAt", + order: "desc", + }) + }) + + it("surfaces the API message when the space is forbidden", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "Space is read-only" }), { + status: 403, + }), + ) + + await expect(client("work").listMemoryEntries()).rejects.toThrow( + "Space is read-only", + ) + }) + + it("falls back to scope guidance when the forbidden body is empty", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 403 })) + + await expect(client("work").listMemoryEntries()).rejects.toThrow( + FORBIDDEN_FALLBACK, + ) + }) + }) + + describe("container tags", () => { + it("requests the list with auth and source headers", async () => { + const tag = { + id: "ct_1", + name: "Work", + containerTag: "work", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + isExperimental: false, + isNova: false, + documentCount: 2, + memoryCount: 3, + } + fetchMock.mockResolvedValue(new Response(JSON.stringify([tag]))) + + await expect(client("work").listContainerTags()).resolves.toEqual([tag]) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${API_URL}/v3/container-tags/list`) + expect(init.method).toBe("GET") + expect(init.headers.Authorization).toBe(`Bearer ${TOKEN}`) + expect(init.headers["x-sm-source"]).toBe("supermemory-mcp") + }) + + it("asks the user to re-authenticate on 401", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 401 })) + + await expect(client().listContainerTags()).rejects.toThrow( + "Authentication failed. Please re-authenticate.", + ) + }) + + it("reports the status text for other failures", async () => { + fetchMock.mockResolvedValue( + new Response("", { status: 500, statusText: "Internal Server Error" }), + ) + + await expect(client().listContainerTags()).rejects.toThrow( + "Failed to fetch container tags: Internal Server Error", + ) + }) + + it("rejects a malformed payload", async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([{ id: 1 }]))) + + await expect(client().listContainerTags()).rejects.toThrow() + }) + }) + + describe("error translation", () => { + const cases: [number, string, string][] = [ + [400, "", "Invalid request. Check your input."], + [ + 400, + JSON.stringify({ error: "page must be positive" }), + "page must be positive", + ], + [ + 401, + JSON.stringify({ error: "expired" }), + "Authentication failed. Please re-authenticate.", + ], + [402, "", "Memory limit reached. Upgrade at supermemory.ai"], + [403, "", FORBIDDEN_FALLBACK], + [403, JSON.stringify({ error: "Key is read-only" }), "Key is read-only"], + [ + 403, + JSON.stringify({ message: "Scoped to sm_project_x" }), + "Scoped to sm_project_x", + ], + [403, "plain text refusal", "plain text refusal"], + [403, JSON.stringify({ error: "" }), FORBIDDEN_FALLBACK], + [404, JSON.stringify({ error: "no such space" }), "Not found."], + [ + 422, + JSON.stringify({ error: "limit must be <= 200" }), + "limit must be <= 200", + ], + [429, "", "Rate limit exceeded. Please wait and try again."], + [500, "", "Server error. Please try again later."], + [ + 503, + JSON.stringify({ error: "upstream down" }), + "Server error. Please try again later.", + ], + [409, "", "Request failed with status 409."], + [409, JSON.stringify({ error: "already queued" }), "already queued"], + ] + + it.each( + cases, + )("maps HTTP %i with body '%s'", async (status, body, expected) => { + fetchMock.mockResolvedValue(new Response(body, { status })) + + await expect(client("work").getDocuments()).rejects.toThrow(expected) + }) + + it("reports an aborted or timed out request", async () => { + for (const name of ["AbortError", "TimeoutError"]) { + fetchMock.mockRejectedValue( + Object.assign(new Error("aborted"), { name }), + ) + + await expect(client().listContainerTags()).rejects.toThrow( + "Request to Supermemory API timed out", + ) + } + }) + + it("reports a failed network call", async () => { + fetchMock.mockRejectedValue(new TypeError("fetch failed")) + + await expect(client().listContainerTags()).rejects.toThrow( + "Network error. Please check your connection.", + ) + }) + + it("keeps unrelated type errors intact", async () => { + fetchMock.mockRejectedValue(new TypeError("value is not iterable")) + + await expect(client().listContainerTags()).rejects.toThrow( + "value is not iterable", + ) + }) + + it("wraps a thrown non-error value", async () => { + fetchMock.mockRejectedValue("kaboom") + + await expect(client().listContainerTags()).rejects.toThrow( + "Unexpected error: kaboom", + ) + }) + + it("labels failures with the operation that caused them", async () => { + sdk.add.mockRejectedValue(apiError("over quota", 402)) + await expect(client("work").createMemory("hi")).rejects.toThrow( + "Create memory request failed: Memory limit reached. Upgrade at supermemory.ai", + ) + + sdk.search.memories.mockRejectedValue(apiError("slow down", 429)) + await expect(client("work").search("query")).rejects.toThrow( + "Search request failed: Rate limit exceeded. Please wait and try again.", + ) + }) + }) + + describe("memory text", () => { + it("reads whichever text field the API returned", () => { + expect( + getMemoryText({ id: "a", memory: "remembered", similarity: 1 }), + ).toBe("remembered") + expect(getMemoryText({ id: "b", chunk: "chunked", similarity: 1 })).toBe( + "chunked", + ) + }) + }) +}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index ad9695a40..f9638826d 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -158,6 +158,7 @@ function extractApiErrorMessage(raw: unknown): string | undefined { if (typeof parsed.error === "string" && parsed.error) return parsed.error if (typeof parsed.message === "string" && parsed.message) return parsed.message + if (parsed && typeof parsed === "object") return undefined } catch {} return raw } @@ -188,12 +189,17 @@ export class SupermemoryClient { async createMemory( content: string, + options?: { title?: string }, ): Promise<{ id: string; status: string; containerTag: string }> { try { + const title = options?.title?.trim() const result = await this.client.add({ content, containerTag: this.containerTag, - metadata: { sm_source: MCP_SOURCE }, + metadata: { + sm_source: MCP_SOURCE, + ...(title ? { title } : {}), + }, }) return { id: result.id, @@ -501,6 +507,7 @@ export class SupermemoryClient { if (status >= 500) { throw new Error("Server error. Please try again later.") } + if (!message) throw new Error(`Request failed with status ${status}.`) } } diff --git a/apps/mcp/src/server/format.test.ts b/apps/mcp/src/server/format.test.ts new file mode 100644 index 000000000..4d923c5ee --- /dev/null +++ b/apps/mcp/src/server/format.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest" +import type { DocumentDetails, DocumentsListResponse } from "./client" +import { formatDocument, formatDocumentsList } from "./format" + +function list(document: Record): DocumentsListResponse { + return { + documents: [ + { + id: "doc_1", + type: "text", + status: "done", + createdAt: "2026-08-07T12:53:00.000Z", + ...document, + }, + ], + pagination: { currentPage: 1, totalPages: 1, totalItems: 1, limit: 50 }, + } as unknown as DocumentsListResponse +} + +function details(document: Record): DocumentDetails { + return { + id: "doc_1", + type: "text", + status: "done", + createdAt: "2026-08-07T12:53:00.000Z", + updatedAt: "2026-08-07T12:53:00.000Z", + content: "body", + ...document, + } as unknown as DocumentDetails +} + +describe("document titles in MCP output", () => { + it("prefers a pinned metadata title over the stored one", () => { + expect( + formatDocumentsList( + list({ title: "Paraphrase", metadata: { title: "Pinned" } }), + ), + ).toContain('"Pinned"') + expect( + formatDocument( + details({ title: "Paraphrase", metadata: { title: "Pinned" } }), + ), + ).toContain("# Pinned") + }) + + it("uses the pinned title when titling produced nothing", () => { + expect( + formatDocumentsList(list({ title: null, metadata: { title: "Pinned" } })), + ).toContain('"Pinned"') + }) + + it("falls back to the stored title, then to a placeholder", () => { + expect(formatDocumentsList(list({ title: "Stored" }))).toContain('"Stored"') + expect(formatDocumentsList(list({ title: null }))).toContain("(untitled)") + expect(formatDocument(details({ title: null }))).toContain("# (untitled)") + }) + + it("ignores metadata that is blank or not a string", () => { + expect( + formatDocumentsList( + list({ title: "Stored", metadata: { title: " " } }), + ), + ).toContain('"Stored"') + expect( + formatDocumentsList(list({ title: "Stored", metadata: { title: 42 } })), + ).toContain('"Stored"') + }) + + it("survives non-object metadata", () => { + for (const metadata of [null, "raw", 7, true, ["a"]]) { + expect( + formatDocumentsList(list({ title: "Stored", metadata })), + ).toContain('"Stored"') + } + }) +}) diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts index c9bb3f11c..b35de6f5b 100644 --- a/apps/mcp/src/server/format.ts +++ b/apps/mcp/src/server/format.ts @@ -18,6 +18,18 @@ function day(value: string | null | undefined): string { return value?.slice(0, 10) ?? "" } +function documentTitle(document: { + title?: string | null + metadata?: unknown +}): string { + const metadata = document.metadata + if (metadata && typeof metadata === "object") { + const pinned = (metadata as Record).title + if (typeof pinned === "string" && pinned.trim()) return pinned.trim() + } + return document.title?.trim() || "(untitled)" +} + function paginationSummary( currentPage: number, totalPages: number, @@ -39,7 +51,7 @@ export function formatDocumentsList(response: DocumentsListResponse): string { } const blocks = documents.map((document) => { - const title = document.title?.trim() || "(untitled)" + const title = documentTitle(document) const lines = [ `- [${document.id}] "${title}" (${document.type}, ${document.status}, ${day(document.createdAt)})`, ] @@ -150,7 +162,7 @@ export function getDocumentContent(document: DocumentDetails): { } export function formatDocument(document: DocumentDetails): string { - const title = document.title?.trim() || "(untitled)" + const title = documentTitle(document) const parts = [ `# ${title}`, `Document ID: ${document.id}`, diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index c708deb50..62a08390c 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -11,6 +11,15 @@ export function register(deps: ToolDeps) { .max(200000, "Content exceeds maximum length") .describe("The memory content to save or forget"), action: z.enum(["save", "forget"]).optional().default("save"), + title: z + .string() + .trim() + .min(1) + .max(200) + .optional() + .describe( + "Optional title for the saved memory. Overrides the title generated during processing. Ignored when action is 'forget'.", + ), containerTag: optionalContainerTagSchema, }) @@ -42,7 +51,9 @@ export function register(deps: ToolDeps) { } } - const result = await client.createMemory(args.content) + const result = await client.createMemory(args.content, { + title: args.title, + }) const message = `Memory saved (ID: ${result.id}, space: ${result.containerTag})` const structuredContent: AddMemoryOutput = { action: "save", diff --git a/apps/web/components/brain-home/brain-home-view.tsx b/apps/web/components/brain-home/brain-home-view.tsx index 3501c5e8f..2d68b758f 100644 --- a/apps/web/components/brain-home/brain-home-view.tsx +++ b/apps/web/components/brain-home/brain-home-view.tsx @@ -12,6 +12,7 @@ import { TrialSetupBanner } from "@/components/trial-setup-banner" import { useTrialStatus } from "@/hooks/use-trial-status" import { dmSans125ClassName } from "@/lib/fonts" import { useViewMode } from "@/lib/view-mode-context" +import { resolveDocumentTitle } from "@/lib/document-title" import { AskInSlackCard, CONNECT_TOOLS_CARD_ID, @@ -31,6 +32,8 @@ const cardStyle = { type RecentDoc = { id?: string title?: string | null + content?: string | null + metadata?: Record | null createdAt?: string | Date | null updatedAt?: string | Date | null } @@ -390,7 +393,7 @@ function RecentMemories({

- {doc.title?.trim() || "Untitled memory"} + {resolveDocumentTitle(doc) || "Untitled memory"}

{formatWhen(doc.createdAt)} diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 12c76a86f..90fa98bd3 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -45,6 +45,7 @@ import { normalizePluginClientId } from "@/lib/plugin-catalog" import { detectPluginSpace } from "@/lib/plugin-space" import { useDigests } from "@/hooks/use-digests" import { ReviewMemoriesCard } from "@/components/review-memories-card" +import { resolveDocumentTitle } from "@/lib/document-title" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -1599,7 +1600,7 @@ export function DashboardView({ )} - {doc.title?.trim() || "Untitled"} + {resolveDocumentTitle(doc) || "Untitled"} diff --git a/apps/web/components/document-cards/mcp-preview.tsx b/apps/web/components/document-cards/mcp-preview.tsx index 19cdeec4a..0e33a3453 100644 --- a/apps/web/components/document-cards/mcp-preview.tsx +++ b/apps/web/components/document-cards/mcp-preview.tsx @@ -6,6 +6,7 @@ import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons" import type { ParsedPluginDocument } from "@/lib/plugin-document" +import { resolveDocumentTitle } from "@/lib/document-title" import { PluginPreview } from "./plugin-preview" type DocumentsResponse = z.infer @@ -28,6 +29,8 @@ export function McpPreview({ .replace(/\b\w/g, (match) => match.toUpperCase()) : "MCP Client" + const title = resolveDocumentTitle(document) + return (
@@ -43,9 +46,9 @@ export function McpPreview({
- {document.title && ( + {title && (

- {document.title} + {title}

)} {document.content && ( diff --git a/apps/web/components/document-cards/note-preview.tsx b/apps/web/components/document-cards/note-preview.tsx index e96234976..8de965cac 100644 --- a/apps/web/components/document-cards/note-preview.tsx +++ b/apps/web/components/document-cards/note-preview.tsx @@ -6,6 +6,7 @@ import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" import { DocumentIcon } from "@/components/document-icon" import type { ParsedPluginDocument } from "@/lib/plugin-document" +import { resolveDocumentTitle } from "@/lib/document-title" import { PluginPreview } from "./plugin-preview" type DocumentsResponse = z.infer @@ -22,6 +23,8 @@ export function NotePreview({ return } + const title = resolveDocumentTitle(document) + return (
@@ -31,14 +34,14 @@ export function NotePreview({

- {document.title && ( + {title && (

- {document.title} + {title}

)} {document.summary && ( diff --git a/apps/web/components/document-modal/index.tsx b/apps/web/components/document-modal/index.tsx index 77d808261..e74d6f977 100644 --- a/apps/web/components/document-modal/index.tsx +++ b/apps/web/components/document-modal/index.tsx @@ -27,6 +27,7 @@ import type { UseMutationResult } from "@tanstack/react-query" import { toast } from "sonner" import { useIsMobile } from "@hooks/use-mobile" import { parsePluginDocument } from "@/lib/plugin-document" +import { resolveDocumentTitle } from "@/lib/document-title" import { useFullDocumentContent } from "@/hooks/use-full-document" type DocumentsResponse = z.infer @@ -219,6 +220,10 @@ export function DocumentModal({ () => parsePluginDocument(effectiveDocument), [effectiveDocument], ) + const resolvedTitle = useMemo( + () => resolveDocumentTitle(effectiveDocument), + [effectiveDocument], + ) const [draftContentString, setDraftContentString] = useState(initialEditorString) @@ -330,17 +335,17 @@ export function DocumentModal({ <> {isMobile ? ( - {_document?.title} - Document + {resolvedTitle} - Document ) : ( - {_document?.title} - Document + {resolvedTitle} - Document )}
type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -282,7 +283,10 @@ export function DocumentsCommandPalette({ ) } - const title = item.kind === "document" ? item.doc.title : item.result.title + const title = + item.kind === "document" + ? resolveDocumentTitle(item.doc) + : item.result.title const type = item.kind === "document" ? item.doc.type : item.result.type const url = item.kind === "document" diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index beaee36cc..11a87197c 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -29,6 +29,7 @@ import { YoutubePreview } from "./document-cards/youtube-preview" import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils" import { SyncLogoIcon } from "@ui/assets/icons" import { McpPreview } from "./document-cards/mcp-preview" +import { resolveDocumentTitle } from "@/lib/document-title" import { NotionPreview } from "./document-cards/notion-preview" import { getFaviconUrl, isSupermemoryFileUrl } from "@/lib/url-helpers" import { QuickNoteCard } from "./quick-note-card" @@ -1143,6 +1144,10 @@ const DocumentCard = memo( () => parsePluginDocument(document), [document], ) + const resolvedTitle = useMemo( + () => resolveDocumentTitle(document), + [document], + ) const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 }) const cardRef = useRef<HTMLButtonElement>(null) const [ogData, setOgData] = useState<OgData | null>(null) @@ -1298,7 +1303,7 @@ const DocumentCard = memo( "text-[13px] text-[#E5E5E5] line-clamp-1 font-semibold", )} > - {document.title || ogData?.title || "Untitled Document"} + {resolvedTitle || ogData?.title || "Untitled Document"} </p> {getFaviconUrl(document.url) && needsOgData && ( <img diff --git a/apps/web/lib/document-title.test.ts b/apps/web/lib/document-title.test.ts new file mode 100644 index 000000000..9d2fd70dd --- /dev/null +++ b/apps/web/lib/document-title.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "bun:test" +import { resolveDocumentTitle } from "./document-title" + +const fromContent = (content: string | null | undefined) => + resolveDocumentTitle({ content }) + +describe("resolveDocumentTitle precedence", () => { + it("prefers metadata.title over everything", () => { + expect( + resolveDocumentTitle({ + title: "LLM paraphrase", + metadata: { title: "Pinned" }, + content: "# Derived", + }), + ).toBe("Pinned") + }) + + it("falls back to the stored title", () => { + expect( + resolveDocumentTitle({ title: "Stored", content: "# Derived" }), + ).toBe("Stored") + }) + + it("derives from content when titling produced nothing", () => { + expect(resolveDocumentTitle({ title: null, content: "# Derived" })).toBe( + "Derived", + ) + }) + + it("skips blank and non-string candidates", () => { + expect(resolveDocumentTitle({ title: " ", content: "# Derived" })).toBe( + "Derived", + ) + expect( + resolveDocumentTitle({ metadata: { title: " " }, content: "# Derived" }), + ).toBe("Derived") + expect( + resolveDocumentTitle({ metadata: { title: 42 }, content: "# Derived" }), + ).toBe("Derived") + expect( + resolveDocumentTitle({ metadata: { title: null }, title: "Stored" }), + ).toBe("Stored") + }) + + it("survives odd metadata shapes", () => { + expect(resolveDocumentTitle({ metadata: null, title: "Stored" })).toBe( + "Stored", + ) + expect( + resolveDocumentTitle({ + metadata: [] as unknown as Record<string, unknown>, + title: "Stored", + }), + ).toBe("Stored") + }) + + it("returns null when there is nothing to show", () => { + expect(resolveDocumentTitle(null)).toBeNull() + expect(resolveDocumentTitle(undefined)).toBeNull() + expect(resolveDocumentTitle({})).toBeNull() + expect(resolveDocumentTitle({ title: null, content: null })).toBeNull() + }) +}) + +describe("deriving a title from content", () => { + it("reads markdown headings at every level", () => { + expect(fromContent("# Quarterly planning\n\nProse.")).toBe( + "Quarterly planning", + ) + expect(fromContent("###### Deep heading\n\nProse.")).toBe("Deep heading") + }) + + it("drops closing hashes and surrounding markup", () => { + expect(fromContent("### Deploy runbook ###\n\nSteps.")).toBe( + "Deploy runbook", + ) + expect(fromContent("# **Bold heading**\n\nProse.")).toBe("Bold heading") + expect(fromContent("**Bold line**\n\nProse.")).toBe("Bold line") + expect(fromContent("`code line`\n\nProse.")).toBe("code line") + expect(fromContent('"Quoted line"\n\nProse.')).toBe("Quoted line") + }) + + it("requires a space after the hashes", () => { + expect(fromContent("#NotAHeading\n\nProse.")).toBe("#NotAHeading") + }) + + it("reads a YAML frontmatter title", () => { + expect( + fromContent( + '---\ntitle: "Kubernetes upgrade"\ntags: [infra]\n---\n\nBody.', + ), + ).toBe("Kubernetes upgrade") + expect(fromContent("---\ntitle: 'Single quoted'\n---\nBody.")).toBe( + "Single quoted", + ) + }) + + it("prefers frontmatter over a following heading", () => { + expect(fromContent("---\ntitle: Real\n---\n\n# Other")).toBe("Real") + }) + + it("falls through when frontmatter has no usable title", () => { + expect(fromContent("---\ntags: [infra]\n---\n\n# Heading wins")).toBe( + "Heading wins", + ) + expect(fromContent("---\ntitle:\n---\n\n# Heading wins")).toBe( + "Heading wins", + ) + }) + + it("ignores an indented title key inside frontmatter", () => { + expect(fromContent("---\nauthor:\n title: Nested\n---\n\n# Heading")).toBe( + "Heading", + ) + }) + + it("takes a short opening line followed by prose", () => { + expect( + fromContent("Postgres connection pooling\n\nWe moved to pgbouncer."), + ).toBe("Postgres connection pooling") + }) + + it("takes a setext heading regardless of length", () => { + const long = `${"Long ".repeat(40)}heading` + expect(fromContent(`${long}\n===\n\nBody.`)).toStartWith("Long") + expect(fromContent("Underlined\n---\n\nBody.")).toBe("Underlined") + }) + + it("rejects an opening paragraph too long to be a title", () => { + expect( + fromContent("This is ordinary prose that keeps going. ".repeat(6)), + ).toBeNull() + }) + + it("rejects list, quote, table, rule, fence and URL openers", () => { + expect(fromContent("- first\n- second")).toBeNull() + expect(fromContent("* first\n* second")).toBeNull() + expect(fromContent("1. first\n2. second")).toBeNull() + expect(fromContent("> quoted\n\nmore")).toBeNull() + expect(fromContent("| a | b |\n| - | - |")).toBeNull() + expect(fromContent("---\n\nnot frontmatter")).toBeNull() + expect(fromContent("```ts\nconst a = 1\n```")).toBeNull() + expect(fromContent("~~~\ncode\n~~~")).toBeNull() + expect(fromContent("https://example.com/article")).toBeNull() + expect(fromContent("www.example.com/article")).toBeNull() + }) + + it("skips leading blank lines", () => { + expect(fromContent("\n\n\n# After blanks\n\nProse.")).toBe("After blanks") + }) + + it("handles CRLF, a BOM and collapsed whitespace", () => { + expect(fromContent("# Spaced out\r\n\r\nBody.")).toBe("Spaced out") + expect(fromContent("\ufeff---\r\ntitle: From BOM\r\n---\r\nBody.")).toBe( + "From BOM", + ) + expect(fromContent("Tabbed\ttitle\n\nBody.")).toBe("Tabbed title") + }) + + it("truncates an overlong heading to a bounded length", () => { + const title = fromContent(`# ${"word ".repeat(60)}`) + expect(title).not.toBeNull() + expect((title as string).length).toBeLessThanOrEqual(120) + expect(title).toEndWith("…") + }) + + it("returns null for empty, blank or missing content", () => { + expect(fromContent("")).toBeNull() + expect(fromContent(" \n\n ")).toBeNull() + expect(fromContent("#\n\nBody.")).toBeNull() + expect(fromContent(null)).toBeNull() + expect(fromContent(undefined)).toBeNull() + }) + + it("handles a single-line document with no trailing newline", () => { + expect(fromContent("Just one line")).toBe("Just one line") + }) +}) + +describe("issue #1425 saves", () => { + const cases = [ + [ + "markdown H1", + "# Kubernetes upgrade plan\n\nWe are moving the cluster to 1.31.", + "Kubernetes upgrade plan", + ], + [ + "YAML frontmatter", + "---\ntitle: Postgres pooling decision\ndate: 2026-08-07\n---\n\nWe moved to pgbouncer.", + "Postgres pooling decision", + ], + [ + "title line then blank line then prose", + "Vendor security review\n\nThey passed SOC2 but the DPA needs redlines.", + "Vendor security review", + ], + ] as const + + for (const [label, content, expected] of cases) { + it(`${label} no longer reads as untitled`, () => { + const doc = { + title: null, + content, + metadata: { sm_source: "supermemory-mcp" }, + } + expect(resolveDocumentTitle(doc)).toBe(expected) + }) + } + + it("a pinned title repairs a card without re-saving", () => { + expect( + resolveDocumentTitle({ + title: null, + content: cases[0][1], + metadata: { sm_source: "supermemory-mcp", title: "My chosen title" }, + }), + ).toBe("My chosen title") + }) + + it("a pinned title beats an LLM paraphrase", () => { + expect( + resolveDocumentTitle({ + title: "Notes About Upgrading Some Infrastructure", + content: cases[0][1], + metadata: { title: "Kubernetes upgrade plan" }, + }), + ).toBe("Kubernetes upgrade plan") + }) +}) diff --git a/apps/web/lib/document-title.ts b/apps/web/lib/document-title.ts new file mode 100644 index 000000000..2328b1e02 --- /dev/null +++ b/apps/web/lib/document-title.ts @@ -0,0 +1,106 @@ +type TitleSource = { + title?: string | null + content?: string | null + metadata?: Record<string, unknown> | null +} + +const MAX_TITLE_CHARS = 120 +const FRONTMATTER = /^\ufeff?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/ +const FRONTMATTER_TITLE = /^title[ \t]*:[ \t]*(.+)$/m +const ATX_HEADING = /^#{1,6}\s+(.*?)\s*#*$/ +const SETEXT_UNDERLINE = /^(?:=+|-{2,})$/ +const HORIZONTAL_RULE = /^(?:-{3,}|\*{3,}|_{3,})$/ +const BLOCK_MARKER = /^(?:[-*+]\s|>\s?|\d+[.)]\s|\|)/ +const BARE_URL = /^(?:https?:\/\/|www\.)\S+$/i +const WORD = /[\p{L}\p{N}]/u +const WRAPPERS = ["***", "**", "__", "*", "_", "`"] + +function collapse(value: string): string { + return value.replace(/\s+/g, " ").trim() +} + +function clamp(value: string): string | null { + if (!value) return null + return value.length <= MAX_TITLE_CHARS + ? value + : `${value.slice(0, MAX_TITLE_CHARS - 1).trimEnd()}…` +} + +function unwrap(value: string): string { + let text = value.trim() + for (const marker of WRAPPERS) { + while ( + text.length > marker.length * 2 && + text.startsWith(marker) && + text.endsWith(marker) + ) { + text = text.slice(marker.length, -marker.length).trim() + } + } + const quote = text[0] + if ( + text.length >= 2 && + (quote === '"' || quote === "'") && + text.endsWith(quote) + ) { + text = text.slice(1, -1).trim() + } + return text +} + +function fromContent(content: string): string | null { + const frontmatter = FRONTMATTER.exec(content) + const declared = frontmatter?.[1] + ? FRONTMATTER_TITLE.exec(frontmatter[1])?.[1] + : undefined + if (declared) { + const title = clamp(collapse(unwrap(declared))) + if (title) return title + } + + const body = frontmatter ? content.slice(frontmatter[0].length) : content + const lines = body.split(/\r?\n/) + const start = lines.findIndex((line) => line.trim().length > 0) + if (start === -1) return null + + const first = (lines[start] ?? "").trim() + if ( + first.startsWith("```") || + first.startsWith("~~~") || + BARE_URL.test(first) || + HORIZONTAL_RULE.test(first) + ) { + return null + } + + const heading = ATX_HEADING.exec(first) + if (heading) return clamp(collapse(unwrap(heading[1] ?? ""))) + if (BLOCK_MARKER.test(first)) return null + + const candidate = collapse(unwrap(first)) + if (!WORD.test(candidate)) return null + const underlined = SETEXT_UNDERLINE.test(lines[start + 1]?.trim() ?? "") + if (!underlined && candidate.length > MAX_TITLE_CHARS) return null + return clamp(candidate) +} + +export function resolveDocumentTitle( + document: TitleSource | null | undefined, +): string | null { + if (!document) return null + + const metadata = document.metadata + const pinned = + metadata && typeof metadata === "object" ? metadata.title : undefined + + for (const candidate of [pinned, document.title]) { + if (typeof candidate === "string") { + const title = clamp(collapse(candidate)) + if (title) return title + } + } + + return typeof document.content === "string" + ? fromContent(document.content) + : null +}