mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 16:13:19 +00:00
Merge 75158df4a6 into ffac43db93
This commit is contained in:
commit
761b529a37
3 changed files with 395 additions and 67 deletions
250
apps/web/app/(app)/connect/page.tsx
Normal file
250
apps/web/app/(app)/connect/page.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { OAUTH_PLUGINS } from "@/lib/oauth-plugins"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Building2, ExternalLink, LoaderIcon, Plug, Trash2 } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
interface Connection {
|
||||
clientId: string
|
||||
name: string
|
||||
icon: string | null
|
||||
isFirstParty: boolean
|
||||
workspaceId: string | null
|
||||
workspaceName: string | null
|
||||
scopes: string[]
|
||||
connectedAt: string | null
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return null
|
||||
const diff = Date.now() - then
|
||||
const mins = Math.round(diff / 60000)
|
||||
if (mins < 1) return "just now"
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.round(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
const months = Math.round(days / 30)
|
||||
if (months < 12) return `${months}mo ago`
|
||||
return `${Math.round(months / 12)}y ago`
|
||||
}
|
||||
|
||||
const cardClass = cn(
|
||||
"rounded-[14px] bg-[#14161A] p-5",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)
|
||||
|
||||
function PluginIcon({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[8px] border border-[#1E293B] bg-[#080B0F]">
|
||||
<Plug className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[8px] border border-[#1E293B] bg-[#080B0F]">
|
||||
<Image
|
||||
alt={alt}
|
||||
className="size-5"
|
||||
height={20}
|
||||
onError={() => setFailed(true)}
|
||||
src={src}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConnectPage() {
|
||||
const [connections, setConnections] = useState<Connection[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [revoking, setRevoking] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/v3/oauth/grants`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to load connections (${res.status})`)
|
||||
const data = (await res.json()) as { grants: Connection[] }
|
||||
setConnections(data.grants)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("Failed to load connections:", err)
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load connections",
|
||||
)
|
||||
setConnections([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function revoke(clientId: string) {
|
||||
setRevoking(clientId)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/v3/oauth/grants/${encodeURIComponent(clientId)}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (!res.ok && res.status !== 204)
|
||||
throw new Error(`Failed to revoke (${res.status})`)
|
||||
setConnections((prev) =>
|
||||
prev ? prev.filter((c) => c.clientId !== clientId) : prev,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Failed to revoke connection:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to revoke")
|
||||
} finally {
|
||||
setRevoking(null)
|
||||
}
|
||||
}
|
||||
|
||||
const connectedClientIds = new Set(connections?.map((c) => c.clientId) ?? [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-[760px] px-4 py-10",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<h1 className="font-semibold text-[22px] text-[#FAFAFA]">Connections</h1>
|
||||
<p className="mt-1 text-[14px] text-[#737373]">
|
||||
Apps and plugins you've connected to your Supermemory account.
|
||||
</p>
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-[13px] text-[#737373] uppercase tracking-[0.06em]">
|
||||
Connected apps
|
||||
</h2>
|
||||
|
||||
{connections === null ? (
|
||||
<div className={cn(cardClass, "flex items-center gap-3")}>
|
||||
<LoaderIcon className="size-4 animate-spin text-[#4BA0FA]" />
|
||||
<span className="text-[14px] text-[#737373]">Loading…</span>
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<div className={cn(cardClass, "text-center")}>
|
||||
<p className="text-[14px] text-[#FAFAFA]">No apps connected yet</p>
|
||||
<p className="mt-1 text-[13px] text-[#737373]">
|
||||
Connect a plugin below — anything you authorize will show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{connections.map((c) => {
|
||||
const connectedRel = relativeTime(c.connectedAt)
|
||||
const usedRel = relativeTime(c.lastUsedAt)
|
||||
return (
|
||||
<div
|
||||
key={c.clientId}
|
||||
className={cn(cardClass, "flex items-center gap-4")}
|
||||
>
|
||||
<PluginIcon src={c.icon} alt={c.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-[15px] text-[#FAFAFA]">
|
||||
{c.name}
|
||||
</p>
|
||||
{!c.isFirstParty && (
|
||||
<span className="rounded-full border border-[#1E293B] px-1.5 py-0.5 text-[10px] text-[#737373] uppercase tracking-[0.04em]">
|
||||
external
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[12px] text-[#737373]">
|
||||
{c.workspaceName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="size-3" />
|
||||
{c.workspaceName}
|
||||
</span>
|
||||
)}
|
||||
{connectedRel && <span>Connected {connectedRel}</span>}
|
||||
{usedRel && <span>Last used {usedRel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revoke(c.clientId)}
|
||||
disabled={revoking === c.clientId}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[8px] border border-[#1E293B] bg-[#0D121A] px-3 py-1.5",
|
||||
"text-[13px] text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
|
||||
"cursor-pointer disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{revoking === c.clientId ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-[13px] text-red-400">{error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-[13px] text-[#737373] uppercase tracking-[0.06em]">
|
||||
Available plugins
|
||||
</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{OAUTH_PLUGINS.map((p) => {
|
||||
const isConnected =
|
||||
p.oauthClientId != null && connectedClientIds.has(p.oauthClientId)
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={cn(cardClass, "flex flex-col gap-3 p-4")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PluginIcon src={p.icon} alt={p.name} />
|
||||
<p className="flex-1 truncate text-[15px] text-[#FAFAFA]">
|
||||
{p.name}
|
||||
</p>
|
||||
{isConnected && (
|
||||
<span className="rounded-full border border-[#1f4d44] bg-[#0c1c19] px-2 py-0.5 text-[10px] text-[#7fd9c4] uppercase tracking-[0.04em]">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[13px] text-[#8B8B8B] leading-snug">
|
||||
{p.description}
|
||||
</p>
|
||||
<a
|
||||
href={p.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-opacity hover:opacity-80"
|
||||
>
|
||||
Setup guide
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,14 @@ import { Suspense, useState } from "react"
|
|||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
// Mirror of the OAuth plugins in mono's packages/lib/plugins.ts (oauthClientId → display name).
|
||||
const OAUTH_PLUGIN_NAMES: Record<string, string> = {
|
||||
"supermemory-claude-code": "Claude Code",
|
||||
"supermemory-opencode": "OpenCode",
|
||||
"supermemory-openclaw": "OpenClaw",
|
||||
"supermemory-codex": "OpenAI Codex",
|
||||
}
|
||||
|
||||
// Phase 1 is one coarse grant — every approved client gets all of these.
|
||||
const DATA_CAPABILITIES = [
|
||||
"Read and search your saved memories",
|
||||
|
|
@ -53,6 +61,8 @@ function OAuthConsentContent() {
|
|||
organizations?.find((o) => o.id === activeOrgId)?.name ?? null
|
||||
const canSwitchOrg = (organizations?.length ?? 0) > 1
|
||||
const clientId = params.get("client_id") ?? ""
|
||||
const pluginName = clientId ? (OAUTH_PLUGIN_NAMES[clientId] ?? null) : null
|
||||
const appLabel = pluginName ?? "An application"
|
||||
const scopes = (params.get("scope") ?? "").split(/\s+/).filter(Boolean)
|
||||
const accountAccess = accountAccessLabels(scopes)
|
||||
// A valid consent page is reached only via /oauth2/authorize, which appends a
|
||||
|
|
@ -60,6 +70,7 @@ function OAuthConsentContent() {
|
|||
const expSeconds = Number(params.get("exp"))
|
||||
const requestExpired = expSeconds > 0 && expSeconds * 1000 < Date.now()
|
||||
const invalidRequest = !params.get("sig") || requestExpired
|
||||
const busy = submitting !== null || switchingOrgId !== null
|
||||
|
||||
async function changeOrg(orgId: string) {
|
||||
if (!orgId || orgId === activeOrgId) return
|
||||
|
|
@ -166,6 +177,66 @@ function OAuthConsentContent() {
|
|||
)
|
||||
}
|
||||
|
||||
const workspacePicker =
|
||||
canSwitchOrg && session?.user ? (
|
||||
<Popover open={orgMenuOpen} onOpenChange={setOrgMenuOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[8px] border border-[#1E293B] bg-[#0D121A] px-2.5 py-1.5 text-[13px] text-[#FAFAFA] transition-colors hover:bg-[#1E293B] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-3.5 shrink-0 text-[#737373]" />
|
||||
<span className="max-w-[180px] truncate">
|
||||
{activeOrgName ?? "Select workspace"}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 text-[#737373]" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-max min-w-[12rem] max-w-[18rem] rounded-[12px] border-white/10 bg-[#1B1F24] p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{organizations?.map((o) => {
|
||||
const isCurrent = o.id === activeOrgId
|
||||
const isSwitching = switchingOrgId === o.id
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
disabled={isCurrent || switchingOrgId !== null}
|
||||
onClick={() => changeOrg(o.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[8px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent ? "bg-white/5" : "cursor-pointer hover:bg-white/5",
|
||||
"disabled:cursor-default",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-[#737373]" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<p className="truncate text-[13px] text-[#FAFAFA] tracking-[-0.14px]">
|
||||
{o.name}
|
||||
</p>
|
||||
{isCurrent && (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
)}
|
||||
{isSwitching && (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : activeOrgName ? (
|
||||
<span className="flex items-center gap-1.5 text-[13px] text-[#FAFAFA]">
|
||||
<Building2 className="size-3.5 shrink-0 text-[#737373]" />
|
||||
<span className="max-w-[200px] truncate">{activeOrgName}</span>
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-[520px] rounded-[14px] bg-[#14161A] p-6 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
|
|
@ -174,68 +245,9 @@ function OAuthConsentContent() {
|
|||
<div className="flex items-center justify-between gap-3">
|
||||
<LogoFull className="h-6 w-auto shrink-0 text-[#FAFAFA]" />
|
||||
{session?.user && (
|
||||
<div className="flex min-w-0 flex-col items-end">
|
||||
<p className="truncate text-[12px] text-[#FAFAFA]">
|
||||
{session.user.email}
|
||||
</p>
|
||||
{canSwitchOrg ? (
|
||||
<Popover open={orgMenuOpen} onOpenChange={setOrgMenuOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"flex max-w-[200px] items-center gap-1 text-[11px] text-[#737373] transition-colors hover:text-[#FAFAFA]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeOrgName ?? "Select organization"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-max min-w-[12rem] max-w-[18rem] rounded-[12px] border-white/10 bg-[#1B1F24] p-1.5 shadow-[0px_4px_16px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{organizations?.map((o) => {
|
||||
const isCurrent = o.id === activeOrgId
|
||||
const isSwitching = switchingOrgId === o.id
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() => changeOrg(o.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[8px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "cursor-pointer hover:bg-white/5",
|
||||
"disabled:cursor-default",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-[#737373]" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<p className="truncate text-[13px] text-[#FAFAFA] tracking-[-0.14px]">
|
||||
{o.name}
|
||||
</p>
|
||||
{isCurrent && (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
)}
|
||||
{isSwitching && (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : activeOrgName ? (
|
||||
<p className="truncate text-[11px] text-[#737373]">
|
||||
{activeOrgName}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="min-w-0 truncate text-[12px] text-[#737373]">
|
||||
{session.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-px bg-[#1E293B]" />
|
||||
|
|
@ -243,10 +255,10 @@ function OAuthConsentContent() {
|
|||
|
||||
<div className="text-center">
|
||||
<h2 className="font-semibold text-[18px] text-[#FAFAFA]">
|
||||
Authorize access
|
||||
{pluginName ? `Connect ${pluginName}` : "Authorize access"}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[#737373]">
|
||||
An application wants to connect to your Supermemory account.
|
||||
{appLabel} wants to connect to your Supermemory account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -281,13 +293,22 @@ function OAuthConsentContent() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{workspacePicker && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-[12px] bg-[#0D121A] px-4 py-3">
|
||||
<span className="text-[13px] text-[#737373]">
|
||||
{pluginName ? `Connect ${pluginName} to` : "Connect to"}
|
||||
</span>
|
||||
{workspacePicker}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-[13px] text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(false)}
|
||||
disabled={submitting !== null}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex h-11 flex-1 items-center justify-center rounded-[10px]",
|
||||
"border border-[#1E293B] bg-[#0D121A] text-[#FAFAFA]",
|
||||
|
|
@ -302,7 +323,7 @@ function OAuthConsentContent() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => submit(true)}
|
||||
disabled={submitting !== null}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"relative flex h-11 flex-1 items-center justify-center rounded-[10px]",
|
||||
"font-medium text-[14px] text-[#FAFAFA] tracking-[-0.14px]",
|
||||
|
|
@ -322,7 +343,7 @@ function OAuthConsentContent() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{clientId && (
|
||||
{clientId && !pluginName && (
|
||||
<p className="text-center text-[11px] text-[#5C5C5C]">
|
||||
App ID · <code>{shortClientId(clientId)}</code>
|
||||
</p>
|
||||
|
|
|
|||
57
apps/web/lib/oauth-plugins.ts
Normal file
57
apps/web/lib/oauth-plugins.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// OAuth-connectable plugins, mirroring the `authMethod: "oauth"` entries in mono's packages/lib/plugins.ts.
|
||||
// `oauthClientId` is the stable first-party client id (omitted for Cursor — it self-registers via DCR).
|
||||
export interface OAuthPluginInfo {
|
||||
id: string
|
||||
oauthClientId?: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
export const OAUTH_PLUGINS: OAuthPluginInfo[] = [
|
||||
{
|
||||
id: "claude_code",
|
||||
oauthClientId: "supermemory-claude-code",
|
||||
name: "Claude Code",
|
||||
description:
|
||||
"Persistent memory for Claude Code — recalls your coding context, patterns and decisions across sessions.",
|
||||
icon: "/images/plugins/claude-code.svg",
|
||||
docsUrl: "https://supermemory.ai/docs/integrations/claude-code",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
oauthClientId: "supermemory-opencode",
|
||||
name: "OpenCode",
|
||||
description:
|
||||
"Memory layer for OpenCode — semantic search across sessions and automatic context injection.",
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
docsUrl: "https://supermemory.ai/docs/integrations/opencode",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
oauthClientId: "supermemory-openclaw",
|
||||
name: "OpenClaw",
|
||||
description:
|
||||
"Multi-platform memory for OpenClaw — persistence across Telegram, WhatsApp, Discord, Slack and more.",
|
||||
icon: "/images/plugins/openclaw.svg",
|
||||
docsUrl: "https://supermemory.ai/docs/integrations/openclaw",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
oauthClientId: "supermemory-codex",
|
||||
name: "OpenAI Codex",
|
||||
description:
|
||||
"Persistent memory for the OpenAI Codex CLI — recalls coding context and decisions across projects.",
|
||||
icon: "/images/plugins/codex.png",
|
||||
docsUrl: "https://supermemory.ai/docs/integrations/codex",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
description:
|
||||
"Persistent AI memory for Cursor via the Supermemory MCP server. Connect from Cursor's MCP setup.",
|
||||
icon: "/images/plugins/cursor.png",
|
||||
docsUrl: "https://supermemory.ai/docs/supermemory-mcp/setup",
|
||||
},
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue