diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index fd2fd1716..02ba09ab0 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -60,6 +60,7 @@ import { type IntegrationParamValue, } from "@/lib/search-params" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" +import { getToolDocumentSpace } from "@/lib/plugin-space" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -106,7 +107,7 @@ export default function NewPage() { const isMobile = useIsMobile() const { user, session } = useAuth() - const { selectedProject, selectedProjects } = useProject() + const { selectedProject, selectedProjects, setSelectedProject } = useProject() const selectedProjectTag = selectedProjects[0] const { allProjects } = useContainerTags() const dashboardSpaceLabel = useMemo( @@ -408,6 +409,18 @@ export default function NewPage() { [setDocId], ) + const handleOpenToolDocument = useCallback( + (document: DocumentWithMemories, pluginClientId: string) => { + const documentSpace = getToolDocumentSpace(document, pluginClientId) + if (documentSpace) { + setSelectedProject(documentSpace) + } + handleOpenDocument(document) + void setViewMode("list") + }, + [handleOpenDocument, setSelectedProject, setViewMode], + ) + // Separate from handleOpenDocument because the graph view only has a document ID, // not the full document object. The modal will fetch the document via the docId // query param, so there may be a brief loading state (unlike handleOpenDocument @@ -712,6 +725,7 @@ export default function NewPage() { onNavigateToMemories={() => void setViewMode("list")} onNavigateToGraph={() => void setViewMode("graph")} onOpenDocument={handleOpenDocument} + onOpenToolDocument={handleOpenToolDocument} onHighlightsChat={handleHighlightsChat} onHighlightsShowRelated={handleHighlightsShowRelated} onResetHighlights={handleResetHighlights} diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 609c6e9a3..37f61d3d6 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -90,6 +90,17 @@ const PLUGIN_INFO: Record = { ], icon: "/images/plugins/cursor.svg", }, + codex: { + name: "OpenAI Codex", + description: + "Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.", + features: [ + "Auto-recalls relevant context before each prompt", + "Captures coding decisions and patterns automatically", + "Builds persistent user profile across projects", + ], + icon: "/images/plugins/codex.svg", + }, } function getPluginName(client: string): string { diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 38f13ee32..f2e823b31 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -7,12 +7,14 @@ import { $fetch } from "@lib/api" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import { useQuery } from "@tanstack/react-query" import { useRouter } from "next/navigation" +import Image from "next/image" import { ArrowRight, ExternalLink, FileText, Lightbulb, Link2, + Plug, RotateCcw, SearchIcon, Terminal, @@ -37,6 +39,7 @@ import { usePersonalization, type Profession, } from "@/hooks/use-personalization" +import { normalizePluginClientId } from "@/lib/plugin-catalog" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -313,6 +316,468 @@ const PLUGIN_STATIC = [ }, ] as const +// Plugin catalog for tool usage display - maps plugin IDs to display names and icons +const PLUGIN_DISPLAY_CATALOG: Record< + string, + { name: string; icon: string | null; type: "Plugin" } +> = { + claude_code: { + name: "Claude Code", + icon: "/images/plugins/claude-code.svg", + type: "Plugin", + }, + opencode: { + name: "OpenCode", + icon: "/images/plugins/opencode.svg", + type: "Plugin", + }, + openclaw: { + name: "OpenClaw", + icon: "/images/plugins/openclaw.svg", + type: "Plugin", + }, + hermes: { + name: "Hermes", + icon: "/images/plugins/hermes.svg", + type: "Plugin", + }, + codex: { + name: "OpenAI Codex", + icon: "/mcp-supported-tools/codex.png", + type: "Plugin", + }, +} + +// Types for tool usage +interface ToolUsageItem { + id: string + name: string + type: "Plugin" | "MCP" + icon: string | null + lastUsedAt: Date | null + hasBeenUsed: boolean + connectedAt: Date | null + lastDocumentTitle: string | null + lastDocumentId: string | null + lastDocumentPreview: string | null + lastDocument: DocumentWithMemories | null +} + +type ToolUsageApiKey = { + id: string + name: string + createdAt: string + lastRequest: string | null + metadata: string +} + +function toValidDate(value: string | Date | null | undefined): Date | null { + if (!value) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function compactText(value: string): string { + return value.replace(/\s+/g, " ").trim() +} + +function getDocumentText(document: DocumentWithMemories): string { + return typeof document.content === "string" ? document.content : "" +} + +function toMetadataRecord(value: unknown): Record | null { + if (!value) return null + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== "string") return null + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function getDocumentMetadataRecords( + document: DocumentWithMemories, +): Record[] { + const records = [toMetadataRecord(document.metadata)] + + for (const entry of document.memoryEntries ?? []) { + records.push( + toMetadataRecord(entry.metadata), + toMetadataRecord(entry.sourceMetadata), + ) + } + + return records.filter((record): record is Record => !!record) +} + +function hasClaudeCodeContainer(document: DocumentWithMemories): boolean { + const containerTags = + (document as { containerTags?: string[] }).containerTags ?? [] + if (containerTags.some((tag) => tag.startsWith("claudecode_"))) return true + + return (document.memoryEntries ?? []).some((entry) => + entry.spaceContainerTag?.startsWith("claudecode_"), + ) +} + +function getPluginClientFromDocument( + document: DocumentWithMemories, +): string | null { + for (const metadata of getDocumentMetadataRecords(document)) { + const metadataClient = + typeof metadata.sm_client === "string" + ? metadata.sm_client + : typeof metadata.sm_internal_plugin_client === "string" + ? metadata.sm_internal_plugin_client + : typeof metadata.sm_internal_mcp_client_name === "string" + ? metadata.sm_internal_mcp_client_name + : null + if (metadataClient) return normalizePluginClientId(metadataClient) + + if (metadata.sm_source === "claude-code-plugin") return "claude_code" + } + + if (hasClaudeCodeContainer(document)) return "claude_code" + + const content = getDocumentText(document) + const title = document.title ?? "" + if ( + /\[Session\s+[^\]]+\]/i.test(content) || + /\[SAVE:[^\]]+\]/i.test(content) + ) { + return "codex" + } + if (/\bCodex\b/i.test(title)) return "codex" + + return null +} + +function getDocumentPreview(document: DocumentWithMemories): string | null { + const summary = + typeof document.summary === "string" ? compactText(document.summary) : "" + if (summary) return summary + + const content = getDocumentText(document) + if (!content) return document.title?.trim() || null + + const transcriptTurns = Array.from( + content.matchAll( + /\d+\.\s+\[(user|assistant)\]\s*([\s\S]*?)(?=\d+\.\s+\[(?:user|assistant|tool|system)\]|---|\[\/?[A-Za-z]|$)/gi, + ), + ) + .slice(0, 2) + .map((match) => { + const role = match[1] === "assistant" ? "Assistant" : "You" + const text = compactText(match[2] ?? "") + return text ? `${role}: ${text}` : null + }) + .filter(Boolean) + + if (transcriptTurns.length > 0) return transcriptTurns.join(" ยท ") + + const cleaned = compactText( + content + .replace(/\[Session\s+[^\]]+\]/gi, "") + .replace(/\[SAVE:[^\]]+\]/gi, "") + .replace(/\[\/SAVE\]/gi, ""), + ) + return cleaned || document.title?.trim() || null +} + +function getToolDocumentTitle(document: DocumentWithMemories): string { + return document.title?.trim() || "Recent conversation" +} + +// Parse API keys to extract tool usage data +function parseToolUsage( + apiKeys: ToolUsageApiKey[], + recentMcpDocuments: DocumentWithMemories[] = [], +): ToolUsageItem[] { + const toolMap = new Map() + let latestMcpClientName: string | null = null + let latestMcpDocumentAt: Date | null = null + + const latestDocPerPlugin = new Map< + string, + { + title: string + id: string + at: Date + preview: string | null + document: DocumentWithMemories + } + >() + + for (const key of apiKeys) { + let meta: Record = {} + try { + meta = key.metadata ? JSON.parse(key.metadata) : {} + } catch { + continue + } + + const smType = meta.sm_type as string | undefined + const smClient = meta.sm_client as string | undefined + const smSource = meta.sm_source as string | undefined + const smKind = meta.sm_kind as string | undefined + + // Plugin keys + if (smType === "plugin_auth" && smClient) { + const normalizedClient = normalizePluginClientId(smClient) + const catalog = PLUGIN_DISPLAY_CATALOG[normalizedClient] + const existingItem = toolMap.get(`plugin_${normalizedClient}`) + const lastUsed = + toValidDate(key.lastRequest) ?? toValidDate(key.createdAt) + const existingLastUsed = existingItem?.lastUsedAt + + // Keep the most recent usage + if ( + !existingItem || + (lastUsed && + (!existingLastUsed || + lastUsed.getTime() > existingLastUsed.getTime())) + ) { + toolMap.set(`plugin_${normalizedClient}`, { + id: `plugin_${normalizedClient}`, + name: catalog?.name ?? smClient, + type: "Plugin", + icon: catalog?.icon ?? null, + lastUsedAt: lastUsed, + hasBeenUsed: !!key.lastRequest, + connectedAt: toValidDate(key.createdAt), + lastDocumentTitle: null, + lastDocumentId: null, + lastDocumentPreview: null, + lastDocument: null, + }) + } + } + + // MCP keys + if (smSource === "mcp" || smKind === "mcp_oauth_exchange") { + const existingItem = toolMap.get("mcp") + const lastUsed = + toValidDate(key.lastRequest) ?? toValidDate(key.createdAt) + const existingLastUsed = existingItem?.lastUsedAt + + // Keep the most recent usage + if ( + !existingItem || + (lastUsed && + (!existingLastUsed || + lastUsed.getTime() > existingLastUsed.getTime())) + ) { + toolMap.set("mcp", { + id: "mcp", + name: "Supermemory MCP", + type: "MCP", + icon: null, + lastUsedAt: lastUsed, + hasBeenUsed: !!key.lastRequest, + connectedAt: toValidDate(key.createdAt), + lastDocumentTitle: null, + lastDocumentId: null, + lastDocumentPreview: null, + lastDocument: null, + }) + } + } + } + + for (const doc of recentMcpDocuments) { + const metadataRecords = getDocumentMetadataRecords(doc) + const clientName = + metadataRecords + .map((record) => record.sm_internal_mcp_client_name) + .find((value): value is string => typeof value === "string") ?? null + const pluginClient = getPluginClientFromDocument(doc) + const isMcpDocument = + doc.source === "mcp" || + metadataRecords.some( + (record) => record.sm_internal_event_from === "mcp", + ) || + !!clientName + const isPluginDocument = !!pluginClient && pluginClient !== "mcp" + + if (!isMcpDocument && !isPluginDocument) continue + + const createdAt = toValidDate(doc.createdAt) + if ( + isMcpDocument && + clientName && + clientName !== "unknown" && + (!latestMcpDocumentAt || + (createdAt && createdAt.getTime() > latestMcpDocumentAt.getTime())) + ) { + latestMcpClientName = clientName + latestMcpDocumentAt = createdAt + } + + if (isMcpDocument) { + const existingItem = toolMap.get("mcp") + const existingLastUsed = existingItem?.lastUsedAt + if ( + !existingItem || + (createdAt && + (!existingLastUsed || + createdAt.getTime() > existingLastUsed.getTime())) + ) { + toolMap.set("mcp", { + id: "mcp", + name: + clientName && clientName !== "unknown" + ? clientName + : "Supermemory MCP", + type: "MCP", + icon: null, + lastUsedAt: createdAt, + hasBeenUsed: true, + connectedAt: toolMap.get("mcp")?.connectedAt ?? null, + lastDocumentTitle: doc.title?.trim() || null, + lastDocumentId: doc.id ?? null, + lastDocumentPreview: getDocumentPreview(doc), + lastDocument: doc, + }) + } + } + + if (pluginClient && createdAt && doc.id) { + const existing = latestDocPerPlugin.get(pluginClient) + if (!existing || createdAt.getTime() > existing.at.getTime()) { + latestDocPerPlugin.set(pluginClient, { + title: getToolDocumentTitle(doc), + id: doc.id, + at: createdAt, + preview: getDocumentPreview(doc), + document: doc, + }) + } + } + } + + if (latestMcpClientName) { + const existingItem = toolMap.get("mcp") + if (existingItem) { + toolMap.set("mcp", { + ...existingItem, + name: latestMcpClientName, + }) + } + } + + // Attach latest document info to plugin items where available from MCP documents + for (const [, item] of toolMap) { + if (item.type === "Plugin" && !item.lastDocumentTitle) { + const pluginId = item.id.replace(/^plugin_/, "") + const docInfo = latestDocPerPlugin.get(pluginId) + if (docInfo) { + item.lastDocumentTitle = docInfo.title + item.lastDocumentId = docInfo.id + item.lastDocumentPreview = docInfo.preview + item.lastDocument = docInfo.document + } + } + } + + // Sort by lastUsedAt (most recent first), then by hasBeenUsed + return Array.from(toolMap.values()).sort((a, b) => { + // Items that have been used come first + if (a.hasBeenUsed !== b.hasBeenUsed) { + return a.hasBeenUsed ? -1 : 1 + } + // Then sort by recency + if (!a.lastUsedAt && !b.lastUsedAt) return 0 + if (!a.lastUsedAt) return 1 + if (!b.lastUsedAt) return -1 + return b.lastUsedAt.getTime() - a.lastUsedAt.getTime() + }) +} + +// Format relative time for tool usage +function formatToolUsageTime(date: Date | null, hasBeenUsed: boolean): string { + if (!hasBeenUsed) return "Never used" + if (!date) return "Connected" + const diffMs = Date.now() - date.getTime() + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + if (diffMins < 1) return "Just now" + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() +} + +function ToolUsageRecentRow({ + item, + onOpenPlugins, + onOpenToolDocument, +}: { + item: ToolUsageItem + onOpenPlugins: () => void + onOpenToolDocument: ( + document: DocumentWithMemories, + pluginClientId: string, + ) => void +}) { + const pluginClientId = item.id.replace(/^plugin_/, "") + + return ( +
  • + +
  • + ) +} + function RecommendedPluginsCard({ profession, setProfession, @@ -353,7 +818,7 @@ function RecommendedPluginsCard({ return PLUGIN_STATIC.map((p) => ({ ...p, connected: connected[p.id] ?? false, - onClick: onClicks[p.id]!, + onClick: onClicks[p.id] ?? (() => {}), })) }, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations]) @@ -525,7 +990,7 @@ function PluginPromoCard({ return PLUGIN_STATIC.map((p) => ({ ...p, connected: connected[p.id] ?? false, - onClick: onClicks[p.id]!, + onClick: onClicks[p.id] ?? (() => {}), })).filter((p) => !p.connected) }, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations]) @@ -628,9 +1093,10 @@ export function DashboardView({ onOpenSearch, onOpenIntegrations, onOpenPlugins, - onNavigateToMemories, + onNavigateToMemories: _onNavigateToMemories, onNavigateToGraph, onOpenDocument, + onOpenToolDocument, onHighlightsChat, onHighlightsShowRelated, onResetHighlights, @@ -647,12 +1113,16 @@ export function DashboardView({ onNavigateToMemories: () => void onNavigateToGraph: () => void onOpenDocument: (document: DocumentWithMemories) => void + onOpenToolDocument: ( + document: DocumentWithMemories, + pluginClientId: string, + ) => void onHighlightsChat: (highlightContent: string, userReply: string) => void onHighlightsShowRelated: (query: string) => void onResetHighlights: () => void memoryOfDay: MemoryOfDay | null }) { - const { user } = useAuth() + const { user, org } = useAuth() const { effectiveContainerTags } = useProject() const _router = useRouter() const { data: recentsData, isPending: isRecentsLoading } = useQuery({ @@ -672,7 +1142,7 @@ export function DashboardView({ return response.data as DocumentsResponse }, staleTime: 60 * 1000, - enabled: !!user, + enabled: !!user && !!org?.id, }) const { data: connections = [] } = useQuery({ @@ -698,6 +1168,58 @@ export function DashboardView({ enabled: !!user, }) + // Fetch API keys for tool usage tracking + const { data: apiKeysData } = useQuery({ + queryKey: ["api-keys-tool-usage", org?.id], + queryFn: async () => { + const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const res = await fetch(`${API_URL}/v3/auth/keys`, { + credentials: "include", + }) + if (!res.ok) return { keys: [] } + return (await res.json()) as { + keys: Array<{ + id: string + name: string + createdAt: string + lastRequest: string | null + metadata: string + }> + } + }, + staleTime: 5 * 60 * 1000, + enabled: !!user, + }) + + const { data: recentMcpDocumentsData } = useQuery({ + queryKey: ["dashboard-tool-documents", org?.id], + queryFn: async (): Promise => { + const response = await $fetch("@post/documents/documents", { + body: { + page: 1, + limit: 50, + sort: "createdAt", + order: "desc", + }, + disableValidation: true, + }) + if (response.error) throw new Error(response.error?.message) + return response.data as DocumentsResponse + }, + staleTime: 5 * 60 * 1000, + enabled: !!user, + }) + + const toolUsageItems = useMemo( + () => + parseToolUsage( + apiKeysData?.keys ?? [], + recentMcpDocumentsData?.documents ?? [], + ), + [apiKeysData, recentMcpDocumentsData], + ) + const { copy: personalizedCopy, profession, @@ -705,6 +1227,14 @@ export function DashboardView({ } = usePersonalization() const recents = recentsData?.documents ?? [] + const recentToolUsageItems = toolUsageItems + .filter((item) => item.type === "Plugin" && item.lastDocument) + .sort((a, b) => { + const aTime = toValidDate(a.lastDocument?.createdAt)?.getTime() ?? 0 + const bTime = toValidDate(b.lastDocument?.createdAt)?.getTime() ?? 0 + return bTime - aTime + }) + .slice(0, 3) const totalMemories = recentsData?.pagination?.totalItems ?? 0 const hasMcp = mcpData?.previousLogin ?? false const connectedProviders = new Set(connections.map((c) => c.provider)) @@ -895,9 +1425,9 @@ export function DashboardView({ className="space-y-2" >
    -
    +

    - Recently saved + Recents

    @@ -908,7 +1438,7 @@ export function DashboardView({
    -
    +
    {isRecentsLoading ? (
      ))}
    - ) : recents.length > 0 ? ( + ) : recents.length > 0 || recentToolUsageItems.length > 0 ? (
      + {recentToolUsageItems.map((item) => ( + + ))} {recents.map((doc) => { const isLink = !!doc.url return ( diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts index b2c704b5c..f94a4e929 100644 --- a/apps/web/lib/plugin-catalog.ts +++ b/apps/web/lib/plugin-catalog.ts @@ -147,3 +147,9 @@ const SPACE_TO_CATALOG_ID: Record = { export function spacePluginIdToCatalogId(spacePluginId: string): string | null { return SPACE_TO_CATALOG_ID[spacePluginId] ?? null } + +/** Normalize plugin client ids from API keys, metadata, and space ids. */ +export function normalizePluginClientId(client: string): string { + const trimmed = client.trim().toLowerCase() + return spacePluginIdToCatalogId(trimmed) ?? trimmed.replace(/-/g, "_") +} diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts index c2582ec31..b42a2fdc9 100644 --- a/apps/web/lib/plugin-space.ts +++ b/apps/web/lib/plugin-space.ts @@ -1,3 +1,5 @@ +import { normalizePluginClientId } from "@/lib/plugin-catalog" + export type PluginSpaceInfo = { pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp" label: string @@ -131,6 +133,48 @@ export function detectPluginSource( } } +type DocumentSpaceCandidate = { + containerTags?: string[] + memoryEntries?: Array<{ spaceContainerTag?: string | null }> | null +} + +function catalogIdForPluginSpace( + pluginId: PluginSpaceInfo["pluginId"], +): string { + return pluginId.replace(/-/g, "_") +} + +/** Resolve the best container tag for a tool/plugin document. */ +export function getToolDocumentSpace( + document: DocumentSpaceCandidate, + pluginClientId?: string | null, +): string | null { + const containerTags = document.containerTags ?? [] + const memorySpaceTags = (document.memoryEntries ?? []) + .map((entry) => entry.spaceContainerTag) + .filter((tag): tag is string => !!tag) + const allTags = [...containerTags, ...memorySpaceTags] + + if (pluginClientId) { + const normalizedClient = normalizePluginClientId(pluginClientId) + for (const tag of allTags) { + const pluginSpace = detectPluginSpace(tag) + if ( + pluginSpace && + catalogIdForPluginSpace(pluginSpace.pluginId) === normalizedClient + ) { + return tag + } + } + } + + for (const tag of allTags) { + if (detectPluginSpace(tag)) return tag + } + + return allTags[0] ?? null +} + export function detectPluginSpace( containerTag: string, ): PluginSpaceInfo | null {