From f5383b66715a12b88b25c2f2e47ca7e8ceeea9b2 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Thu, 4 Jun 2026 12:56:39 +0530 Subject: [PATCH 01/41] fix auth connect link (#1045) --- apps/web/app/auth/connect/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 37f61d3d..eade1f09 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -395,7 +395,7 @@ function AuthConnectContent() { Date: Thu, 4 Jun 2026 01:28:18 -0700 Subject: [PATCH 02/41] ENG-712: Accept JSON uploads in Nova + fix file-size display (#1047) --- apps/web/components/add-document/file.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx index beb31878..a766bcf0 100644 --- a/apps/web/components/add-document/file.tsx +++ b/apps/web/components/add-document/file.tsx @@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook" import { toast } from "sonner" export const FILE_ACCEPT = - "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown" + "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,text/markdown,application/json" export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error" @@ -46,10 +46,12 @@ function isAcceptedFile(file: File): boolean { ".txt", ".md", ".mdx", + ".json", ]) if (allowedExt.has(ext)) return true if (file.type.startsWith("image/")) return true if (file.type === "text/markdown") return true + if (file.type === "application/json") return true return false } @@ -57,6 +59,18 @@ function fileQueueKey(file: File): string { return `${file.name}:${file.size}:${file.lastModified}` } +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ["KB", "MB", "GB", "TB"] + let size = bytes / 1024 + let i = 0 + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}` +} + export function FileContent({ data, onDataChange, @@ -284,7 +298,7 @@ export function FileContent({ {item.file.name}

- {(item.file.size / 1024 / 1024).toFixed(2)} MB + {formatFileSize(item.file.size)}

{item.status === "error" && item.errorMessage ? (

From c606f76f6daecf62bfb00f9f4c1877eb7a6e0d5b Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 5 Jun 2026 03:45:20 +0530 Subject: [PATCH 03/41] Refine Codex and OpenCode OAuth integration status (#1048) Co-authored-by: Mahesh Sanikommu --- apps/web/app/auth/agent-connect/page.tsx | 1 + apps/web/app/auth/connect/page.tsx | 3 +- .../document-cards/plugin-preview.tsx | 8 - .../document-modal/content/plugin-content.tsx | 18 - apps/web/components/document-modal/index.tsx | 9 +- .../document-modal/plugin-details.tsx | 83 --- apps/web/components/integrations-view.tsx | 614 ++++++++++++++---- .../integrations/plugins-detail.tsx | 330 +++++++++- apps/web/lib/plugin-catalog.ts | 19 +- 9 files changed, 814 insertions(+), 271 deletions(-) create mode 100644 apps/web/app/auth/agent-connect/page.tsx delete mode 100644 apps/web/components/document-modal/plugin-details.tsx diff --git a/apps/web/app/auth/agent-connect/page.tsx b/apps/web/app/auth/agent-connect/page.tsx new file mode 100644 index 00000000..86838d41 --- /dev/null +++ b/apps/web/app/auth/agent-connect/page.tsx @@ -0,0 +1 @@ +export { default } from "../connect/page" diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index eade1f09..0f96e52c 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -99,7 +99,7 @@ const PLUGIN_INFO: Record = { "Captures coding decisions and patterns automatically", "Builds persistent user profile across projects", ], - icon: "/images/plugins/codex.svg", + icon: "/images/plugins/codex.png", }, } @@ -199,6 +199,7 @@ function AuthConnectContent() { const redirectUrl = new URL(callback) redirectUrl.searchParams.set("apikey", data.key) + redirectUrl.searchParams.set("api_url", API_URL) window.location.href = redirectUrl.toString() } catch (err) { console.error("Failed to get API key:", err) diff --git a/apps/web/components/document-cards/plugin-preview.tsx b/apps/web/components/document-cards/plugin-preview.tsx index e4b90419..bd40e76c 100644 --- a/apps/web/components/document-cards/plugin-preview.tsx +++ b/apps/web/components/document-cards/plugin-preview.tsx @@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) { )} {parsed.pluginLabel} -

- {parsed.formatLabel} -

- {parsed.identifierValue && ( -

- {parsed.identifierValue} -

- )}

{parsed.pluginLabel} - - {parsed.formatLabel} - - {parsed.identifierLabel && parsed.identifierValue && ( - - {parsed.identifierLabel}: {parsed.identifierValue} - - )}

type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -270,13 +269,8 @@ export function DocumentModal({ ], ) - const hasPluginInsights = - pluginDocument && - pluginDocument.kind !== "claude-code-doc" && - pluginDocument.kind !== "openclaw-session" const hasDocumentInsights = Boolean( - hasPluginInsights || - _document?.summary || + _document?.summary || pluginDocument?.summary || (_document?.memoryEntries && _document.memoryEntries.length > 0), ) @@ -303,7 +297,6 @@ export function DocumentModal({ dmSansClassName(), )} > - {hasPluginInsights && } {_document && (_document.summary || pluginDocument?.summary) && ( -

- {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/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 5a5a7161..a812cce7 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -23,18 +23,20 @@ import { ArrowRight, BookOpen, Check, - ChevronDown, Loader, + Plus, Search, X, Zap, } from "lucide-react" +import { formatRelativeTime } from "@/components/settings/sync-utils" import { CHROME_EXTENSION_URL } from "@lib/constants" import { analytics } from "@/lib/analytics" import Image from "next/image" import { useViewMode } from "@/lib/view-mode-context" import type { ViewParamValue } from "@/lib/search-params" import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs" +import { addDocumentParam } from "@/lib/search-params" import { useCallback, useEffect, @@ -46,11 +48,11 @@ import { import { AnimatePresence, motion } from "motion/react" import { toast } from "sonner" import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" -import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { PLUGIN_CATALOG, FREE_TIER_PLUGIN_IDS, isFreeTierPlugin, + normalizePluginClientId, type InstallStep, } from "@/lib/plugin-catalog" import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" @@ -64,6 +66,57 @@ interface ConnectedKey { keyId: string keyStart: string | null pluginId: string + lastRequest?: string | null + createdAt?: string | null +} + +function toIsoDate(value: string | Date | null | undefined): string | null { + if (!value) return null + const d = value instanceof Date ? value : new Date(value) + if (Number.isNaN(d.getTime())) return null + return d.toISOString() +} + +function parsePluginAuthKeys( + apiKeys: ListedApiKey[], + keyPrefix: (key: ListedApiKey) => string | null, +): { active: ConnectedKey[]; setup: ConnectedKey[] } { + const active: ConnectedKey[] = [] + const setup: ConnectedKey[] = [] + for (const key of apiKeys) { + if (key.enabled === false) continue + if (!key.metadata) continue + try { + const metadata = + typeof key.metadata === "string" + ? (JSON.parse(key.metadata) as { + sm_type?: string + sm_client?: string + }) + : (key.metadata as { sm_type?: string; sm_client?: string }) + if (metadata.sm_type !== "plugin_auth" || !metadata.sm_client) continue + const entry: ConnectedKey = { + keyId: key.id, + keyStart: keyPrefix(key), + pluginId: normalizePluginClientId(metadata.sm_client), + lastRequest: toIsoDate(key.lastRequest), + createdAt: toIsoDate(key.createdAt), + } + if (key.lastRequest) active.push(entry) + else setup.push(entry) + } catch {} + } + return { active, setup } +} + +type ListedApiKey = { + id: string + name?: string | null + createdAt?: string | Date | null + enabled?: boolean + lastRequest?: string | Date | null + metadata: string | Record | null + start?: string | null } type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import" @@ -185,7 +238,7 @@ const catParam = parseAsStringEnum([ const CATEGORY_LABEL: Record = { all: "All", - connected: "Connected", + connected: "Active", plugins: "Plugins", "knowledge-bases": "Knowledge bases", "apps-extensions": "Apps & extensions", @@ -423,7 +476,7 @@ function ProChip() { Pro @@ -487,56 +540,43 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) { ) } -function ConnectedPill({ - keys, - onRevoke, +function ActiveButton({ + count, + lastActive, + onClick, }: { - keys: ConnectedKey[] - onRevoke: (keyId: string) => void + count: number + lastActive?: string | null + onClick: () => void }) { return ( - - - - - e.stopPropagation()} - className={cn( - dmSans125ClassName(), - "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]", - )} - > -

- {keys.length > 1 ? `${keys.length} connections` : "Connection"} -

-
- {keys.map((k) => ( -
- - {k.keyStart ? `${k.keyStart}…` : "API key"} - - onRevoke(k.keyId)} /> -
- ))} -
-
-
+ + ) +} + +function FinishSetupButton({ onClick }: { onClick: () => void }) { + return ( + + + Finish setup + ) } @@ -545,8 +585,7 @@ function ConnectionsCountPill({ count }: { count: number }) { @@ -594,7 +633,7 @@ function ItemCard({
-
+
{leftIndicator} ({ open: false, key: "", pluginId: null }) + const [connectedPluginId, setConnectedPluginId] = useState( + null, + ) + const [finishSetupPluginId, setFinishSetupPluginId] = useState( + null, + ) const { data: pluginsData } = useQuery({ queryFn: async () => { @@ -1030,47 +1075,63 @@ export function IntegrationsView() { enabled: hasProProduct, }) - type ApiKey = { - id: string - metadata: Record | null - start: string | null - } - const { data: apiKeys = [], refetch: refetchKeys } = useQuery({ - queryKey: ["api-keys", org?.id], - queryFn: async () => { - if (!org?.id) return [] - const data = (await authClient.apiKey.list({ - fetchOptions: { query: { metadata: { organizationId: org.id } } }, - })) as unknown as ApiKey[] - return data.filter((key) => key.metadata?.organizationId === org.id) + const { data: apiKeys = [], refetch: refetchKeys } = useQuery( + { + queryKey: ["api-keys", org?.id], + queryFn: async () => { + if (!org?.id) return [] + 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 [] + const data = (await res.json()) as { keys?: ListedApiKey[] } + return data.keys ?? [] + }, + enabled: !!org?.id, + staleTime: 30 * 1000, }, - enabled: !!org?.id, - staleTime: 30 * 1000, - }) + ) - const connectedPlugins = useMemo(() => { - const out: ConnectedKey[] = [] - for (const key of apiKeys) { - if (!key.metadata) continue - try { - const metadata = - typeof key.metadata === "string" - ? (JSON.parse(key.metadata) as { - sm_type?: string - sm_client?: string - }) - : (key.metadata as { sm_type?: string; sm_client?: string }) - if (metadata.sm_type === "plugin_auth" && metadata.sm_client) { - out.push({ - keyId: key.id, - keyStart: key.start ?? null, - pluginId: metadata.sm_client, - }) - } - } catch {} + const keyPrefix = useCallback((key: ListedApiKey): string | null => { + return key.start ?? (key.name?.startsWith("sm_") ? key.name : null) + }, []) + + const { active: activePlugins, setup: setupPlugins } = useMemo( + () => parsePluginAuthKeys(apiKeys, keyPrefix), + [apiKeys, keyPrefix], + ) + + const activePluginById = useMemo(() => { + const map = new Map() + for (const key of activePlugins) { + const existing = map.get(key.pluginId) + if (!existing) { + map.set(key.pluginId, key) + continue + } + const a = key.lastRequest ? new Date(key.lastRequest).getTime() : 0 + const b = existing.lastRequest + ? new Date(existing.lastRequest).getTime() + : 0 + if (a >= b) map.set(key.pluginId, key) } - return out - }, [apiKeys]) + return map + }, [activePlugins]) + + const activeCountByPlugin = useMemo(() => { + const map = new Map() + for (const key of activePlugins) { + map.set(key.pluginId, (map.get(key.pluginId) ?? 0) + 1) + } + return map + }, [activePlugins]) + + const setupPluginIds = useMemo( + () => new Set(setupPlugins.map((k) => k.pluginId)), + [setupPlugins], + ) const connectionsByProvider = useMemo(() => { const out: Record = { @@ -1188,6 +1249,7 @@ export function IntegrationsView() { ) const [category, setCategory] = useQueryState("cat", catParam) + const [, setAddDoc] = useQueryState("add", addDocumentParam) const [mcpClient, setMcpClient] = useQueryState("mcpClient", parseAsString) const [mcpModalOpen, setMcpModalOpen] = useState(false) const [search, setSearch] = useState("") @@ -1218,14 +1280,14 @@ export function IntegrationsView() { const isItemConnected = useCallback( (item: Item): boolean => { if (item.kind === "plugin") { - return connectedPlugins.some((k) => k.pluginId === item.pluginId) + return activePluginById.has(item.pluginId) } if (item.kind === "connector") { return connectionsByProvider[item.provider].length > 0 } return false }, - [connectedPlugins, connectionsByProvider], + [activePluginById, connectionsByProvider], ) const counts = useMemo>( @@ -1251,9 +1313,7 @@ export function IntegrationsView() { } }, [category, counts, setCategory]) - const claudeCodeConnected = connectedPlugins.some( - (k) => k.pluginId === "claude_code", - ) + const claudeCodeConnected = activePluginById.has("claude_code") const claudeCodeNeedsPro = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code") @@ -1307,7 +1367,7 @@ export function IntegrationsView() { ), docsUrl: "https://docs.supermemory.ai/integrations/claude-code", ctaLabel: claudeCodeConnected - ? "Connected" + ? "Active" : claudeCodeNeedsPro ? "Upgrade" : "Connect", @@ -1362,13 +1422,58 @@ export function IntegrationsView() { const renderRight = (item: Item): ReactNode => { switch (item.kind) { case "plugin": { - const keys = connectedPlugins.filter( - (k) => k.pluginId === item.pluginId, - ) + const activeKey = activePluginById.get(item.pluginId) + const activeCount = activeCountByPlugin.get(item.pluginId) ?? 0 const needsProUpgrade = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId) - if (keys.length > 0) { - return + if (activeKey) { + const busy = connectingPlugin === item.pluginId + return ( +
+ { + trackCard(item) + setConnectedPluginId(item.pluginId) + }} + /> + +
+ ) + } + if (setupPluginIds.has(item.pluginId)) { + return ( + { + trackCard(item) + setFinishSetupPluginId(item.pluginId) + }} + /> + ) } if (needsProUpgrade) { return ( @@ -1399,7 +1504,28 @@ export function IntegrationsView() { case "connector": { const count = connectionsByProvider[item.provider].length const needsProUpgrade = !isAutumnLoading && !hasProProduct - if (count > 0) return + if (count > 0) { + return ( +
+ + +
+ ) + } if (needsProUpgrade) { return ( @@ -1498,30 +1624,32 @@ export function IntegrationsView() { /> ) - const renderLeftIndicator = (item: Item): ReactNode => { - if (item.kind === "plugin") { - const isConnected = connectedPlugins.some( - (k) => k.pluginId === item.pluginId, - ) - return isConnected ? ( - - ) : null - } - if (item.kind === "connector") { - const count = connectionsByProvider[item.provider].length - return count > 0 ? ( - - ) : null - } + const renderLeftIndicator = (_item: Item): ReactNode => { return null } const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] : undefined + const connectedDialogPlugin = connectedPluginId + ? PLUGIN_CATALOG[connectedPluginId] + : undefined + const connectedDialogKeys = connectedPluginId + ? activePlugins.filter((key) => key.pluginId === connectedPluginId) + : [] + const connectedDialogNeedsPro = + !!connectedPluginId && + !isAutumnLoading && + !hasProProduct && + !isFreeTierPlugin(connectedPluginId) + const finishSetupPlugin = finishSetupPluginId + ? PLUGIN_CATALOG[finishSetupPluginId] + : undefined + const finishSetupSteps = finishSetupPlugin?.installSteps ?? [] const pluginSteps = dialogPlugin?.installSteps ?? [] const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) - const setupSteps: InstallStep[] = stepsEmbedKey + const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth + const setupSteps: InstallStep[] = skipGeneratedKeyStep ? pluginSteps : [ { @@ -1538,9 +1666,7 @@ export function IntegrationsView() { return (
- {!q && category !== "connected" && ( - - )} + {!q && }
- ) : q ? ( + ) : q || category !== "all" ? (
{visibleItems.map((item) => renderItemCard(item))}
@@ -1699,6 +1825,246 @@ export function IntegrationsView() { + { + if (!open) setConnectedPluginId(null) + }} + > + + + {connectedDialogPlugin?.name ?? "Plugin"} connection + +
+
+

+ {connectedDialogKeys.length > 1 + ? `${connectedDialogKeys.length} connections` + : "Connection"} +

+ {connectedDialogKeys.length > 0 ? ( +
+ {connectedDialogKeys.map((key) => ( +
+ + {key.keyStart ? `${key.keyStart}...` : "API key"} + + void handleRevokePluginKey(key.keyId)} + /> +
+ ))} +
+ ) : ( +

+ No active connection was found. +

+ )} +

+ Connect this plugin to another agent to run them in parallel. +

+
+
+ {connectedDialogNeedsPro ? ( + + Upgrade to connect + more + + ) : ( + { + if (!connectedPluginId) return + const pluginId = connectedPluginId + setConnectedPluginId(null) + createPluginKeyMutation.mutate(pluginId) + }} + disabled={!!connectingPlugin} + > + {connectingPlugin === connectedPluginId ? ( + <> + Connecting… + + ) : ( + <> + Connect another + + )} + + )} + + + +
+ + + + { + if (!open) setFinishSetupPluginId(null) + }} + > + + + Finish setup {finishSetupPlugin?.name ?? "plugin"} + +
+ {finishSetupPlugin && ( + + {finishSetupPlugin.name} + + )} +
+

+ Finish setup {finishSetupPlugin?.name ?? "plugin"} +

+

+ Complete install in the tool — this card turns active after the + first API call. +

+
+ + + +
+
+
+ {finishSetupSteps.length > 0 ? ( + + ) : ( +

+ Open {finishSetupPlugin?.name ?? "the plugin"} and finish + authentication, then send a test memory. +

+ )} +
+
+
+ + + +
+
+
+ { diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index 053e7156..0bd044ae 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -8,7 +8,15 @@ 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, Loader, X, Zap } from "lucide-react" +import { + BookOpen, + Check, + ChevronDown, + ExternalLink, + Loader, + X, + Zap, +} from "lucide-react" import Image from "next/image" import { type ReactNode, useEffect, useMemo, useState } from "react" import { toast } from "sonner" @@ -17,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { PLUGIN_CATALOG, isFreeTierPlugin, + normalizePluginClientId, type InstallStep, type PluginInfo, } from "@/lib/plugin-catalog" @@ -27,9 +36,20 @@ interface ConnectedPlugin { keyId: string pluginId: string createdAt: string + lastRequest?: string | null keyStart?: string | null } +type ListedApiKey = { + id: string + name?: string | null + createdAt: string + enabled?: boolean + lastRequest: string | null + metadata: string | Record | null + start?: string | null +} + function SectionHeader({ children }: { children: ReactNode }) { return (

void }) { ) } -function ConnectedPill({ +function toDate(value: string | Date | null | undefined): Date | null { + if (!value) return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function formatRelativeTime(value: string | Date | null | undefined): string { + const date = toDate(value) + if (!date) return "Never" + const absMs = Math.abs(Date.now() - date.getTime()) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (absMs < minute) return "Just now" + if (absMs < hour) return `${Math.round(absMs / minute)}m ago` + if (absMs < day) return `${Math.round(absMs / hour)}h ago` + return `${Math.round(absMs / day)}d ago` +} + +function formatDate(value: string | Date | null | undefined): string { + const date = toDate(value) + if (!date) return "Unknown" + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function maskKey(start: string | null | undefined): string { + if (!start) return "sm_********" + return `${start}********` +} + +function keyPrefix(key: ListedApiKey): string | null { + return key.start ?? (key.name?.startsWith("sm_") ? key.name : null) +} + +function DetailStat({ label, value }: { label: string; value: string }) { + return ( +

+

+ {label} +

+

+ {value} +

+
+ ) +} + +function ActivePill({ + plugin, connectedKeys, onRevoke, }: { + plugin: PluginInfo connectedKeys: ConnectedPlugin[] onRevoke: (keyId: string) => void }) { + const primaryKey = connectedKeys[0] return ( @@ -140,7 +224,7 @@ function ConnectedPill({ )} > - Connected + ACTIVE @@ -148,13 +232,67 @@ function ConnectedPill({ align="end" className={cn( dmSans125ClassName(), - "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]", + "w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]", )} > +
+ +
+

+ {plugin.name} +

+

Active

+
+ +
+ +
+ + + +
+ +
+ {plugin.docsUrl && ( + + Docs + + )} + {plugin.githubUrl && ( + + GitHub + + )} + + Connected {formatDate(primaryKey?.createdAt)} + +
+

{connectedKeys.length > 1 @@ -188,22 +326,26 @@ function PluginRow({ plugin, pluginId, connectedKeys, + needsSetup, needsProUpgrade, isConnecting, actionsDisabled, onConnect, onUpgrade, onRevoke, + onFinishSetup, }: { plugin: PluginInfo pluginId: string connectedKeys: ConnectedPlugin[] + needsSetup: boolean needsProUpgrade: boolean isConnecting: boolean actionsDisabled: boolean onConnect: (id: string) => void onUpgrade: () => void onRevoke: (keyId: string) => void + onFinishSetup: (id: string) => void }) { const isConnected = connectedKeys.length > 0 return ( @@ -240,7 +382,16 @@ function PluginRow({

{plugin.docsUrl && } {isConnected ? ( - + + ) : needsSetup ? ( + onFinishSetup(pluginId)}> + + Finish setup + ) : needsProUpgrade ? ( Upgrade @@ -307,6 +458,9 @@ export function PluginsDetail() { const queryClient = useQueryClient() const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) + const [finishSetupPluginId, setFinishSetupPluginId] = useState( + null, + ) const [newKey, setNewKey] = useState<{ open: boolean key: string @@ -332,21 +486,51 @@ export function PluginsDetail() { queryKey: ["plugins"], }) - const { data: apiKeys = [], refetch: refetchKeys } = useQuery({ - enabled: !!org?.id, - queryFn: async () => { - if (!org?.id) return [] - const data = await authClient.apiKey.list({ - fetchOptions: { query: { metadata: { organizationId: org.id } } }, - }) - return data.filter((key) => key.metadata?.organizationId === org.id) + const { data: apiKeys = [], refetch: refetchKeys } = useQuery( + { + enabled: !!org?.id, + queryFn: async () => { + if (!org?.id) return [] + 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 [] + const data = (await res.json()) as { keys?: ListedApiKey[] } + return data.keys ?? [] + }, + queryKey: ["api-keys", org?.id], }, - queryKey: ["api-keys", org?.id], - }) + ) + + const setupPluginIds = useMemo(() => { + const ids = new Set() + for (const key of apiKeys) { + if (key.enabled === false) continue + if (key.lastRequest) continue + if (!key.metadata) continue + try { + const metadata = + typeof key.metadata === "string" + ? (JSON.parse(key.metadata) as { + sm_type?: string + sm_client?: string + }) + : (key.metadata as { sm_type?: string; sm_client?: string }) + if (metadata.sm_type === "plugin_auth" && metadata.sm_client) { + ids.add(normalizePluginClientId(metadata.sm_client)) + } + } catch {} + } + return ids + }, [apiKeys]) const connectedPlugins = useMemo(() => { const plugins: ConnectedPlugin[] = [] for (const key of apiKeys) { + if (key.enabled === false) continue + if (!key.lastRequest) continue if (!key.metadata) continue try { const metadata = @@ -358,12 +542,15 @@ export function PluginsDetail() { : (key.metadata as { sm_type?: string; sm_client?: string }) if (metadata.sm_type === "plugin_auth" && metadata.sm_client) { + const createdAt = toDate(key.createdAt)?.toISOString() + const lastRequest = toDate(key.lastRequest)?.toISOString() plugins.push({ id: key.id, keyId: key.id, - pluginId: metadata.sm_client, - createdAt: key.createdAt.toISOString(), - keyStart: key.start ?? null, + pluginId: normalizePluginClientId(metadata.sm_client), + createdAt: createdAt ?? new Date().toISOString(), + lastRequest: lastRequest ?? null, + keyStart: keyPrefix(key), }) } } catch {} @@ -463,13 +650,18 @@ export function PluginsDetail() { const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] : undefined + const finishSetupPlugin = finishSetupPluginId + ? PLUGIN_CATALOG[finishSetupPluginId] + : undefined const pluginSteps = dialogPlugin?.installSteps ?? [] // If a step already embeds the key (an `export …="sm_…"` line), don't also // show the bare key in its own step — that's the repetition to avoid. - // Otherwise (wizard-style installs) lead with a copy-the-key step. + // Otherwise (wizard-style installs) lead with a copy-the-key step, unless + // the plugin performs browser OAuth itself. const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) - const setupSteps: InstallStep[] = stepsEmbedKey + const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth + const setupSteps: InstallStep[] = skipGeneratedKeyStep ? pluginSteps : [ { @@ -512,10 +704,15 @@ export function PluginsDetail() { connectedKeys={connectedPlugins.filter( (p) => p.pluginId === pluginId, )} + needsSetup={ + !connectedPluginIds.has(pluginId) && + setupPluginIds.has(pluginId) + } needsProUpgrade={needsProUpgrade} isConnecting={connectingPlugin === pluginId} actionsDisabled={!!connectingPlugin} onConnect={(id) => createPluginKeyMutation.mutate(id)} + onFinishSetup={(id) => setFinishSetupPluginId(id)} onUpgrade={handleUpgrade} onRevoke={handleRevoke} /> @@ -638,6 +835,97 @@ export function PluginsDetail() {
+ + { + if (!open) setFinishSetupPluginId(null) + }} + > + + + Finish setup {finishSetupPlugin?.name ?? "plugin"} + +
+ {finishSetupPlugin && ( + + )} +
+

+ Finish setup {finishSetupPlugin?.name ?? "plugin"} +

+

+ Complete install in the tool — status becomes active after the + first API call. +

+
+ + + +
+
+
+ {finishSetupPlugin?.installSteps?.length ? ( + + ) : ( +

+ Open {finishSetupPlugin?.name ?? "the plugin"} and finish + authentication. +

+ )} +
+
+
+ + + +
+
+
) } diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts index f94a4e92..9f17d156 100644 --- a/apps/web/lib/plugin-catalog.ts +++ b/apps/web/lib/plugin-catalog.ts @@ -14,6 +14,8 @@ export interface PluginInfo { tagline: string icon: string docsUrl?: string + githubUrl?: string + usesOAuth?: boolean /** Steps shown after a key is minted. The literal `sm_...` is replaced * with the freshly generated key when rendered. */ installSteps?: InstallStep[] @@ -55,6 +57,7 @@ export const PLUGIN_CATALOG: Record = { tagline: "Persistent memory for the Codex CLI — free on every plan", icon: "/images/plugins/codex.png", docsUrl: "https://docs.supermemory.ai/integrations/codex", + githubUrl: "https://github.com/supermemoryai/codex-supermemory", installSteps: [ { title: "Save your API key", @@ -77,20 +80,20 @@ export const PLUGIN_CATALOG: Record = { tagline: "Long-term memory for your OpenCode sessions", icon: "/images/plugins/opencode.svg", docsUrl: "https://docs.supermemory.ai/integrations/opencode", + githubUrl: "https://github.com/supermemoryai/opencode-supermemory", + usesOAuth: true, 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: "Authenticate OpenCode", + description: + "Run the browser auth flow from the machine where OpenCode runs:", + code: "bunx opencode-supermemory@latest login", + }, { title: "Verify your config", description: From c5bf8ff1f75fc5de22d03ca4be2e03630daa9f77 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Fri, 5 Jun 2026 09:59:28 +0530 Subject: [PATCH 04/41] Fix plugin memory parser attribution (#1049) --- apps/web/lib/plugin-catalog.ts | 1 + apps/web/lib/plugin-document.ts | 239 ++++++++++++++++++++++++++------ apps/web/lib/plugin-space.ts | 13 +- 3 files changed, 208 insertions(+), 45 deletions(-) diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts index 9f17d156..0c69d57b 100644 --- a/apps/web/lib/plugin-catalog.ts +++ b/apps/web/lib/plugin-catalog.ts @@ -145,6 +145,7 @@ const SPACE_TO_CATALOG_ID: Record = { codex: "codex", opencode: "opencode", openclaw: "openclaw", + hermes: "hermes", } export function spacePluginIdToCatalogId(spacePluginId: string): string | null { diff --git a/apps/web/lib/plugin-document.ts b/apps/web/lib/plugin-document.ts index c5f79db3..9ee34ef4 100644 --- a/apps/web/lib/plugin-document.ts +++ b/apps/web/lib/plugin-document.ts @@ -1,6 +1,11 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" -import { detectPluginSource, pluginIconByLabel } from "@/lib/plugin-space" +import { + detectPluginSource, + detectPluginSpace, + pluginIconByLabel, + type PluginSpaceInfo, +} from "@/lib/plugin-space" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -8,10 +13,19 @@ type DocumentWithMemories = DocumentsResponse["documents"][0] export type PluginDocumentKind = | "codex-session" | "codex-save" + | "plugin-session" + | "plugin-save" | "amp-thread" | "openclaw-session" | "claude-code-doc" +type PluginIdentity = { + pluginId: PluginSpaceInfo["pluginId"] + label: string + iconSrc: string | null + projectId?: string +} + export interface PluginArtifact { label: string value: string @@ -76,11 +90,108 @@ function formatClientName(value: string | null | undefined): string | null { if (lower === "claude code") return "Claude Code" if (lower === "opencode") return "OpenCode" if (lower === "openclaw") return "OpenClaw" + if (lower === "hermes") return "Hermes" if (lower === "amp") return "Amp" return normalized.replace(/\b\w/g, (match) => match.toUpperCase()) } +function pluginIdentityFromSource( + value: string | null | undefined, +): PluginIdentity | null { + if (!value) return null + + const normalized = value.trim().toLowerCase().replace(/_/g, "-") + if (!normalized) return null + + switch (normalized) { + case "claude-code": + case "claude-code-plugin": + return { + pluginId: "claude-code", + label: "Claude Code", + iconSrc: "/images/plugins/claude-code.svg", + } + case "codex": + return { + pluginId: "codex", + label: "Codex", + iconSrc: "/images/plugins/codex.png", + } + case "openclaw": + return { + pluginId: "openclaw", + label: "OpenClaw", + iconSrc: "/images/plugins/openclaw.svg", + } + case "hermes": + return { + pluginId: "hermes", + label: "Hermes", + iconSrc: "/images/plugins/hermes.svg", + } + case "opencode": + return { + pluginId: "opencode", + label: "OpenCode", + iconSrc: "/images/plugins/opencode.svg", + } + case "amp": + return { + pluginId: "amp", + label: "Amp", + iconSrc: null, + } + default: + return null + } +} + +function pluginIdentityFromSpace( + document: DocumentWithMemories, +): PluginIdentity | null { + const documentWithTags = document as DocumentWithMemories & { + containerTags?: unknown + } + const containerTags = Array.isArray(documentWithTags.containerTags) + ? documentWithTags.containerTags.filter( + (tag): tag is string => typeof tag === "string" && !!tag, + ) + : [] + const memorySpaceTags = Array.isArray(document.memoryEntries) + ? document.memoryEntries + .map((entry) => entry?.spaceContainerTag) + .filter((tag): tag is string => typeof tag === "string" && !!tag) + : [] + + for (const tag of [...containerTags, ...memorySpaceTags]) { + const plugin = detectPluginSpace(tag) + if (plugin) return plugin + } + + return null +} + +function getDocumentPluginIdentity( + document: DocumentWithMemories, + metadata: Record, +): PluginIdentity | null { + const sourceCandidates = [ + typeof document.source === "string" ? document.source : null, + typeof metadata.sm_source === "string" ? metadata.sm_source : null, + typeof metadata.sm_internal_mcp_client_name === "string" + ? metadata.sm_internal_mcp_client_name + : null, + ] + + for (const source of sourceCandidates) { + const plugin = pluginIdentityFromSource(source) + if (plugin) return plugin + } + + return pluginIdentityFromSpace(document) +} + function extractArtifacts(text: string): { cleanText: string artifacts: PluginArtifact[] @@ -180,7 +291,12 @@ function takePreview(text: string, maxLength = 180): string { return `${normalized.slice(0, maxLength - 1).trimEnd()}...` } -function parseSaveSections(content: string): ParsedPluginDocument | null { +function parseSaveSections( + content: string, + plugin: PluginIdentity | null, +): ParsedPluginDocument | null { + if (!plugin) return null + const match = content.match(/\[SAVE:([^\]]+)\]([\s\S]*?)\[\/SAVE\]/i) if (!match) return null @@ -238,12 +354,13 @@ function parseSaveSections(content: string): ParsedPluginDocument | null { "Saved project note" return { - kind: "codex-save", - pluginLabel: "Codex", + kind: plugin.pluginId === "codex" ? "codex-save" : "plugin-save", + pluginLabel: plugin.label, + pluginIconSrc: plugin.iconSrc ?? undefined, formatLabel: "Saved note", title: "Saved memory note", preview: takePreview( - sections[0]?.value ?? "Saved project knowledge from Codex", + sections[0]?.value ?? `Saved project knowledge from ${plugin.label}`, 140, ), summary: takePreview(summary, 220), @@ -259,9 +376,10 @@ function parseSaveSections(content: string): ParsedPluginDocument | null { function parseSessionTranscript( content: string, config: { - kind: "codex-session" | "amp-thread" + kind: "codex-session" | "amp-thread" | "plugin-session" headerLabel: "Session" | "Amp thread" pluginLabel: string + pluginIconSrc?: string | null formatLabel: string }, ): ParsedPluginDocument | null { @@ -289,6 +407,7 @@ function parseSessionTranscript( return { kind: config.kind, pluginLabel: config.pluginLabel, + pluginIconSrc: config.pluginIconSrc ?? undefined, formatLabel: config.formatLabel, title: `${config.pluginLabel} conversation`, preview: takePreview(previewSource, 140), @@ -302,7 +421,17 @@ function parseSessionTranscript( } } -function parseOpenClawTranscript(content: string): ParsedPluginDocument | null { +function parseRoleBlockTranscript( + content: string, + plugin: PluginIdentity | null, +): ParsedPluginDocument | null { + if ( + !plugin || + (plugin.pluginId !== "openclaw" && plugin.pluginId !== "hermes") + ) { + return null + } + const { messages, artifacts } = parseRoleBlockMessages(content) if (messages.length === 0) return null @@ -312,12 +441,14 @@ function parseOpenClawTranscript(content: string): ParsedPluginDocument | null { "Conversation" return { - kind: "openclaw-session", - pluginLabel: "OpenClaw", + kind: + plugin.pluginId === "openclaw" ? "openclaw-session" : "plugin-session", + pluginLabel: plugin.label, + pluginIconSrc: plugin.iconSrc ?? undefined, formatLabel: "Conversation", - title: "OpenClaw conversation", + title: `${plugin.label} conversation`, preview: takePreview(previewSource, 140), - summary: `${messages.length} message${messages.length === 1 ? "" : "s"} captured from OpenClaw.`, + summary: `${messages.length} message${messages.length === 1 ? "" : "s"} captured from ${plugin.label}.`, artifacts, messages, sections: [], @@ -417,7 +548,8 @@ export function claudeCodeTokenBadge( document: DocumentWithMemories, ): string | null { const meta = (document.metadata ?? {}) as Record - if (getDocumentPluginSource(document, meta) !== "claude-code-plugin") { + const plugin = getDocumentPluginIdentity(document, meta) + if (plugin?.pluginId !== "claude-code") { return null } const tokens = document.tokenCount @@ -431,10 +563,26 @@ function parseClaudeCodeByMetadata( ): ParsedPluginDocument | null { const docSource = getDocumentPluginSource(document, metadata) let source = detectPluginSource(metadata, docSource) + const plugin = getDocumentPluginIdentity(document, metadata) const rawContent = typeof document.content === "string" ? document.content : "" + if (!source && plugin?.pluginId === "claude-code") { + const project = + typeof metadata.project === "string" && metadata.project.trim() + ? metadata.project.trim() + : plugin.projectId + source = { + pluginId: "claude-code", + label: "Claude Code", + iconSrc: "/images/plugins/claude-code.svg", + projectName: project, + formatLabel: "Session", + type: "session_turn", + } + } + if (!source) { if (CLAUDE_CODE_CONTENT_RE.test(rawContent)) { const md = metadata ?? {} @@ -488,10 +636,7 @@ export function parsePluginDocument( if (!document) return null const metadata = (document.metadata ?? {}) as Record - - if (getDocumentPluginSource(document, metadata) === "claude-code-plugin") { - return withIcon(parseClaudeCodeByMetadata(document, metadata)) - } + const plugin = getDocumentPluginIdentity(document, metadata) const content = normalizeContent( typeof document.content === "string" ? document.content : "", @@ -504,39 +649,49 @@ export function parsePluginDocument( ) if (content) { - const codexSave = parseSaveSections(content) - if (codexSave) { + const saveDoc = parseSaveSections(content, plugin) + if (saveDoc) { if (clientName) { - codexSave.clientLabel = "Client" - codexSave.clientValue = clientName + saveDoc.clientLabel = "Client" + saveDoc.clientValue = clientName } - return withIcon(codexSave) + return withIcon(saveDoc) } - const codexSession = parseSessionTranscript(content, { - kind: "codex-session", - headerLabel: "Session", - pluginLabel: "Codex", - formatLabel: "Conversation", - }) - if (codexSession) { - if (clientName) { - codexSession.clientLabel = "Client" - codexSession.clientValue = clientName - } - return withIcon(codexSession) + if (plugin?.pluginId === "claude-code") { + return withIcon(parseClaudeCodeByMetadata(document, metadata)) } - const ampThread = parseSessionTranscript(content, { - kind: "amp-thread", - headerLabel: "Amp thread", - pluginLabel: "Amp", - formatLabel: "Conversation", - }) - if (ampThread) return withIcon(ampThread) + if (plugin?.pluginId === "codex") { + const codexSession = parseSessionTranscript(content, { + kind: "codex-session", + headerLabel: "Session", + pluginLabel: plugin.label, + pluginIconSrc: plugin.iconSrc, + formatLabel: "Conversation", + }) + if (codexSession) { + if (clientName) { + codexSession.clientLabel = "Client" + codexSession.clientValue = clientName + } + return withIcon(codexSession) + } + } - const openClawSession = parseOpenClawTranscript(content) - if (openClawSession) return withIcon(openClawSession) + if (plugin?.pluginId === "amp") { + const ampThread = parseSessionTranscript(content, { + kind: "amp-thread", + headerLabel: "Amp thread", + pluginLabel: plugin.label, + pluginIconSrc: plugin.iconSrc, + formatLabel: "Conversation", + }) + if (ampThread) return withIcon(ampThread) + } + + const roleBlockSession = parseRoleBlockTranscript(content, plugin) + if (roleBlockSession) return withIcon(roleBlockSession) } return withIcon(parseClaudeCodeByMetadata(document, metadata)) diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts index b42a2fdc..a4e4d7df 100644 --- a/apps/web/lib/plugin-space.ts +++ b/apps/web/lib/plugin-space.ts @@ -1,7 +1,7 @@ import { normalizePluginClientId } from "@/lib/plugin-catalog" export type PluginSpaceInfo = { - pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp" + pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp" | "hermes" label: string iconSrc: string | null projectId?: string @@ -45,6 +45,12 @@ const PLUGINS: PluginDef[] = [ iconSrc: null, prefixes: ["amp"], }, + { + id: "hermes", + label: "Hermes", + iconSrc: "/images/plugins/hermes.svg", + prefixes: ["hermes"], + }, ] function parsePluginRest(rest: string): { projectId?: string } { @@ -65,6 +71,7 @@ const PLUGIN_ICON_BY_LABEL: Record = { OpenClaw: "/images/plugins/openclaw.svg", OpenCode: "/images/plugins/opencode.svg", Codex: "/images/plugins/codex.png", + Hermes: "/images/plugins/hermes.svg", } export function pluginIconByLabel( @@ -112,8 +119,8 @@ export function detectPluginSource( metadata && typeof metadata.sm_source === "string" ? metadata.sm_source : null - const source = documentSource ?? sourceFromMeta - if (source !== "claude-code-plugin") return null + const source = (documentSource ?? sourceFromMeta)?.trim().toLowerCase() + if (source !== "claude-code-plugin" && source !== "claude-code") return null const md = metadata ?? {} const project = From 361064f231c4a5325d3105db89bb38d5b3422c5f Mon Sep 17 00:00:00 2001 From: Siddhesh Gawade Date: Fri, 5 Jun 2026 17:26:28 +0530 Subject: [PATCH 05/41] fix(pipecat-sdk): handle null profile object safely (#1041) --- .../src/supermemory_pipecat/service.py | 11 +- .../tests/test_empty_profile.py | 123 ++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/pipecat-sdk-python/tests/test_empty_profile.py diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py index 2aef866b..eb9d5fb6 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/service.py @@ -146,14 +146,17 @@ class SupermemoryPipecatService(FrameProcessor): response = await self._supermemory_client.profile(**kwargs) + profile = getattr(response, "profile", None) + search_results_response = getattr(response, "search_results", None) + search_results = [] - if response.search_results and response.search_results.results: - search_results = response.search_results.results + if search_results_response and search_results_response.results: + search_results = search_results_response.results return { "profile": { - "static": response.profile.static, - "dynamic": response.profile.dynamic, + "static": profile.static if profile is not None else [], + "dynamic": profile.dynamic if profile is not None else [], }, "search_results": search_results, } diff --git a/packages/pipecat-sdk-python/tests/test_empty_profile.py b/packages/pipecat-sdk-python/tests/test_empty_profile.py new file mode 100644 index 00000000..ec3ccd26 --- /dev/null +++ b/packages/pipecat-sdk-python/tests/test_empty_profile.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import sys +import types +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + + +def _install_test_stubs() -> None: + if "loguru" not in sys.modules: + loguru_module = types.ModuleType("loguru") + + class _Logger: + def warning(self, *_args, **_kwargs): + return None + + def error(self, *_args, **_kwargs): + return None + + loguru_module.logger = _Logger() + sys.modules["loguru"] = loguru_module + + if "pydantic" not in sys.modules: + pydantic_module = types.ModuleType("pydantic") + + class BaseModel: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def Field(*, default=None, **_kwargs): + return default + + pydantic_module.BaseModel = BaseModel + pydantic_module.Field = Field + sys.modules["pydantic"] = pydantic_module + + if "pipecat" not in sys.modules: + pipecat_module = types.ModuleType("pipecat") + sys.modules["pipecat"] = pipecat_module + + frames_module = types.ModuleType("pipecat.frames.frames") + + class Frame: # pragma: no cover - import stub + pass + + class InputAudioRawFrame: # pragma: no cover - import stub + pass + + class LLMContextFrame: # pragma: no cover - import stub + pass + + class LLMMessagesFrame: # pragma: no cover - import stub + pass + + frames_module.Frame = Frame + frames_module.InputAudioRawFrame = InputAudioRawFrame + frames_module.LLMContextFrame = LLMContextFrame + frames_module.LLMMessagesFrame = LLMMessagesFrame + + llm_context_module = types.ModuleType("pipecat.processors.aggregators.llm_context") + + class LLMContext: # pragma: no cover - import stub + pass + + llm_context_module.LLMContext = LLMContext + + openai_context_module = types.ModuleType( + "pipecat.processors.aggregators.openai_llm_context" + ) + + class OpenAILLMContextFrame: # pragma: no cover - import stub + pass + + openai_context_module.OpenAILLMContextFrame = OpenAILLMContextFrame + + frame_processor_module = types.ModuleType("pipecat.processors.frame_processor") + + class FrameDirection: # pragma: no cover - import stub + pass + + class FrameProcessor: + def __init__(self, *args, **kwargs): + return None + + frame_processor_module.FrameDirection = FrameDirection + frame_processor_module.FrameProcessor = FrameProcessor + + sys.modules["pipecat.frames.frames"] = frames_module + sys.modules["pipecat.processors.aggregators.llm_context"] = llm_context_module + sys.modules[ + "pipecat.processors.aggregators.openai_llm_context" + ] = openai_context_module + sys.modules["pipecat.processors.frame_processor"] = frame_processor_module + + +_install_test_stubs() + +from supermemory_pipecat.service import SupermemoryPipecatService + + +class _MockSupermemoryClient: + def __init__(self, response): + self.profile = AsyncMock(return_value=response) + + +class TestSupermemoryPipecatNullProfile(unittest.IsolatedAsyncioTestCase): + async def test_retrieve_memories_handles_null_profile(self) -> None: + service = SupermemoryPipecatService(api_key="mock_key", user_id="new_user_123") + + response = SimpleNamespace(profile=None, search_results=None) + service._supermemory_client = _MockSupermemoryClient(response) + + result = await service._retrieve_memories("Hello world") + + self.assertEqual( + result, + { + "profile": {"static": [], "dynamic": []}, + "search_results": [], + }, + ) \ No newline at end of file From 9ad351feb091780aafe328473c5f39907de2d24b Mon Sep 17 00:00:00 2001 From: Mubashir R <112580905+Mubashirrrr@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:58:36 +0500 Subject: [PATCH 06/41] fix: view_range [start, -1] drops the last line in the Claude memory tool (#1043) Co-authored-by: Claude Opus 4.8 --- packages/tools/src/claude-memory.test.ts | 85 ++++++++++++++++++++++++ packages/tools/src/claude-memory.ts | 8 ++- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 packages/tools/src/claude-memory.test.ts diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts new file mode 100644 index 00000000..80aea36d --- /dev/null +++ b/packages/tools/src/claude-memory.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path +// can be exercised deterministically without any network access. We only need +// `search.execute` to return a single document with known multi-line content. +const searchExecute = vi.fn() + +vi.mock("supermemory", () => { + return { + default: class MockSupermemory { + search = { execute: searchExecute } + add = vi.fn() + memories = { forget: vi.fn() } + }, + } +}) + +import { ClaudeMemoryTool } from "./claude-memory" + +const FILE_PATH = "/memories/notes.txt" +// 5 distinct lines so an off-by-one at either end is observable. +const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5" + +function mockDocument(content: string) { + // `readFile` matches by `documentId === normalizePathToCustomId(path)`. + // normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt" + searchExecute.mockResolvedValue({ + results: [{ documentId: "memories_notes_txt", content }], + }) +} + +describe("ClaudeMemoryTool view_range", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + searchExecute.mockReset() + mockDocument(FILE_CONTENT) + tool = new ClaudeMemoryTool("test-api-key") + }) + + it("returns the final line when end is the -1 'to end of file' sentinel", async () => { + // Anthropic's text-editor / memory tool convention: an end of -1 means + // "read to the end of the file". The whole file must come back. + const result = await tool.handleCommand({ + command: "view", + path: FILE_PATH, + view_range: [1, -1], + }) + + expect(result.success).toBe(true) + // Regression guard: the last line must not be dropped. + expect(result.content).toContain("line5") + // And every line should be present, in order. + for (const line of ["line1", "line2", "line3", "line4", "line5"]) { + expect(result.content).toContain(line) + } + }) + + it("reads from a start line to the end when end is -1", async () => { + const result = await tool.handleCommand({ + command: "view", + path: FILE_PATH, + view_range: [3, -1], + }) + + expect(result.success).toBe(true) + expect(result.content).toContain("line3") + expect(result.content).toContain("line5") + expect(result.content).not.toContain("line2") + }) + + it("still honors explicit positive ranges", async () => { + const result = await tool.handleCommand({ + command: "view", + path: FILE_PATH, + view_range: [2, 4], + }) + + expect(result.success).toBe(true) + expect(result.content).toContain("line2") + expect(result.content).toContain("line4") + expect(result.content).not.toContain("line1") + expect(result.content).not.toContain("line5") + }) +}) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index b65c8ff9..bbb60547 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -283,7 +283,13 @@ export class ClaudeMemoryTool { if (viewRange) { const lines = content.split("\n") const [startLine, endLine] = viewRange - const selectedLines = lines.slice(startLine - 1, endLine) + // `endLine === -1` is the documented sentinel for "read to the end + // of the file" (same convention as Anthropic's text-editor tool). + // Passing it straight to Array.slice would be interpreted as a + // from-the-end index and silently drop the final line, so map any + // negative end to the array length. + const sliceEnd = endLine < 0 ? lines.length : endLine + const selectedLines = lines.slice(startLine - 1, sliceEnd) // Format with line numbers const numberedLines = selectedLines.map( From ad5734cdfcf86a598a0e0e9e7830c45cf1672873 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:00:18 +0000 Subject: [PATCH 07/41] feat(web): Current/All space filter + redesigned chat history sheet (#1050) Add a Current/All scope toggle (default Current) to Nova chat history; All fetches threads across every space via the new scope=all API, with each row showing its space. Redesign the sheet to fit the design system: cap width at 380px (was ~92vw), add search, date-grouped rows (Today/Yesterday/Previous 7/30 days/Older), hover-reveal delete, header space chip, solid primary New Conversation button. Part of ENG-728: https://linear.app/supermemory/issue/ENG-728 --- apps/web/components/chat/index.tsx | 257 ++++++++++++++++++++--------- 1 file changed, 182 insertions(+), 75 deletions(-) diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index 80e933bb..73d3cc09 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -23,6 +23,7 @@ import { ChevronDownIcon, HistoryIcon, Plus, + Search, SquarePenIcon, Trash2, XIcon, @@ -177,8 +178,21 @@ export function ChatSidebar({ const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [heightOffset, setHeightOffset] = useState(95) const [isHistoryOpen, setIsHistoryOpen] = useState(false) + const [historyScope, setHistoryScope] = useState<"current" | "all">("current") + const [historySearch, setHistorySearch] = useState("") const [threads, setThreads] = useState< - Array<{ id: string; title: string; createdAt: string; updatedAt: string }> + Array<{ + id: string + title: string + createdAt: string + updatedAt: string + space?: { + containerTag: string + name: string + emoji?: string + isDefault: boolean + } + }> >([]) const [isLoadingThreads, setIsLoadingThreads] = useState(false) const [confirmingDeleteId, setConfirmingDeleteId] = useState( @@ -626,8 +640,10 @@ export function ChatSidebar({ const fetchThreads = useCallback(async () => { setIsLoadingThreads(true) try { + const params = new URLSearchParams({ projectId: chatProject }) + if (historyScope === "all") params.set("scope", "all") const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${chatProject}`, + `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`, { credentials: "include" }, ) if (response.ok) { @@ -639,7 +655,7 @@ export function ChatSidebar({ } finally { setIsLoadingThreads(false) } - }, [chatProject]) + }, [chatProject, historyScope]) useEffect(() => { if (!isHistoryOpen) return @@ -1057,6 +1073,36 @@ export function ChatSidebar({ !isStackedInput || isResponding || messages.length > 0 const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT + const threadGroups = useMemo(() => { + const q = historySearch.trim().toLowerCase() + const filtered = q + ? threads.filter((t) => (t.title || "").toLowerCase().includes(q)) + : threads + const now = new Date() + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime() + const day = 86_400_000 + const buckets: Array<{ label: string; items: typeof threads }> = [ + { label: "Today", items: [] }, + { label: "Yesterday", items: [] }, + { label: "Previous 7 days", items: [] }, + { label: "Previous 30 days", items: [] }, + { label: "Older", items: [] }, + ] + for (const t of filtered) { + const ts = new Date(t.updatedAt).getTime() + if (ts >= startOfToday) buckets[0].items.push(t) + else if (ts >= startOfToday - day) buckets[1].items.push(t) + else if (ts >= startOfToday - 7 * day) buckets[2].items.push(t) + else if (ts >= startOfToday - 30 * day) buckets[3].items.push(t) + else buckets[4].items.push(t) + } + return buckets.filter((b) => b.items.length > 0) + }, [threads, historySearch]) + const chatHistorySheet = ( button]:text-[#FAFAFA]", dmSansClassName(), )} > - - Chat History - - Space: {chatSpaceLabel} + +
+ Chat History + + {historyScope === "all" ? "All spaces" : chatSpaceLabel} + +
+ + {historyScope === "all" + ? "All conversations across your spaces" + : `Conversations in ${chatSpaceLabel}`} +
+
+ + setHistorySearch(e.target.value)} + placeholder="Search conversations…" + className="h-9 w-full rounded-lg border border-[#161F2C] bg-[#0F141B] pr-3 pl-9 text-sm text-white placeholder:text-[#737373] focus:border-[#267BF1]/50 focus:outline-none" + /> +
+
+ {(["current", "all"] as const).map((scope) => ( + + ))} +
+
- -
+ +
{isLoadingThreads ? (
@@ -1091,79 +1174,103 @@ export function ChatSidebar({
No conversations yet
+ ) : threadGroups.length === 0 ? ( +
+ No conversations match “{historySearch.trim()}” +
) : ( -
- {threads.map((thread) => { - const isActive = thread.id === currentChatId - return ( - - -
- ) : ( - - )} - - ) - })} +
+
+ {thread.title || "Untitled Chat"} +
+
+ + {formatRelativeTime(thread.updatedAt)} + + {historyScope === "all" && thread.space ? ( + <> + · + + {thread.space.emoji + ? `${thread.space.emoji} ` + : ""} + {thread.space.name} + + + ) : null} +
+
+ {isConfirming ? ( +
+ + +
+ ) : ( + + )} + + ) + })} +
+ ))}
)}
-
+
+
+ Mode: +
+ {(["consumer", "console"] as const).map((variant) => ( + + ))} +
+
+ Load: +
+ {(["zoom", "manual", "background"] as const).map((behavior) => ( + + ))} +
+ {loadBehavior === "manual" && ( + + )} + {loadBehavior === "background" && !mockData && hasMore && ( + + Auto paging + + )} +
{/* Stress test buttons */} Stress Test: {[50, 100, 200, 500].map((count) => ( @@ -300,13 +593,24 @@ export default function Home() { 0} isSlideshowActive={isSlideshowActive} onSlideshowNodeChange={handleSlideshowNodeChange} onSlideshowStop={handleSlideshowStop} + totalCount={totalCount} + colors={ + graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined + } >

diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 17433857..6d336fa3 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -684,7 +684,7 @@ export default function NewPage() { onBack={() => void setViewMode("integrations")} /> ) : viewMode === "graph" ? ( -

+
) : viewMode === "list" ? ( diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx index c1fc0311..0e9f5d67 100644 --- a/apps/web/components/graph-layout-view.tsx +++ b/apps/web/components/graph-layout-view.tsx @@ -35,15 +35,14 @@ export const GraphLayoutView = memo(function GraphLayoutView({ }, [setIsShareModalOpen]) return ( -
+
{/* Full-width graph */} -
+
diff --git a/apps/web/components/memory-graph/hooks/use-graph-api.ts b/apps/web/components/memory-graph/hooks/use-graph-api.ts index 7991f600..30b47672 100644 --- a/apps/web/components/memory-graph/hooks/use-graph-api.ts +++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts @@ -1,7 +1,7 @@ "use client" import { useInfiniteQuery } from "@tanstack/react-query" -import { useMemo } from "react" +import { useEffect, useMemo } from "react" import { $fetch } from "@lib/api" import type { GraphApiDocument, @@ -9,12 +9,13 @@ import type { MemoryRelation, } from "@supermemory/memory-graph" -const PAGE_SIZE = 100 +const PAGE_SIZE = 500 interface UseGraphApiOptions { containerTags?: string[] documentIds?: string[] enabled?: boolean + maxNodes?: number } interface ApiMemoryEntry { @@ -59,6 +60,13 @@ interface ApiDocumentsResponse { } } +function getGraphNodeCount(documents: ApiDocument[]): number { + return documents.reduce( + (total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0), + 0, + ) +} + function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory { return { id: mem.id, @@ -108,7 +116,7 @@ function toGraphDocument( } export function useGraphApi(options: UseGraphApiOptions = {}) { - const { containerTags, documentIds, enabled = true } = options + const { containerTags, documentIds, enabled = true, maxNodes } = options const filteredDocumentIds = documentIds?.filter(Boolean) const hasDocumentIds = filteredDocumentIds != null && filteredDocumentIds.length > 0 @@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) { containerTags, [], filteredDocumentIds, + maxNodes, ], initialPageParam: 1, queryFn: async ({ pageParam }) => { @@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) { return response.data as unknown as ApiDocumentsResponse }, - getNextPageParam: (lastPage) => { + getNextPageParam: (lastPage, allPages) => { + if (hasDocumentIds) return undefined + if (maxNodes != null) { + const loadedNodes = allPages.reduce( + (total, page) => total + getGraphNodeCount(page.documents ?? []), + 0, + ) + if (loadedNodes >= maxNodes) return undefined + } + const { currentPage, totalPages } = lastPage.pagination return currentPage < totalPages ? currentPage + 1 : undefined }, @@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) { enabled, }) + const loadedNodeCount = useMemo(() => { + if (!data?.pages) return 0 + return data.pages.reduce( + (total, page) => total + getGraphNodeCount(page.documents ?? []), + 0, + ) + }, [data]) + + useEffect(() => { + if (!enabled || hasDocumentIds) return + if (!hasNextPage || isFetchingNextPage) return + if (maxNodes != null && loadedNodeCount >= maxNodes) return + fetchNextPage() + }, [ + enabled, + hasDocumentIds, + hasNextPage, + isFetchingNextPage, + loadedNodeCount, + maxNodes, + fetchNextPage, + ]) + const documents = useMemo(() => { if (!data?.pages) return [] return data.pages.flatMap((page) => diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx index 0b2ca826..f594004f 100644 --- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -1,6 +1,5 @@ "use client" -import { useEffect, useRef, useState } from "react" import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph" import type { GraphThemeColors } from "@supermemory/memory-graph" import { useGraphApi } from "./hooks/use-graph-api" @@ -34,20 +33,6 @@ export function MemoryGraph({ canvasRef, ...rest }: MemoryGraphWrapperProps) { - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) - const containerRef = useRef(null) - - useEffect(() => { - const el = containerRef.current - if (!el) return - const ro = new ResizeObserver(() => { - setContainerSize({ width: el.clientWidth, height: el.clientHeight }) - }) - ro.observe(el) - setContainerSize({ width: el.clientWidth, height: el.clientHeight }) - return () => ro.disconnect() - }, []) - const { documents, isLoading: apiIsLoading, @@ -59,11 +44,11 @@ export function MemoryGraph({ } = useGraphApi({ containerTags, documentIds, - enabled: containerSize.width > 0 && containerSize.height > 0, + maxNodes, }) return ( -
+
{ + it("extends edges have lower opacity than derives edges (visible but quiet)", () => { const ext = getEdgeVisualProps("extends") const der = getEdgeVisualProps("derives") - expect(ext.opacity).toBeGreaterThan(der.opacity) + expect(ext.opacity).toBeLessThan(der.opacity) }) - it("updates edges have higher opacity than derives edges (version chains are prominent)", () => { + it("updates edges are more prominent than quiet relation edges", () => { const upd = getEdgeVisualProps("updates") const der = getEdgeVisualProps("derives") + const ext = getEdgeVisualProps("extends") expect(upd.opacity).toBeGreaterThan(der.opacity) + expect(upd.opacity).toBeGreaterThan(ext.opacity) }) it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => { diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts index 9c643925..4f728f97 100644 --- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect } from "vitest" import { getMemoryBorderColor, getEdgeVisualProps, + getMemoryOrbitOffset, + computeClusterAssignments, + getAppendPosition, + getNodeBounds, } from "../hooks/use-graph-data" import { DEFAULT_COLORS } from "../constants" -import type { GraphApiMemory } from "../types" +import type { GraphApiDocument, GraphApiMemory, GraphNode } from "../types" function makeMemory(overrides: Partial = {}): GraphApiMemory { return { @@ -25,6 +29,28 @@ function makeMemory(overrides: Partial = {}): GraphApiMemory { } } +function makeNode(id: string, x: number, y: number, size = 50): GraphNode { + return { + id, + type: "document", + x, + y, + size, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + describe("getMemoryBorderColor", () => { const colors = DEFAULT_COLORS @@ -67,14 +93,14 @@ describe("getEdgeVisualProps", () => { it("returns correct props for updates edges", () => { const props = getEdgeVisualProps("updates") - expect(props.opacity).toBeCloseTo(0.7) - expect(props.thickness).toBeCloseTo(2) + expect(props.opacity).toBeCloseTo(0.48) + expect(props.thickness).toBeCloseTo(1.45) }) it("returns correct props for extends edges", () => { const props = getEdgeVisualProps("extends") - expect(props.opacity).toBeCloseTo(0.55) - expect(props.thickness).toBeCloseTo(1.5) + expect(props.opacity).toBeCloseTo(0.16) + expect(props.thickness).toBeCloseTo(0.8) }) it("returns default props for unknown edge types", () => { @@ -83,3 +109,113 @@ describe("getEdgeVisualProps", () => { expect(props.thickness).toBeCloseTo(1.2) }) }) + +describe("cluster assignments", () => { + it("keeps memories from the same document in the same visual cluster", () => { + const assignments = computeClusterAssignments([ + makeDocument("doc-a", [ + makeMemory({ id: "a1" }), + makeMemory({ id: "a2" }), + ]), + ]) + + expect(assignments.get("a1")?.key).toBe(assignments.get("a2")?.key) + expect(assignments.get("a1")?.color).toMatch(/^#[0-9A-Fa-f]{6}$/) + }) + + it("merges cross-document relation clusters", () => { + const assignments = computeClusterAssignments([ + makeDocument("doc-a", [makeMemory({ id: "a1" })]), + makeDocument("doc-b", [ + makeMemory({ id: "b1", memoryRelations: { a1: "extends" } }), + ]), + ]) + + expect(assignments.get("a1")?.key).toBe(assignments.get("b1")?.key) + }) +}) + +describe("memory orbit placement", () => { + it("pushes high-index memories onto wider rings", () => { + const early = getMemoryOrbitOffset(0, 80, "mem-0") + const late = getMemoryOrbitOffset(50, 80, "mem-50") + + expect(late.radius).toBeGreaterThan(early.radius) + }) + + it("is deterministic for the same memory", () => { + const first = getMemoryOrbitOffset(12, 40, "mem-12") + const second = getMemoryOrbitOffset(12, 40, "mem-12") + + expect(second).toEqual(first) + }) +}) + +describe("append placement helpers", () => { + it("computes bounds including node radius", () => { + const bounds = getNodeBounds([ + makeNode("a", 100, 100, 50), + makeNode("b", 300, 220, 40), + ]) + + expect(bounds).toEqual({ + minX: 75, + minY: 75, + maxX: 320, + maxY: 240, + centerX: 197.5, + centerY: 157.5, + }) + }) + + it("places appended nodes outside existing graph bounds", () => { + const existing = [makeNode("a", 100, 100, 50), makeNode("b", 300, 220, 40)] + const bounds = getNodeBounds(existing) + const pos = getAppendPosition(existing, 0, 1000, 800) + + if (!bounds) throw new Error("Expected bounds") + const outsideBounds = + pos.x < bounds.minX || + pos.x > bounds.maxX || + pos.y < bounds.minY || + pos.y > bounds.maxY + expect(outsideBounds).toBe(true) + }) + + it("distributes append positions across multiple surrounding areas", () => { + const existing = [makeNode("a", 100, 100, 50), makeNode("b", 300, 220, 40)] + const bounds = getNodeBounds(existing) + if (!bounds) throw new Error("Expected bounds") + + const areas = new Set( + Array.from({ length: 8 }, (_, index) => { + const pos = getAppendPosition(existing, index, 1000, 800) + if (pos.x < bounds.minX) return "left" + if (pos.x > bounds.maxX) return "right" + if (pos.y < bounds.minY) return "top" + return "bottom" + }), + ) + + expect(areas.size).toBeGreaterThan(2) + }) + + it("uses the canvas center when no existing nodes are available", () => { + expect(getAppendPosition([], 0, 1000, 800)).toEqual({ x: 500, y: 400 }) + }) +}) + +function makeDocument( + id: string, + memories: GraphApiMemory[], +): GraphApiDocument { + return { + id, + title: id, + summary: null, + documentType: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories, + } +} diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts index 481256a2..02630037 100644 --- a/packages/memory-graph/src/__tests__/renderer-utils.test.ts +++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest" -import { lightenColor } from "../canvas/renderer" +import { + getRelationEdgeStride, + lightenColor, + mixHexColors, + shouldDrawRelationEdge, +} from "../canvas/renderer" describe("lightenColor", () => { test("lightens a dark hex color", () => { @@ -55,3 +60,33 @@ describe("lightenColor", () => { expect(result).toBe("#2f3338") }) }) + +describe("mixHexColors", () => { + test("mixes two hex colors", () => { + expect(mixHexColors("#000000", "#ffffff", 0.5)).toBe("#808080") + }) + + test("returns the base color for unsupported color formats", () => { + expect(mixHexColors("rgb(0,0,0)", "#ffffff", 0.5)).toBe("rgb(0,0,0)") + }) +}) + +describe("relation edge level-of-detail helpers", () => { + test("keeps all relation edges at normal zoom", () => { + expect(getRelationEdgeStride(5000, 0.5)).toBe(1) + }) + + test("samples dense relation edges at low zoom", () => { + expect(getRelationEdgeStride(1040, 0.1)).toBe(4) + }) + + test("always draws structural derives edges", () => { + expect(shouldDrawRelationEdge("edge-1", "derives", 10)).toBe(true) + }) + + test("deterministically samples non-structural relation edges", () => { + const first = shouldDrawRelationEdge("rel-a-b", "updates", 4) + const second = shouldDrawRelationEdge("rel-a-b", "updates", 4) + expect(second).toBe(first) + }) +}) diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts index 61d46752..cd0035a2 100644 --- a/packages/memory-graph/src/__tests__/simulation.test.ts +++ b/packages/memory-graph/src/__tests__/simulation.test.ts @@ -61,8 +61,10 @@ describe("ForceSimulation", () => { sim.init(nodes, []) // After init with pre-ticks, nodes at same position should have moved apart - const dx = nodes[0]!.x - nodes[1]!.x - const dy = nodes[0]!.y - nodes[1]!.y + const [first, second] = nodes + if (!first || !second) throw new Error("Expected two nodes") + const dx = first.x - second.x + const dy = first.y - second.y const dist = Math.sqrt(dx * dx + dy * dy) expect(dist).toBeGreaterThan(0) sim.destroy() @@ -74,7 +76,9 @@ describe("ForceSimulation", () => { sim.init(nodes, []) // Update with same nodes but different positions - nodes[0]!.x = 50 + const [first] = nodes + if (!first) throw new Error("Expected node") + first.x = 50 expect(() => sim.update(nodes, [])).not.toThrow() expect(sim.isActive()).toBe(true) sim.destroy() @@ -97,6 +101,15 @@ describe("ForceSimulation", () => { sim.destroy() }) + it("stop immediately deactivates the simulation", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + sim.stop() + expect(sim.isActive()).toBe(false) + sim.destroy() + }) + it("handles empty nodes array", () => { const sim = new ForceSimulation() expect(() => sim.init([], [])).not.toThrow() diff --git a/packages/memory-graph/src/__tests__/version-chain.test.ts b/packages/memory-graph/src/__tests__/version-chain.test.ts index eb1bf029..1ce75c39 100644 --- a/packages/memory-graph/src/__tests__/version-chain.test.ts +++ b/packages/memory-graph/src/__tests__/version-chain.test.ts @@ -67,6 +67,52 @@ describe("VersionChainIndex", () => { expect(chain).not.toBeNull() expect(chain!.length).toBe(3) expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + expect(chain?.map((e) => e.version)).toEqual([1, 2, 3]) + }) + + it("infers display versions when backend repeats v1 across an update chain", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, isLatest: false }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 1, + memoryRelations: { m1: "updates" }, + }), + ]) + idx.rebuild([doc]) + + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain?.map((e) => e.id)).toEqual(["m1", "m2"]) + expect(chain?.map((e) => e.version)).toEqual([1, 2]) + }) + + it("only infers broken version entries and preserves valid backend versions", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 5, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 5, + }), + ]) + idx.rebuild([doc]) + + const chain = idx.getChain("m3") + expect(chain).not.toBeNull() + expect(chain?.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + expect(chain?.map((e) => e.version)).toEqual([1, 5, 6]) }) it("getChain from middle element returns full chain (backward + forward)", () => { diff --git a/packages/memory-graph/src/__tests__/viewport.test.ts b/packages/memory-graph/src/__tests__/viewport.test.ts index aee0b14b..b2164e25 100644 --- a/packages/memory-graph/src/__tests__/viewport.test.ts +++ b/packages/memory-graph/src/__tests__/viewport.test.ts @@ -122,6 +122,22 @@ describe("ViewportState", () => { expect(vp.zoom).toBeCloseTo(0.1) }) + it("can lower the minimum zoom to fit a large loaded graph", () => { + const vp = new ViewportState(0, 0, 0.5) + const nodes = [ + makeNode("a", 0, 0), + makeNode("b", 10_000, 0), + makeNode("c", 0, 10_000), + makeNode("d", 10_000, 10_000), + ] + + vp.setMinZoomForNodes(nodes, 800, 600) + vp.zoomImmediate(0.01, 0, 0) + + expect(vp.zoom).toBeLessThan(0.1) + expect(vp.zoom).toBeGreaterThan(0.005) + }) + it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => { const vp = new ViewportState(0, 0, 2) // Try to zoom way up: 2 * 100 = 200, should clamp to 5 diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index a3e1be30..17c73f7b 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -7,6 +7,7 @@ import type { } from "../types" import type { ViewportState } from "./viewport" import { drawDocIcon, roundRect } from "./document-icons" +import { hashString } from "../utils/hash" export interface RenderState { selectedNodeId: string | null @@ -17,6 +18,13 @@ export interface RenderState { // Module-level reusable batch map – cleared each frame instead of reallocating const edgeBatches = new Map() +const RELATION_LOD_ZOOM = 0.5 +const RELATION_LOD_MAX_BACKGROUND_EDGES = 260 +const RELATION_LOD_DENSE_COUNT = 180 +const DERIVES_LOD_ZOOM = 0.38 +const DERIVES_LOD_MAX_BACKGROUND_EDGES = 3200 +const DENSE_POINT_THRESHOLD = 25000 +const DENSE_POINT_ZOOM = 0.42 function nodeMatchesDocumentHighlights( node: GraphNode, @@ -30,13 +38,21 @@ function nodeMatchesDocumentHighlights( /** Group items by their `color` property into batches for efficient canvas drawing */ function groupByColor( items: T[], +): Map { + return groupByComputedColor(items, (item) => item.color) +} + +function groupByComputedColor( + items: T[], + getColor: (item: T) => string, ): Map { const map = new Map() for (const item of items) { - let batch = map.get(item.color) + const color = getColor(item) + let batch = map.get(color) if (!batch) { batch = [] - map.set(item.color, batch) + map.set(color, batch) } batch.push(item) } @@ -70,9 +86,98 @@ function edgeStyle( if (edge.edgeType === "derives") return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 } if (edge.edgeType === "updates") - return { color: colors.edgeUpdates, width: 2, opacity: 0.7 } + return { color: colors.edgeUpdates, width: 1.45, opacity: 0.48 } // "extends" and any unknown edge types - return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 } + return { color: colors.edgeExtends, width: 0.8, opacity: 0.16 } +} + +export function getRelationEdgeStride( + relationEdgeCount: number, + zoom: number, +): number { + if ( + zoom >= RELATION_LOD_ZOOM || + relationEdgeCount <= RELATION_LOD_MAX_BACKGROUND_EDGES + ) { + return 1 + } + return Math.ceil(relationEdgeCount / RELATION_LOD_MAX_BACKGROUND_EDGES) +} + +function getDerivesEdgeStride(derivesEdgeCount: number, zoom: number): number { + if ( + zoom >= DERIVES_LOD_ZOOM || + derivesEdgeCount <= DERIVES_LOD_MAX_BACKGROUND_EDGES + ) { + return 1 + } + return Math.ceil(derivesEdgeCount / DERIVES_LOD_MAX_BACKGROUND_EDGES) +} + +export function shouldDrawRelationEdge( + edgeId: string, + edgeType: string, + stride: number, +): boolean { + if (edgeType === "derives" || stride <= 1) return true + return hashString(edgeId) % stride === 0 +} + +function shouldDrawSampledEdge(edgeId: string, stride: number): boolean { + return stride <= 1 || hashString(edgeId) % stride === 0 +} + +function applyRelationLevelOfDetail( + style: { color: string; width: number; opacity: number }, + edgeType: string, + relationEdgeCount: number, + zoom: number, + hasFocus: boolean, + hasActiveHover: boolean, +) { + if (edgeType === "derives") return { style, glow: true } + if (hasFocus || hasActiveHover) { + const isUpdate = edgeType === "updates" + const minOpacity = hasActiveHover ? 0.9 : 0.76 + const minWidth = hasActiveHover ? 2.35 : 1.8 + return { + style: isUpdate + ? { + ...style, + width: Math.max(style.width, minWidth), + opacity: Math.max(style.opacity, minOpacity), + } + : style, + glow: isUpdate, + } + } + if ( + zoom >= RELATION_LOD_ZOOM || + relationEdgeCount <= RELATION_LOD_DENSE_COUNT + ) { + return { style, glow: edgeType === "updates" } + } + + const densityFactor = Math.min( + 1, + RELATION_LOD_DENSE_COUNT / relationEdgeCount, + ) + const zoomFactor = clampNumber(zoom / RELATION_LOD_ZOOM, 0.25, 1) + const opacityFactor = clampNumber(densityFactor * zoomFactor, 0.06, 0.24) + const widthFactor = clampNumber(zoomFactor * 0.65, 0.22, 0.7) + + return { + style: { + ...style, + width: Math.max(0.45, style.width * widthFactor), + opacity: style.opacity * opacityFactor, + }, + glow: false, + } +} + +function clampNumber(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value } function batchKey(style: { @@ -92,6 +197,7 @@ interface PreparedEdge { style: { color: string; width: number; opacity: number } edgeType: string arrowSize: number + glow: boolean } function drawEdges( @@ -106,13 +212,36 @@ function drawEdges( ): void { const margin = 100 const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 + const relationEdgeCount = edges.reduce( + (count, edge) => count + (edge.edgeType === "derives" ? 0 : 1), + 0, + ) + const derivesEdgeCount = edges.length - relationEdgeCount + const relationStride = getRelationEdgeStride(relationEdgeCount, viewport.zoom) + const derivesStride = getDerivesEdgeStride(derivesEdgeCount, viewport.zoom) const prepared: PreparedEdge[] = [] for (const edge of edges) { - // Zoom-based edge culling for extends edges at very low zoom - if (edge.edgeType === "extends") { - if (viewport.zoom < 0.08) continue + const edgeType = edge.edgeType ?? "derives" + const srcId = typeof edge.source === "string" ? edge.source : edge.source.id + const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id + const hoverConnected = + state.hoveredNodeId != null && + (srcId === state.hoveredNodeId || tgtId === state.hoveredNodeId) + const selectedConnected = + state.selectedNodeId != null && + (srcId === state.selectedNodeId || tgtId === state.selectedNodeId) + const activeConnected = hoverConnected || selectedConnected + const shouldAlwaysDrawActiveUpdate = + edgeType === "updates" && activeConnected + const edgeStride = edgeType === "derives" ? derivesStride : relationStride + if ( + !shouldAlwaysDrawActiveUpdate && + !hasDim && + !shouldDrawSampledEdge(edge.id, edgeStride) + ) { + continue } const src = @@ -121,7 +250,7 @@ function drawEdges( typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target if (!src || !tgt) continue - if (edge.edgeType === "derives") { + if (edgeType === "derives") { const mem = src.type === "memory" ? src : tgt if (mem.size * viewport.zoom < 3) continue } @@ -149,12 +278,39 @@ function drawEdges( let connected = true if (hasDim) { - const srcId = - typeof edge.source === "string" ? edge.source : edge.source.id - const tgtId = - typeof edge.target === "string" ? edge.target : edge.target.id - connected = - srcId === state.selectedNodeId || tgtId === state.selectedNodeId + connected = selectedConnected + } + if ( + !shouldAlwaysDrawActiveUpdate && + hasDim && + !connected && + !shouldDrawSampledEdge(edge.id, edgeStride) + ) { + continue + } + + const edgeDetail = applyRelationLevelOfDetail( + edgeStyle(edge, colors), + edgeType, + relationEdgeCount, + viewport.zoom, + hasDim && connected, + edgeType === "updates" && hoverConnected, + ) + let style = edgeDetail.style + let glow = edgeDetail.glow + if (edgeType === "derives" && derivesStride > 1 && !activeConnected) { + const zoomFactor = clampNumber( + viewport.zoom / DERIVES_LOD_ZOOM, + 0.08, + 0.32, + ) + style = { + ...style, + width: Math.max(0.35, style.width * 0.45), + opacity: style.opacity * zoomFactor, + } + glow = false } prepared.push({ @@ -163,10 +319,16 @@ function drawEdges( endX: t.x - ux * tr, endY: t.y - uy * tr, connected, - style: edgeStyle(edge, colors), - edgeType: edge.edgeType ?? "derives", + style, + edgeType, arrowSize: - edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0, + edgeType === "updates" + ? Math.max( + shouldAlwaysDrawActiveUpdate ? 8 : 6, + (shouldAlwaysDrawActiveUpdate ? 11 : 8) * viewport.zoom, + ) + : 0, + glow, }) } @@ -174,7 +336,7 @@ function drawEdges( edgeBatches.clear() for (const e of prepared) { const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" - const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}` + const key = `${e.edgeType}|${batchKey(e.style)}|${e.glow ? "g" : "f"}${dimKey}` let batch = edgeBatches.get(key) if (!batch) { batch = [] @@ -190,8 +352,9 @@ function drawEdges( const isDimmed = key.endsWith("|d") const batchEdgeType = first.edgeType - // Draw glow pass behind all edge types for luminous aesthetic - if (!isDimmed) { + // Draw glow pass behind structural/revision edges. Cross-cluster + // extends edges stay flat so dense graphs do not become a mesh. + if (!isDimmed && first.glow && batchEdgeType !== "extends") { const glowAlpha = batchEdgeType === "updates" ? first.style.opacity * 0.4 @@ -204,7 +367,6 @@ function drawEdges( ctx.globalAlpha = glowAlpha ctx.strokeStyle = first.style.color ctx.lineWidth = glowWidth - if (batchEdgeType === "extends") ctx.setLineDash([6, 4]) ctx.beginPath() for (const e of batch) { ctx.moveTo(e.startX, e.startY) @@ -221,9 +383,6 @@ function drawEdges( ctx.strokeStyle = first.style.color ctx.lineWidth = first.style.width - // Extends edges use dashed lines - if (batchEdgeType === "extends") ctx.setLineDash([6, 4]) - ctx.beginPath() for (const e of batch) { ctx.moveTo(e.startX, e.startY) @@ -231,8 +390,6 @@ function drawEdges( } ctx.stroke() - if (batchEdgeType === "extends") ctx.setLineDash([]) - // Arrowheads for updates edges if (batchEdgeType === "updates") { ctx.globalAlpha = isDimmed @@ -281,12 +438,26 @@ function drawNodes( colors: GraphThemeColors, ): void { const margin = 60 + const densePointMode = + nodes.length > DENSE_POINT_THRESHOLD && + viewport.zoom < DENSE_POINT_ZOOM && + !state.selectedNodeId && + state.highlightIds.size === 0 + const pointDots: { + x: number + y: number + r: number + color: string + }[] = [] const memDots: { x: number y: number r: number color: string + fillColor: string + haloColor: string dimmed: boolean + updateChain: boolean }[] = [] const docDots: { x: number; y: number; s: number }[] = [] @@ -316,6 +487,14 @@ function drawNodes( if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) { if (node.type === "document") { docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) }) + } else if (densePointMode) { + pointDots.push({ + x: screen.x, + y: screen.y, + r: Math.max(1.1, screenSize * 0.42), + color: + node.clusterColor || node.borderColor || colors.memStrokeDefault, + }) } else { const md = node.data as MemoryNodeData memDots.push({ @@ -323,7 +502,10 @@ function drawNodes( y: screen.y, r: Math.max(2, screenSize * 0.45), color: node.borderColor || colors.memStrokeDefault, + fillColor: getMemoryNodeFillColor(node, colors, false), + haloColor: node.clusterColor || node.borderColor || colors.glowColor, dimmed: md.isLatest === false, + updateChain: isMemoryInUpdateChain(md), }) } continue @@ -383,6 +565,19 @@ function drawNodes( : 1 const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1 + if (pointDots.length > 0) { + ctx.globalAlpha = dimAlpha * 0.78 + for (const [color, batch] of groupByColor(pointDots)) { + ctx.fillStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() + } + } + if (docDots.length > 0) { ctx.fillStyle = colors.docFill ctx.strokeStyle = colors.docStroke @@ -403,7 +598,10 @@ function drawNodes( if (normalDots.length > 0) { // Subtle glow behind memory dots for luminous effect ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25 - for (const [color, batch] of groupByColor(normalDots)) { + for (const [color, batch] of groupByComputedColor( + normalDots, + (d) => d.haloColor, + )) { ctx.fillStyle = color ctx.beginPath() for (const d of batch) { @@ -415,13 +613,18 @@ function drawNodes( // Filled dot ctx.globalAlpha = dimAlpha * hlBatchMult - ctx.fillStyle = colors.memFill - ctx.beginPath() - for (const d of normalDots) { - ctx.moveTo(d.x + d.r, d.y) - ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + for (const [fillColor, batch] of groupByComputedColor( + normalDots, + (d) => d.fillColor, + )) { + ctx.fillStyle = fillColor + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() } - ctx.fill() // Colored border ctx.lineWidth = 1.5 @@ -434,18 +637,37 @@ function drawNodes( } ctx.stroke() } + + const updateDots = normalDots.filter((d) => d.updateChain) + if (updateDots.length > 0) { + ctx.globalAlpha = dimAlpha * hlBatchMult * 0.85 + ctx.strokeStyle = colors.edgeUpdates + ctx.lineWidth = 1.2 + ctx.beginPath() + for (const d of updateDots) { + const r = d.r * 1.65 + ctx.moveTo(d.x + r, d.y) + ctx.arc(d.x, d.y, r, 0, Math.PI * 2) + } + ctx.stroke() + } } // Draw dimmed (superseded) memory dots at reduced opacity if (dimmedDots.length > 0) { ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5 - ctx.fillStyle = colors.memFill - ctx.beginPath() - for (const d of dimmedDots) { - ctx.moveTo(d.x + d.r, d.y) - ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + for (const [fillColor, batch] of groupByComputedColor( + dimmedDots, + (d) => d.fillColor, + )) { + ctx.fillStyle = fillColor + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() } - ctx.fill() ctx.lineWidth = 1 for (const [color, batch] of groupByColor(dimmedDots)) { @@ -457,6 +679,20 @@ function drawNodes( } ctx.stroke() } + + const updateDots = dimmedDots.filter((d) => d.updateChain) + if (updateDots.length > 0) { + ctx.globalAlpha = dimAlpha * hlBatchMult * 0.55 + ctx.strokeStyle = colors.edgeUpdates + ctx.lineWidth = 1 + ctx.beginPath() + for (const d of updateDots) { + const r = d.r * 1.65 + ctx.moveTo(d.x + r, d.y) + ctx.arc(d.x, d.y, r, 0, Math.PI * 2) + } + ctx.stroke() + } } } @@ -476,11 +712,12 @@ function drawDocumentNode( ): void { const half = size * 0.5 const cornerR = 8 * (size / 50) + const clusterColor = node.clusterColor ?? colors.docStroke // Drop shadow for selected/hovered nodes if (isSelected || isHovered) { ctx.save() - ctx.shadowColor = colors.accent + ctx.shadowColor = isSelected ? colors.accent : clusterColor ctx.shadowBlur = isSelected ? 16 : 10 ctx.shadowOffsetX = 0 ctx.shadowOffsetY = 0 @@ -493,12 +730,16 @@ function drawDocumentNode( sx + half, sy + half, ) - grad.addColorStop(0, colors.docFill) - grad.addColorStop(1, lightenColor(colors.docFill, 0.08)) + grad.addColorStop(0, mixHexColors(colors.docFill, clusterColor, 0.1)) + grad.addColorStop(1, mixHexColors(colors.docFill, clusterColor, 0.22)) ctx.fillStyle = grad ctx.strokeStyle = - isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke + isSelected || isHighlighted + ? colors.accent + : isHovered + ? clusterColor + : node.borderColor || clusterColor ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1 roundRect(ctx, sx - half, sy - half, size, size, cornerR) ctx.fill() @@ -511,14 +752,14 @@ function drawDocumentNode( const innerSize = size * 0.72 const innerHalf = innerSize * 0.5 const innerR = 6 * (size / 50) - ctx.fillStyle = colors.docInnerFill + ctx.fillStyle = mixHexColors(colors.docInnerFill, clusterColor, 0.08) roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR) ctx.fill() const iconSize = size * 0.35 const docType = node.type === "document" ? (node.data as DocumentNodeData).type : "text" - drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors.iconColor) + drawDocIcon(ctx, sx, sy, iconSize, docType || "text", clusterColor) } function drawMemoryNode( @@ -535,13 +776,14 @@ function drawMemoryNode( const memData = node.data as MemoryNodeData const isSuperseded = memData.isLatest === false const isForgotten = memData.isForgotten + const isUpdateChain = isMemoryInUpdateChain(memData) const radius = size * 0.5 // Dim superseded (non-latest) memory nodes with strikethrough effect if (isSuperseded && !isSelected && !isHovered) { const prevAlpha = ctx.globalAlpha ctx.globalAlpha = prevAlpha * 0.5 - ctx.fillStyle = colors.memFill + ctx.fillStyle = getMemoryNodeFillColor(node, colors, false) drawHexagon(ctx, sx, sy, radius) ctx.fill() ctx.strokeStyle = node.borderColor || colors.memStrokeDefault @@ -559,6 +801,8 @@ function drawMemoryNode( ctx.lineWidth = 1.5 ctx.stroke() + drawUpdateMarker(ctx, sx, sy, radius, colors, 0.85) + ctx.globalAlpha = prevAlpha return } @@ -573,7 +817,7 @@ function drawMemoryNode( ctx.shadowOffsetY = 0 } - ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill + ctx.fillStyle = getMemoryNodeFillColor(node, colors, isHovered) drawHexagon(ctx, sx, sy, radius) ctx.fill() @@ -582,6 +826,17 @@ function drawMemoryNode( ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5 ctx.stroke() + if (isUpdateChain) { + drawUpdateMarker( + ctx, + sx, + sy, + radius, + colors, + isSelected || isHovered ? 1 : 0.86, + ) + } + if (isSelected || isHovered) { ctx.restore() } @@ -604,6 +859,60 @@ function drawMemoryNode( } } +function isMemoryInUpdateChain(memData: MemoryNodeData): boolean { + if (memData.isLatest === false || memData.parentMemoryId) return true + if (!memData.memoryRelations) return false + return Object.values(memData.memoryRelations).some( + (relation) => relation === "updates", + ) +} + +function drawUpdateMarker( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + radius: number, + colors: GraphThemeColors, + alpha: number, +) { + const markerR = Math.max(3.5, radius * 0.22) + const cx = sx + radius * 0.48 + const cy = sy - radius * 0.48 + + ctx.save() + ctx.globalAlpha *= alpha + ctx.fillStyle = colors.popoverBg + ctx.strokeStyle = colors.edgeUpdates + ctx.lineWidth = Math.max(1.2, radius * 0.08) + ctx.beginPath() + ctx.arc(cx, cy, markerR, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + + ctx.strokeStyle = colors.edgeUpdates + ctx.lineCap = "round" + ctx.lineJoin = "round" + ctx.lineWidth = Math.max(1.2, radius * 0.07) + ctx.beginPath() + ctx.moveTo(cx - markerR * 0.45, cy) + ctx.lineTo(cx + markerR * 0.12, cy) + ctx.lineTo(cx - markerR * 0.06, cy - markerR * 0.2) + ctx.moveTo(cx + markerR * 0.12, cy) + ctx.lineTo(cx - markerR * 0.06, cy + markerR * 0.2) + ctx.stroke() + ctx.restore() +} + +function getMemoryNodeFillColor( + node: GraphNode, + colors: GraphThemeColors, + isHovered: boolean, +): string { + const base = isHovered ? colors.memFillHover : colors.memFill + if (!node.clusterColor) return base + return mixHexColors(base, node.clusterColor, isHovered ? 0.42 : 0.32) +} + function drawGlow( ctx: CanvasRenderingContext2D, sx: number, @@ -678,3 +987,33 @@ export function lightenColor(hex: string, amount: number): string { _lightenCache = { input: hex, amount, result } return result } + +export function mixHexColors( + base: string, + overlay: string, + amount: number, +): string { + const baseRgb = parseHexColor(base) + const overlayRgb = parseHexColor(overlay) + if (!baseRgb || !overlayRgb) return base + + const t = clampNumber(amount, 0, 1) + const r = Math.round(baseRgb.r + (overlayRgb.r - baseRgb.r) * t) + const g = Math.round(baseRgb.g + (overlayRgb.g - baseRgb.g) * t) + const b = Math.round(baseRgb.b + (overlayRgb.b - baseRgb.b) * t) + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +function parseHexColor(hex: string) { + const raw = hex.startsWith("#") ? hex.slice(1) : hex + if (!/^[0-9a-fA-F]{6}$/.test(raw)) return null + return { + r: Number.parseInt(raw.slice(0, 2), 16), + g: Number.parseInt(raw.slice(2, 4), 16), + b: Number.parseInt(raw.slice(4, 6), 16), + } +} + +function toHex(value: number): string { + return value.toString(16).padStart(2, "0") +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 44271793..b04cee27 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -1,7 +1,9 @@ import * as d3 from "d3-force" -import type { GraphEdge, GraphNode } from "../types" +import type { DocumentNodeData, GraphEdge, GraphNode } from "../types" import { FORCE_CONFIG } from "../constants" +export const DENSE_GRAPH_STATIC_THRESHOLD = 6000 + export class ForceSimulation { private sim: d3.Simulation | null = null @@ -27,7 +29,7 @@ export class ForceSimulation { .id((d) => d.id) .distance((link) => link.edgeType === "derives" - ? FORCE_CONFIG.docMemoryDistance + ? getDocMemoryDistance(link) : FORCE_CONFIG.linkDistance, ) .strength((link) => { @@ -61,8 +63,17 @@ export class ForceSimulation { this.sim.stop() this.sim.alpha(1) - for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick() - this.sim.alphaTarget(0).restart() + const preSettleTicks = + nodes.length > DENSE_GRAPH_STATIC_THRESHOLD + ? FORCE_CONFIG.densePreSettleTicks + : FORCE_CONFIG.preSettleTicks + for (let i = 0; i < preSettleTicks; i++) this.sim.tick() + + if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) { + this.stop() + } else { + this.sim.alphaTarget(0).restart() + } } catch (e) { console.error("ForceSimulation.init failed:", e) this.destroy() @@ -85,6 +96,10 @@ export class ForceSimulation { this.sim?.alphaTarget(0) } + stop(): void { + this.sim?.alpha(0).alphaTarget(0).stop() + } + isActive(): boolean { return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin } @@ -96,3 +111,24 @@ export class ForceSimulation { } } } + +function getDocMemoryDistance(link: GraphEdge): number { + const source = resolveNode(link.source) + const target = resolveNode(link.target) + const docNode = + source?.type === "document" + ? source + : target?.type === "document" + ? target + : null + const memoryCount = + docNode != null ? (docNode.data as DocumentNodeData).memories.length : 1 + const distance = + FORCE_CONFIG.docMemoryDistance + + Math.sqrt(Math.max(1, memoryCount)) * FORCE_CONFIG.docMemoryDistanceScale + return Math.min(FORCE_CONFIG.docMemoryDistanceMax, distance) +} + +function resolveNode(endpoint: string | GraphNode): GraphNode | null { + return typeof endpoint === "string" ? null : endpoint +} diff --git a/packages/memory-graph/src/canvas/version-chain.ts b/packages/memory-graph/src/canvas/version-chain.ts index 6c692fa7..754bce23 100644 --- a/packages/memory-graph/src/canvas/version-chain.ts +++ b/packages/memory-graph/src/canvas/version-chain.ts @@ -80,13 +80,22 @@ export class VersionChainIndex { // A single-entry chain (standalone v1 with no children) is not useful if (all.length <= 1) return null - const chain: ChainEntry[] = all.map((m) => ({ - id: m.id, - version: m.version, - memory: m.memory, - isForgotten: m.isForgotten, - isLatest: m.isLatest, - })) + let lastVersion = 0 + const chain: ChainEntry[] = all.map((m) => { + const version = + Number.isFinite(m.version) && m.version > lastVersion + ? m.version + : lastVersion + 1 + lastVersion = version + + return { + id: m.id, + version, + memory: m.memory, + isForgotten: m.isForgotten, + isLatest: m.isLatest, + } + }) for (const entry of chain) { this.cache.set(entry.id, chain) diff --git a/packages/memory-graph/src/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts index b741097b..c33a1681 100644 --- a/packages/memory-graph/src/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -16,8 +16,10 @@ export class ViewportState { private targetPanY: number | null = null private readonly panLerp = 0.12 - private static readonly MIN_ZOOM = 0.1 + private static readonly DEFAULT_MIN_ZOOM = 0.1 + private static readonly ABSOLUTE_MIN_ZOOM = 0.005 private static readonly MAX_ZOOM = 5.0 + private minZoom = ViewportState.DEFAULT_MIN_ZOOM constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) { this.panX = initialPanX @@ -54,22 +56,14 @@ export class ViewportState { zoomImmediate(delta: number, anchorX: number, anchorY: number): void { const world = this.screenToWorld(anchorX, anchorY) - this.zoom = clamp( - this.zoom * delta, - ViewportState.MIN_ZOOM, - ViewportState.MAX_ZOOM, - ) + this.zoom = clamp(this.zoom * delta, this.minZoom, ViewportState.MAX_ZOOM) this.targetZoom = this.zoom this.panX = anchorX - world.x * this.zoom this.panY = anchorY - world.y * this.zoom } zoomTo(target: number, anchorX: number, anchorY: number): void { - this.targetZoom = clamp( - target, - ViewportState.MIN_ZOOM, - ViewportState.MAX_ZOOM, - ) + this.targetZoom = clamp(target, this.minZoom, ViewportState.MAX_ZOOM) this.zoomAnchorX = anchorX this.zoomAnchorY = anchorY } @@ -79,38 +73,40 @@ export class ViewportState { width: number, height: number, ): void { - if (nodes.length === 0) return + const fit = computeFit(nodes, width, height) + if (!fit) return - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY + const { cx, cy, fitZoom } = fit - for (const n of nodes) { - minX = Math.min(minX, n.x - n.size) - maxX = Math.max(maxX, n.x + n.size) - minY = Math.min(minY, n.y - n.size) - maxY = Math.max(maxY, n.y + n.size) - } - - const pad = 0.1 - const cw = (maxX - minX) * (1 + pad * 2) - const ch = (maxY - minY) * (1 + pad * 2) - const cx = (minX + maxX) / 2 - const cy = (minY + maxY) / 2 - - const fitZoom = Math.min(width / cw, height / ch, 1) - this.targetZoom = clamp( - fitZoom, - ViewportState.MIN_ZOOM, - ViewportState.MAX_ZOOM, - ) + this.targetZoom = clamp(fitZoom, this.minZoom, ViewportState.MAX_ZOOM) this.zoomAnchorX = width / 2 this.zoomAnchorY = height / 2 this.targetPanX = width / 2 - cx * this.targetZoom this.targetPanY = height / 2 - cy * this.targetZoom } + setMinZoomForNodes( + nodes: Array<{ x: number; y: number; size: number }>, + width: number, + height: number, + ): void { + const fit = computeFit(nodes, width, height) + const nextMinZoom = fit + ? Math.min(ViewportState.DEFAULT_MIN_ZOOM, fit.fitZoom) + : ViewportState.DEFAULT_MIN_ZOOM + this.minZoom = clamp( + nextMinZoom, + ViewportState.ABSOLUTE_MIN_ZOOM, + ViewportState.DEFAULT_MIN_ZOOM, + ) + this.zoom = clamp(this.zoom, this.minZoom, ViewportState.MAX_ZOOM) + this.targetZoom = clamp( + this.targetZoom, + this.minZoom, + ViewportState.MAX_ZOOM, + ) + } + centerOn( worldX: number, worldY: number, @@ -163,6 +159,38 @@ export class ViewportState { } } +function computeFit( + nodes: Array<{ x: number; y: number; size: number }>, + width: number, + height: number, +): { cx: number; cy: number; fitZoom: number } | null { + if (nodes.length === 0 || width <= 0 || height <= 0) return null + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const n of nodes) { + minX = Math.min(minX, n.x - n.size) + maxX = Math.max(maxX, n.x + n.size) + minY = Math.min(minY, n.y - n.size) + maxY = Math.max(maxY, n.y + n.size) + } + + const pad = 0.1 + const cw = Math.max((maxX - minX) * (1 + pad * 2), 1) + const ch = Math.max((maxY - minY) * (1 + pad * 2), 1) + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + + return { + cx, + cy, + fitZoom: Math.min(width / cw, height / ch, 1), + } +} + function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v } diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index bd6b2f3a..32c520af 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -6,6 +6,7 @@ interface LegendProps { edges?: GraphEdge[] isLoading?: boolean colors: GraphThemeColors + hoveredNode?: string | null compact?: boolean maxHeight?: number } @@ -40,32 +41,100 @@ function HexagonIcon({ function LineIcon({ color, dashed = false, + arrow = false, }: { color: string dashed?: boolean + arrow?: boolean }) { return ( -