mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge e62ef1a40b into 9652478093
This commit is contained in:
commit
6042889ae9
14 changed files with 1174 additions and 15 deletions
694
apps/mcp/src/server/client/index.test.ts
Normal file
694
apps/mcp/src/server/client/index.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
76
apps/mcp/src/server/format.test.ts
Normal file
76
apps/mcp/src/server/format.test.ts
Normal 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"')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
229
apps/web/lib/document-title.test.ts
Normal file
229
apps/web/lib/document-title.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
106
apps/web/lib/document-title.ts
Normal file
106
apps/web/lib/document-title.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue