mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): add Cursor to Agents (#1361)
Adds Cursor projects to Agents spaces with source filters, legacy labels, icons, and Codex-style structured conversation rendering. Tests: targeted Agents and plugin-document tests.
This commit is contained in:
parent
80af8c9043
commit
6f3c835e8f
7 changed files with 221 additions and 13 deletions
|
|
@ -95,7 +95,12 @@ type Category = {
|
|||
count: number
|
||||
}
|
||||
|
||||
const AGENT_CATALOG_IDS = ["claude_code", "codex", "opencode"] as const
|
||||
const AGENT_CATALOG_IDS = [
|
||||
"claude_code",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cursor",
|
||||
] as const
|
||||
|
||||
export function SelectSpacesModal({
|
||||
isOpen,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "./agent-space"
|
||||
|
||||
describe("Agents spaces", () => {
|
||||
it("recognizes Claude, Codex, and OpenCode shared and legacy tags", () => {
|
||||
it("recognizes shared and legacy tags from every unified agent", () => {
|
||||
expect(isAgentContainerTag("repo_supermemory__0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("user_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("repo_supermemory")).toBe(true)
|
||||
|
|
@ -18,6 +18,8 @@ describe("Agents spaces", () => {
|
|||
expect(isAgentContainerTag("codex_user_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("opencode_user_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("cursor_project_0123456789abcdef")).toBe(true)
|
||||
expect(isAgentContainerTag("cursor_user_0123456789abcdef")).toBe(true)
|
||||
})
|
||||
|
||||
it("shows agent filters only for an Agents selection", () => {
|
||||
|
|
@ -40,6 +42,7 @@ describe("Agents spaces", () => {
|
|||
])
|
||||
expect(agentSourceValues("codex")).toEqual(["codex"])
|
||||
expect(agentSourceValues("opencode")).toEqual(["opencode"])
|
||||
expect(agentSourceValues("cursor")).toEqual(["cursor"])
|
||||
expect(agentSourceValues(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
|
|
@ -148,4 +151,25 @@ describe("Agents spaces", () => {
|
|||
"claudecode_project_0123456789abcdef",
|
||||
)
|
||||
})
|
||||
|
||||
it("shows an unambiguous Cursor project label before metadata loads", () => {
|
||||
const groups = groupAgentSpaces(
|
||||
[{ containerTag: "cursor_project_0123456789abcdef" }],
|
||||
new Map(),
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.label).toBe("Cursor project · 012345")
|
||||
})
|
||||
|
||||
it("shows old Cursor personal memory without a Legacy prefix", () => {
|
||||
const groups = groupAgentSpaces(
|
||||
[{ containerTag: "cursor_user_fedcba9876543210" }],
|
||||
new Map(),
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.label).toBe("Cursor personal · fedcba")
|
||||
expect(groups[0]?.kind).toBe("legacy-personal")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export type AgentContainerKind =
|
|||
| "legacy-personal"
|
||||
| "legacy-project"
|
||||
|
||||
export type AgentSourceFilter = "claude-code" | "codex" | "opencode"
|
||||
export type AgentSourceFilter = "claude-code" | "codex" | "opencode" | "cursor"
|
||||
|
||||
export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
|
||||
value: AgentSourceFilter
|
||||
|
|
@ -19,6 +19,7 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
|
|||
},
|
||||
{ value: "codex", label: "Codex", sources: ["codex"] },
|
||||
{ value: "opencode", label: "OpenCode", sources: ["opencode"] },
|
||||
{ value: "cursor", label: "Cursor", sources: ["cursor"] },
|
||||
]
|
||||
|
||||
export type AgentSpaceMetadata = {
|
||||
|
|
@ -63,6 +64,14 @@ const TAG_PATTERNS: Array<{
|
|||
kind: "legacy-project",
|
||||
pattern: /^opencode_project_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
{
|
||||
kind: "legacy-personal",
|
||||
pattern: /^cursor_user_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
{
|
||||
kind: "legacy-project",
|
||||
pattern: /^cursor_project_([0-9a-f]{6,64})$/i,
|
||||
},
|
||||
]
|
||||
|
||||
function matchAgentTag(containerTag: string): {
|
||||
|
|
@ -127,7 +136,7 @@ function tagPriority(containerTag: string): number {
|
|||
case "personal":
|
||||
return 1
|
||||
case "legacy-personal":
|
||||
return containerTag.startsWith("claudecode_project_") ? 2 : 5
|
||||
return containerTag.startsWith("claudecode_project_") ? 2 : 6
|
||||
case "project":
|
||||
return 3
|
||||
case "legacy-project":
|
||||
|
|
@ -146,17 +155,25 @@ function legacyGroupIdentity(
|
|||
return { key: `tag:${containerTag}`, label: containerTag, kind: "project" }
|
||||
}
|
||||
|
||||
// Old Codex and OpenCode personal containers were intentionally global.
|
||||
// Old Codex, OpenCode, and Cursor personal containers were global.
|
||||
// Even if the newest document has a project name, assigning the whole
|
||||
// container to that project would mix memories from historical projects.
|
||||
if (
|
||||
containerTag.startsWith("codex_user_") ||
|
||||
containerTag.startsWith("opencode_user_")
|
||||
containerTag.startsWith("opencode_user_") ||
|
||||
containerTag.startsWith("cursor_user_")
|
||||
) {
|
||||
const agent = containerTag.startsWith("codex_user_") ? "Codex" : "OpenCode"
|
||||
const agent = containerTag.startsWith("codex_user_")
|
||||
? "Codex"
|
||||
: containerTag.startsWith("opencode_user_")
|
||||
? "OpenCode"
|
||||
: "Cursor"
|
||||
return {
|
||||
key: `legacy-personal:${containerTag}`,
|
||||
label: `Legacy ${agent} personal`,
|
||||
label:
|
||||
agent === "Cursor"
|
||||
? `Cursor personal · ${match.id.slice(0, 6)}`
|
||||
: `Legacy ${agent} personal`,
|
||||
kind: "legacy-personal",
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +186,14 @@ function legacyGroupIdentity(
|
|||
}
|
||||
}
|
||||
|
||||
if (containerTag.startsWith("cursor_project_")) {
|
||||
return {
|
||||
key: `cursor-project:${match.id.toLocaleLowerCase()}`,
|
||||
label: `Cursor project · ${match.id.slice(0, 6)}`,
|
||||
kind: "project",
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
containerTag.startsWith("user_project_") ||
|
||||
containerTag.startsWith("claudecode_project_") ||
|
||||
|
|
@ -225,7 +250,7 @@ function addProjectToGroup<T extends { containerTag: string }>(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collapse the physical Claude/Codex/OpenCode containers into one selectable
|
||||
* Collapse the physical Claude/Codex/OpenCode/Cursor containers into one selectable
|
||||
* Agents row per project. Every returned container tag remains real; the UI
|
||||
* never writes to a synthetic "agents" tag.
|
||||
*/
|
||||
|
|
@ -280,7 +305,9 @@ export function groupAgentSpaces<T extends { containerTag: string }>(
|
|||
addProjectToGroup(
|
||||
grouped,
|
||||
key,
|
||||
projectName ?? identity.label,
|
||||
identity.kind === "legacy-personal"
|
||||
? identity.label
|
||||
: (projectName ?? identity.label),
|
||||
identity.kind,
|
||||
project,
|
||||
projectName,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,54 @@ describe("parsePluginDocument — session transcripts", () => {
|
|||
expect(parsed?.pluginIconSrc).toBe("/images/plugins/claude-code.svg")
|
||||
})
|
||||
|
||||
it("renders a new Cursor capture as structured conversation cards", () => {
|
||||
const parsed = parsePluginDocument({
|
||||
id: "doc_cursor",
|
||||
title: "Cursor conversation",
|
||||
content: [
|
||||
"[Conversation cursor-session-1]",
|
||||
"1. [user] Keep the API boundary stable",
|
||||
"2. [assistant] I will preserve it.",
|
||||
].join("\n"),
|
||||
source: "cursor",
|
||||
metadata: { sm_source: "cursor", type: "conversation" },
|
||||
containerTags: ["repo_supermemory__0123456789abcdef"],
|
||||
memoryEntries: [],
|
||||
} as unknown as PluginDocumentInput)
|
||||
|
||||
expect(parsed?.pluginLabel).toBe("Cursor")
|
||||
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
|
||||
expect(parsed?.formatLabel).toBe("Conversation")
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.role).toBe("user")
|
||||
expect(parsed?.messages[1]?.role).toBe("assistant")
|
||||
})
|
||||
|
||||
it("renders old Cursor tags and transcripts without source metadata", () => {
|
||||
const parsed = parsePluginDocument({
|
||||
id: "doc_cursor_legacy",
|
||||
title: "Cursor session",
|
||||
content: [
|
||||
"Cursor IDE session transcript:",
|
||||
"User: Fix the renderer",
|
||||
"with the existing card design.",
|
||||
"Assistant: Implemented the parser.",
|
||||
].join("\n"),
|
||||
source: "api",
|
||||
metadata: {},
|
||||
containerTags: ["cursor_project_0123456789abcdef"],
|
||||
memoryEntries: [],
|
||||
} as unknown as PluginDocumentInput)
|
||||
|
||||
expect(parsed?.pluginLabel).toBe("Cursor")
|
||||
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe(
|
||||
"Fix the renderer\nwith the existing card design.",
|
||||
)
|
||||
expect(parsed?.messages[1]?.text).toBe("Implemented the parser.")
|
||||
})
|
||||
|
||||
it("keeps multi-line message bodies intact", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ function formatClientName(value: string | null | undefined): string | null {
|
|||
if (lower === "claude desktop") return "Claude Desktop"
|
||||
if (lower === "claude code") return "Claude Code"
|
||||
if (lower === "opencode") return "OpenCode"
|
||||
if (lower === "cursor") return "Cursor"
|
||||
if (lower === "openclaw") return "OpenClaw"
|
||||
if (lower === "hermes") return "Hermes"
|
||||
if (lower === "amp") return "Amp"
|
||||
|
|
@ -136,6 +137,12 @@ function pluginIdentityFromSource(
|
|||
label: "OpenCode",
|
||||
iconSrc: "/images/plugins/opencode.svg",
|
||||
}
|
||||
case "cursor":
|
||||
return {
|
||||
pluginId: "cursor",
|
||||
label: "Cursor",
|
||||
iconSrc: "/images/plugins/cursor.png",
|
||||
}
|
||||
case "amp":
|
||||
return {
|
||||
pluginId: "amp",
|
||||
|
|
@ -164,7 +171,19 @@ function pluginIdentityFromSpace(
|
|||
.filter((tag): tag is string => typeof tag === "string" && !!tag)
|
||||
: []
|
||||
|
||||
for (const tag of [...containerTags, ...memorySpaceTags]) {
|
||||
const allTags = [...containerTags, ...memorySpaceTags]
|
||||
for (const tag of allTags) {
|
||||
if (/^cursor_(?:user|project)_[0-9a-f]{6,64}$/i.test(tag)) {
|
||||
return {
|
||||
pluginId: "cursor",
|
||||
label: "Cursor",
|
||||
iconSrc: "/images/plugins/cursor.png",
|
||||
projectId: tag.split("_").at(-1)?.slice(0, 6),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of allTags) {
|
||||
const plugin = detectPluginSpace(tag)
|
||||
if (plugin) return plugin
|
||||
}
|
||||
|
|
@ -380,7 +399,7 @@ function parseSessionTranscript(
|
|||
content: string,
|
||||
config: {
|
||||
kind: "codex-session" | "amp-thread" | "plugin-session"
|
||||
headerLabel: "Session" | "Amp thread"
|
||||
headerLabel: "Session" | "Amp thread" | "Conversation"
|
||||
pluginLabel: string
|
||||
pluginIconSrc?: string | null
|
||||
formatLabel: string
|
||||
|
|
@ -424,6 +443,61 @@ function parseSessionTranscript(
|
|||
}
|
||||
}
|
||||
|
||||
function parseLegacyCursorTranscript(
|
||||
content: string,
|
||||
plugin: PluginIdentity | null,
|
||||
): ParsedPluginDocument | null {
|
||||
if (
|
||||
plugin?.pluginId !== "cursor" ||
|
||||
!/^Cursor IDE session transcript:\s*/i.test(content)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const transcript = content.replace(/^Cursor IDE session transcript:\s*/i, "")
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const regex =
|
||||
/^(User|Assistant):\s*([\s\S]*?)(?=^(?:User|Assistant):\s*|(?![\s\S]))/gim
|
||||
|
||||
for (const match of transcript.matchAll(regex)) {
|
||||
const role =
|
||||
match[1]?.toLowerCase() === "user"
|
||||
? ("user" as const)
|
||||
: ("assistant" as const)
|
||||
const text = match[2]?.trim()
|
||||
if (!text) continue
|
||||
messages.push({
|
||||
id: `${role}-${messages.length}`,
|
||||
role,
|
||||
text,
|
||||
})
|
||||
}
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const userCount = messages.filter((message) => message.role === "user").length
|
||||
const assistantCount = messages.filter(
|
||||
(message) => message.role === "assistant",
|
||||
).length
|
||||
const previewSource =
|
||||
messages.find((message) => message.role === "user")?.text ??
|
||||
messages[0]?.text ??
|
||||
"Conversation"
|
||||
|
||||
return {
|
||||
kind: "plugin-session",
|
||||
pluginLabel: plugin.label,
|
||||
pluginIconSrc: plugin.iconSrc ?? undefined,
|
||||
formatLabel: "Conversation",
|
||||
title: "Cursor conversation",
|
||||
preview: takePreview(previewSource, 140),
|
||||
summary: `${userCount} user message${userCount === 1 ? "" : "s"} and ${assistantCount} assistant message${assistantCount === 1 ? "" : "s"} captured from Cursor.`,
|
||||
artifacts: [],
|
||||
messages,
|
||||
sections: [],
|
||||
rawContent: content,
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoleBlockTranscript(
|
||||
content: string,
|
||||
plugin: PluginIdentity | null,
|
||||
|
|
@ -710,6 +784,26 @@ export function parsePluginDocument(
|
|||
}
|
||||
}
|
||||
|
||||
if (plugin?.pluginId === "cursor") {
|
||||
const cursorSession = parseSessionTranscript(content, {
|
||||
kind: "plugin-session",
|
||||
headerLabel: "Conversation",
|
||||
pluginLabel: plugin.label,
|
||||
pluginIconSrc: plugin.iconSrc,
|
||||
formatLabel: "Conversation",
|
||||
})
|
||||
if (cursorSession) {
|
||||
if (clientName) {
|
||||
cursorSession.clientLabel = "Client"
|
||||
cursorSession.clientValue = clientName
|
||||
}
|
||||
return withIcon(cursorSession)
|
||||
}
|
||||
|
||||
const legacyCursorSession = parseLegacyCursorTranscript(content, plugin)
|
||||
if (legacyCursorSession) return withIcon(legacyCursorSession)
|
||||
}
|
||||
|
||||
if (plugin?.pluginId === "amp") {
|
||||
const ampThread = parseSessionTranscript(content, {
|
||||
kind: "amp-thread",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type PluginSpaceInfo = {
|
|||
| "openclaw"
|
||||
| "opencode"
|
||||
| "codex"
|
||||
| "cursor"
|
||||
| "amp"
|
||||
| "hermes"
|
||||
label: string
|
||||
|
|
@ -27,7 +28,14 @@ const PLUGINS: PluginDef[] = [
|
|||
id: "agents",
|
||||
label: "Agents",
|
||||
iconSrc: null,
|
||||
prefixes: ["user_project", "repo", "claudecode", "codex", "opencode"],
|
||||
prefixes: [
|
||||
"user_project",
|
||||
"repo",
|
||||
"claudecode",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cursor",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
|
|
@ -73,6 +81,7 @@ const PLUGIN_ICON_BY_LABEL: Record<string, string> = {
|
|||
OpenClaw: "/images/plugins/openclaw.svg",
|
||||
OpenCode: "/images/plugins/opencode.svg",
|
||||
Codex: "/images/plugins/codex.png",
|
||||
Cursor: "/images/plugins/cursor.png",
|
||||
Hermes: "/images/plugins/hermes.svg",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,5 +63,6 @@ export const agentSourceParam = parseAsStringLiteral([
|
|||
"claude-code",
|
||||
"codex",
|
||||
"opencode",
|
||||
"cursor",
|
||||
] as const)
|
||||
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue