This commit is contained in:
Aditya Mishra 2026-08-26 09:36:33 -07:00 committed by GitHub
commit 6042889ae9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1174 additions and 15 deletions

View file

@ -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",
)
})
})
})

View file

@ -128,6 +128,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
}
@ -158,12 +159,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,
@ -471,6 +477,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}.`)
}
}

View file

@ -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<string, unknown>): 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<string, unknown>): 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"')
}
})
})

View file

@ -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<string, unknown>).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}`,

View file

@ -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",

View file

@ -13,6 +13,7 @@ import { BrainSetupModal } from "@/components/brain-setup-modal"
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,
@ -32,6 +33,8 @@ const cardStyle = {
type RecentDoc = {
id?: string
title?: string | null
content?: string | null
metadata?: Record<string, unknown> | null
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
@ -397,7 +400,7 @@ function RecentMemories({
<FileText className="size-3.5" />
</div>
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-[#fafafa]">
{doc.title?.trim() || "Untitled memory"}
{resolveDocumentTitle(doc) || "Untitled memory"}
</p>
<span className="shrink-0 text-[11px] font-medium text-[#737373]">
{formatWhen(doc.createdAt)}

View file

@ -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<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -1599,7 +1600,7 @@ export function DashboardView({
)}
</div>
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
{doc.title?.trim() || "Untitled"}
{resolveDocumentTitle(doc) || "Untitled"}
</span>
<ArrowRight className="size-3.5 shrink-0 text-fg-faint group-hover:text-fg-muted transition-colors" />
</button>

View file

@ -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<typeof DocumentsWithMemoriesResponseSchema>
@ -28,6 +29,8 @@ export function McpPreview({
.replace(/\b\w/g, (match) => match.toUpperCase())
: "MCP Client"
const title = resolveDocumentTitle(document)
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-1">
@ -43,9 +46,9 @@ export function McpPreview({
<MCPIcon className="size-6" />
</div>
<div className="space-y-[6px]">
{document.title && (
{title && (
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{document.title}
{title}
</p>
)}
{document.content && (

View file

@ -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<typeof DocumentsWithMemoriesResponseSchema>
@ -22,6 +23,8 @@ export function NotePreview({
return <PluginPreview parsed={parsed} />
}
const title = resolveDocumentTitle(document)
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
@ -31,14 +34,14 @@ export function NotePreview({
</p>
</div>
<div>
{document.title && (
{title && (
<p
className={cn(
dmSansClassName(),
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
)}
>
{document.title}
{title}
</p>
)}
{document.summary && (

View file

@ -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<typeof DocumentsWithMemoriesResponseSchema>
@ -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 ? (
<DrawerTitle className="sr-only">
{_document?.title} - Document
{resolvedTitle} - Document
</DrawerTitle>
) : (
<DialogTitle className="sr-only">
{_document?.title} - Document
{resolvedTitle} - Document
</DialogTitle>
)}
<div className="flex items-center justify-between h-fit gap-2 md:gap-4">
<div className="flex-1 min-w-0">
<Title
title={_document?.title}
title={resolvedTitle}
documentType={_document?.type ?? "text"}
url={_document?.url}
pluginIconSrc={pluginDocument?.pluginIconSrc}

View file

@ -16,6 +16,7 @@ import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react"
import { DocumentIcon } from "@/components/document-icon"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { $fetch } from "@lib/api"
import { resolveDocumentTitle } from "@/lib/document-title"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
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"

View file

@ -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

View file

@ -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")
})
})

View file

@ -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
}