diff --git a/apps/web/components/document-cards/plugin-preview.tsx b/apps/web/components/document-cards/plugin-preview.tsx
new file mode 100644
index 00000000..e4b90419
--- /dev/null
+++ b/apps/web/components/document-cards/plugin-preview.tsx
@@ -0,0 +1,56 @@
+"use client"
+
+import Image from "next/image"
+import { dmSansClassName } from "@/lib/fonts"
+import { cn } from "@lib/utils"
+import type { ParsedPluginDocument } from "@/lib/plugin-document"
+
+export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
+ return (
+
+
+
+
+ {parsed.pluginIconSrc && (
+
+ )}
+ {parsed.pluginLabel}
+
+
+ {parsed.formatLabel}
+
+
+ {parsed.identifierValue && (
+
+ {parsed.identifierValue}
+
+ )}
+
+
+
+ {parsed.title}
+
+
+ {parsed.preview || parsed.summary}
+
+
+
+ )
+}
diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx
index 7d637c0c..b3df9fe7 100644
--- a/apps/web/components/document-modal/content/index.tsx
+++ b/apps/web/components/document-modal/content/index.tsx
@@ -12,6 +12,8 @@ import { WebPageContent } from "./web-page"
import { TextEditorContent } from "./text-editor-content"
import { GoogleDocViewer } from "./google-doc"
import type { TextEditorProps } from "./text-editor-content"
+import type { ParsedPluginDocument } from "@/lib/plugin-document"
+import { PluginContent } from "./plugin-content"
export type { TextEditorProps }
@@ -33,6 +35,7 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
interface DocumentContentProps {
document: DocumentWithMemories | null
textEditorProps: TextEditorProps
+ pluginDocument?: ParsedPluginDocument | null
}
type ContentType =
@@ -73,10 +76,15 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
export function DocumentContent({
document,
textEditorProps,
+ pluginDocument,
}: DocumentContentProps) {
const contentType = getContentType(document)
- if (!document || !contentType) return null
+ if (!document) return null
+ if (pluginDocument) {
+ return
+ }
+ if (!contentType) return null
switch (contentType) {
case "image":
diff --git a/apps/web/components/document-modal/content/plugin-content.tsx b/apps/web/components/document-modal/content/plugin-content.tsx
new file mode 100644
index 00000000..ef1fdce4
--- /dev/null
+++ b/apps/web/components/document-modal/content/plugin-content.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import { useState } from "react"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import type {
+ ParsedPluginDocument,
+ PluginDocumentMessage,
+ PluginDocumentSection,
+} from "@/lib/plugin-document"
+
+function roleLabel(role: PluginDocumentMessage["role"]): string {
+ switch (role) {
+ case "user":
+ return "User"
+ case "assistant":
+ return "Assistant"
+ case "tool":
+ return "Tool"
+ case "system":
+ return "System"
+ default:
+ return "Message"
+ }
+}
+
+function sectionClasses(tone: PluginDocumentSection["tone"]): string {
+ switch (tone) {
+ case "accent":
+ return "border-[#2261CA33] bg-[#0C1829]"
+ case "muted":
+ return "border-[#252A31] bg-[#11151A]"
+ default:
+ return "border-[#1E232B] bg-[#0F1318]"
+ }
+}
+
+function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
+ return (
+
+
+
+ {parsed.pluginLabel}
+
+
+ {parsed.formatLabel}
+
+ {parsed.identifierLabel && parsed.identifierValue && (
+
+ {parsed.identifierLabel}: {parsed.identifierValue}
+
+ )}
+
+
+ {parsed.title}
+
+
+ {parsed.summary}
+
+
+ )
+}
+
+function ConversationView({ parsed }: { parsed: ParsedPluginDocument }) {
+ return (
+
+ {parsed.messages.map((message) => (
+
+
+ {roleLabel(message.role)}
+
+
+ {message.text}
+
+
+ ))}
+
+ )
+}
+
+function SectionsView({ parsed }: { parsed: ParsedPluginDocument }) {
+ return (
+
+ {parsed.sections.map((section, index) => (
+
+
+ {section.label}
+
+
+ {section.value}
+
+
+ ))}
+
+ )
+}
+
+function RawView({ parsed }: { parsed: ParsedPluginDocument }) {
+ return (
+
+
+ {parsed.rawContent}
+
+
+ )
+}
+
+export function PluginContent({ parsed }: { parsed: ParsedPluginDocument }) {
+ const [mode, setMode] = useState<"structured" | "raw">("structured")
+ const hasMessages = parsed.messages.length > 0
+
+ const hideHeader = parsed.kind === "claude-code-doc"
+
+ return (
+
+ {!hideHeader &&
}
+
+
+
+
+
+
+ {mode === "raw" ? (
+
+ ) : hasMessages ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/web/components/document-modal/index.tsx b/apps/web/components/document-modal/index.tsx
index ba3ac069..6b8e5d37 100644
--- a/apps/web/components/document-modal/index.tsx
+++ b/apps/web/components/document-modal/index.tsx
@@ -8,6 +8,7 @@ import {
Loader2,
Trash2Icon,
CheckIcon,
+ CopyIcon,
} from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
@@ -23,6 +24,8 @@ import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
import { useIsMobile } from "@hooks/use-mobile"
+import { parsePluginDocument } from "@/lib/plugin-document"
+import { PluginDetails } from "./plugin-details"
type DocumentsResponse = z.infer
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -153,6 +156,39 @@ function DeleteButton({
)
}
+function CopySessionIdButton({ sessionId }: { sessionId: string }) {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(sessionId)
+ setCopied(true)
+ toast.success("Copy session id")
+ setTimeout(() => setCopied(false), 1500)
+ } catch {
+ toast.error("Failed to copy session id")
+ }
+ }, [sessionId])
+
+ return (
+
+ )
+}
+
export function DocumentModal({
document: _document,
isOpen,
@@ -168,6 +204,10 @@ export function DocumentModal({
initialEditorString: content ?? "",
}
}, [_document?.content])
+ const pluginDocument = useMemo(
+ () => parsePluginDocument(_document),
+ [_document],
+ )
const [draftContentString, setDraftContentString] =
useState(initialEditorString)
@@ -253,9 +293,14 @@ export function DocumentModal({
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
+ pluginIconSrc={pluginDocument?.pluginIconSrc}
/>
+ {pluginDocument?.kind === "claude-code-doc" &&
+ _document?.customId && (
+
+ )}
- {_document?.summary && (
+ {pluginDocument &&
+ pluginDocument.kind !== "claude-code-doc" &&
+ pluginDocument.kind !== "openclaw-session" && (
+
+ )}
+ {_document && (_document.summary || pluginDocument?.summary) && (
)}
diff --git a/apps/web/components/document-modal/plugin-details.tsx b/apps/web/components/document-modal/plugin-details.tsx
new file mode 100644
index 00000000..29e5630d
--- /dev/null
+++ b/apps/web/components/document-modal/plugin-details.tsx
@@ -0,0 +1,83 @@
+import Image from "next/image"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import type { ParsedPluginDocument } from "@/lib/plugin-document"
+
+function DetailPill({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
{value}
+
+ )
+}
+
+export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
+ return (
+
+
+
+ Details
+
+
+ {parsed.pluginIconSrc && (
+
+ )}
+ {parsed.pluginLabel}
+
+
+
+
+ {parsed.identifierLabel && parsed.identifierValue && (
+
+ )}
+ {parsed.clientLabel && parsed.clientValue && (
+
+ )}
+
+ {parsed.artifacts.length > 0 && (
+
+
+ Outputs
+
+
+ {parsed.artifacts.map((artifact, index) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/apps/web/components/document-modal/title.tsx b/apps/web/components/document-modal/title.tsx
index 9f2b0255..01e4a30c 100644
--- a/apps/web/components/document-modal/title.tsx
+++ b/apps/web/components/document-modal/title.tsx
@@ -1,3 +1,4 @@
+import Image from "next/image"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { DocumentIcon } from "@/components/document-icon"
@@ -15,10 +16,12 @@ export function Title({
title,
documentType,
url,
+ pluginIconSrc,
}: {
title: string | null | undefined
documentType: string
url?: string | null
+ pluginIconSrc?: string
}) {
const extension = getFileExtension(documentType)
@@ -30,7 +33,18 @@ export function Title({
)}
>
-
+ {pluginIconSrc ? (
+
+ ) : (
+
+ )}
{extension && (
{!isMobile && (
-
+ <>
+
+
+ >
)}
{!isMobile && (
@@ -259,7 +265,6 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
diff --git a/apps/web/components/integrations/install-steps.tsx b/apps/web/components/integrations/install-steps.tsx
new file mode 100644
index 00000000..4616b07c
--- /dev/null
+++ b/apps/web/components/integrations/install-steps.tsx
@@ -0,0 +1,166 @@
+"use client"
+
+import { useState, type ReactNode } from "react"
+import { Check, Copy } from "lucide-react"
+import { toast } from "sonner"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import type { InstallStep } from "@/lib/plugin-catalog"
+
+/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */
+export const INSET =
+ "shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
+
+export function PillButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: ReactNode
+ onClick?: () => void
+ disabled?: boolean
+}) {
+ return (
+
+ )
+}
+
+export function CopyButton({ text, label }: { text: string; label?: string }) {
+ const [copied, setCopied] = useState(false)
+ return (
+
+ )
+}
+
+export function CodeBlock({
+ code,
+ copyLabel = "Command",
+ secret,
+}: {
+ code: string
+ copyLabel?: string
+ secret?: boolean
+}) {
+ return (
+
+ )
+}
+
+export function InstallSteps({
+ steps,
+ apiKey,
+}: {
+ steps: InstallStep[]
+ apiKey?: string
+}) {
+ return (
+
+ {steps.map((step, i) => (
+ -
+
+
+ {i + 1}
+
+ {i < steps.length - 1 && (
+
+ )}
+
+
+
+
+
+ {step.title}
+
+ {step.optional && (
+
+ Optional
+
+ )}
+
+ {step.description && (
+
+ {step.description}
+
+ )}
+
+ {step.code && (
+
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx
index 083f13aa..053e7156 100644
--- a/apps/web/components/integrations/plugins-detail.tsx
+++ b/apps/web/components/integrations/plugins-detail.tsx
@@ -8,162 +8,19 @@ import { hasActivePlan } from "@lib/queries"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import * as DialogPrimitive from "@radix-ui/react-dialog"
-import {
- BookOpen,
- Check,
- ChevronDown,
- Copy,
- Loader,
- X,
- Zap,
-} from "lucide-react"
+import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
import Image from "next/image"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
-
-/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */
-const INSET =
- "shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
-
-/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
-const FREE_TIER_PLUGIN_IDS = ["hermes", "codex"]
-function isFreeTierPlugin(pluginId: string): boolean {
- return FREE_TIER_PLUGIN_IDS.includes(pluginId)
-}
-
-interface InstallStep {
- title: string
- description?: string
- code?: string
- copyLabel?: string
- optional?: boolean
- /** Blur the code block until hovered/focused (e.g. it contains the key). */
- secret?: boolean
-}
-
-interface PluginInfo {
- id: string
- name: string
- tagline: string
- icon: string
- docsUrl?: string
- /** Steps shown after a key is minted. The literal `sm_...` is replaced
- * with the freshly generated key when rendered. */
- installSteps?: InstallStep[]
-}
-
-const PLUGIN_CATALOG: Record
= {
- claude_code: {
- id: "claude_code",
- name: "Claude Code",
- tagline: "Remembers your conventions, decisions, and project context",
- icon: "/images/plugins/claude-code.svg",
- docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
- installSteps: [
- {
- title: "Save your API key",
- description:
- "Add this to your shell profile so Claude Code can authenticate. This key is shown only once — save it now.",
- code: 'export SUPERMEMORY_CC_API_KEY="sm_..."',
- copyLabel: "API key",
- secret: true,
- },
- {
- title: "Install the plugin",
- description: "Run these commands inside a Claude Code session:",
- code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory",
- },
- ],
- },
- codex: {
- id: "codex",
- name: "Codex",
- tagline: "Persistent memory for the Codex CLI — free on every plan",
- icon: "/images/plugins/codex.png",
- docsUrl: "https://docs.supermemory.ai/integrations/codex",
- installSteps: [
- {
- title: "Save your API key",
- description:
- "Add this to your shell profile. This key is shown only once — save it now.",
- code: 'export SUPERMEMORY_CODEX_API_KEY="sm_..."',
- copyLabel: "API key",
- secret: true,
- },
- {
- title: "Install the hooks",
- description: "Run this to wire Supermemory into Codex CLI:",
- code: "npx codex-supermemory@latest install",
- },
- ],
- },
- opencode: {
- id: "opencode",
- name: "OpenCode",
- tagline: "Long-term memory for your OpenCode sessions",
- icon: "/images/plugins/opencode.svg",
- docsUrl: "https://docs.supermemory.ai/integrations/opencode",
- installSteps: [
- {
- title: "Save your API key",
- description:
- "Add this to your shell profile. This key is shown only once — save it now.",
- code: 'export SUPERMEMORY_API_KEY="sm_..."',
- copyLabel: "API key",
- secret: true,
- },
- {
- title: "Install the plugin",
- description: "Use --no-tui for non-interactive environments.",
- code: "bunx opencode-supermemory@latest install",
- },
- {
- title: "Verify your config",
- description:
- "Ensure ~/.config/opencode/opencode.jsonc includes the plugin:",
- code: '{\n "plugin": ["opencode-supermemory"]\n}',
- optional: true,
- },
- ],
- },
- openclaw: {
- id: "openclaw",
- name: "OpenClaw",
- tagline: "Cross-platform memory across Telegram, Discord, Slack",
- icon: "/images/plugins/openclaw.svg",
- docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
- installSteps: [
- {
- title: "Install the plugin",
- description: "Run this in your OpenClaw project:",
- code: "openclaw plugins install @supermemory/openclaw-supermemory",
- },
- {
- title: "Configure Supermemory",
- description:
- "Run the setup command and paste your API key when prompted:",
- code: "openclaw supermemory setup",
- },
- ],
- },
- hermes: {
- id: "hermes",
- name: "Hermes",
- tagline: "Persistent memory for the Hermes agent — free on every plan",
- icon: "/images/plugins/hermes.svg",
- docsUrl: "https://docs.supermemory.ai/integrations/hermes",
- installSteps: [
- {
- title: "Run Hermes memory setup",
- description:
- "On the machine where Hermes is deployed, start the memory wizard, choose Supermemory as the provider, and paste your API key when prompted:",
- code: "hermes memory setup",
- },
- ],
- },
-}
+import {
+ PLUGIN_CATALOG,
+ isFreeTierPlugin,
+ type InstallStep,
+ type PluginInfo,
+} from "@/lib/plugin-catalog"
+import { INSET, InstallSteps, PillButton } from "./install-steps"
interface ConnectedPlugin {
id: string
@@ -222,34 +79,6 @@ function ProChip() {
)
}
-function PillButton({
- children,
- onClick,
- disabled,
-}: {
- children: ReactNode
- onClick?: () => void
- disabled?: boolean
-}) {
- return (
-
- )
-}
-
function DocsLink({ href }: { href: string }) {
return (
{
- try {
- await navigator.clipboard.writeText(text)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- toast.success(label ? `${label} copied!` : "Copied!")
- } catch {
- toast.error("Failed to copy")
- }
- }}
- className={cn(
- "flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
- INSET,
- )}
- >
- {copied ? (
-
- ) : (
-
- )}
-
- )
-}
-
-function CodeBlock({
- code,
- copyLabel = "Command",
- secret,
-}: {
- code: string
- copyLabel?: string
- secret?: boolean
-}) {
- return (
-
- )
-}
-
-function InstallSteps({
- steps,
- apiKey,
-}: {
- steps: InstallStep[]
- apiKey: string
-}) {
- return (
-
- {steps.map((step, i) => (
- -
-
-
- {i + 1}
-
- {i < steps.length - 1 && (
-
- )}
-
-
-
-
-
- {step.title}
-
- {step.optional && (
-
- Optional
-
- )}
-
- {step.description && (
-
- {step.description}
-
- )}
-
- {step.code && (
-
- )}
-
-
- ))}
-
- )
-}
-
export function PluginsDetail() {
const { org } = useAuth()
const autumn = useCustomer()
diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx
index d1174b6e..61a84408 100644
--- a/apps/web/components/memories-grid.tsx
+++ b/apps/web/components/memories-grid.tsx
@@ -19,6 +19,11 @@ import { WebsitePreview } from "./document-cards/website-preview"
import { GoogleDocsPreview } from "./document-cards/google-docs-preview"
import { FilePreview } from "./document-cards/file-preview"
import { NotePreview } from "./document-cards/note-preview"
+import {
+ claudeCodeTokenBadge,
+ parsePluginDocument,
+ type ParsedPluginDocument,
+} from "@/lib/plugin-document"
import { YoutubePreview } from "./document-cards/youtube-preview"
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
import { SyncLogoIcon } from "@ui/assets/icons"
@@ -923,6 +928,10 @@ const DocumentCard = memo(
}) => {
const canSelect =
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
+ const pluginDocument = useMemo(
+ () => parsePluginDocument(document),
+ [document],
+ )
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef(null)
const [ogData, setOgData] = useState(null)
@@ -1054,7 +1063,11 @@ const DocumentCard = memo(
{isSelectionMode && isSelected && (
)}
-
+
{!(
document.type === "image" ||
document.type === "notion_doc" ||
@@ -1129,11 +1142,20 @@ const DocumentCard = memo(
"text-[11px] text-[#737373] line-clamp-1",
)}
>
- {new Date(document.createdAt).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
+ {(() => {
+ const badge =
+ pluginDocument?.kind === "claude-code-doc"
+ ? claudeCodeTokenBadge(document)
+ : null
+ const date = new Date(
+ document.createdAt,
+ ).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ return badge ? `${badge} · ${date}` : date
+ })()}