diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index f95321b0..fa283b3b 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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, diff --git a/apps/web/lib/agent-space.test.ts b/apps/web/lib/agent-space.test.ts index 9082c21c..857072b8 100644 --- a/apps/web/lib/agent-space.test.ts +++ b/apps/web/lib/agent-space.test.ts @@ -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") + }) }) diff --git a/apps/web/lib/agent-space.ts b/apps/web/lib/agent-space.ts index 7f78f7bd..16044946 100644 --- a/apps/web/lib/agent-space.ts +++ b/apps/web/lib/agent-space.ts @@ -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( } /** - * 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( addProjectToGroup( grouped, key, - projectName ?? identity.label, + identity.kind === "legacy-personal" + ? identity.label + : (projectName ?? identity.label), identity.kind, project, projectName, diff --git a/apps/web/lib/plugin-document.test.ts b/apps/web/lib/plugin-document.test.ts index 2fa58a07..992895f5 100644 --- a/apps/web/lib/plugin-document.test.ts +++ b/apps/web/lib/plugin-document.test.ts @@ -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( diff --git a/apps/web/lib/plugin-document.ts b/apps/web/lib/plugin-document.ts index 1606311f..8a80fbdb 100644 --- a/apps/web/lib/plugin-document.ts +++ b/apps/web/lib/plugin-document.ts @@ -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", diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts index ba667273..6f5194b6 100644 --- a/apps/web/lib/plugin-space.ts +++ b/apps/web/lib/plugin-space.ts @@ -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 = { 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", } diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index b900352b..5aff8218 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -63,5 +63,6 @@ export const agentSourceParam = parseAsStringLiteral([ "claude-code", "codex", "opencode", + "cursor", ] as const) export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])