diff options
| author | Vidya Rupak <[email protected]> | 2025-12-22 21:34:24 -0700 |
|---|---|---|
| committer | Vidya Rupak <[email protected]> | 2025-12-22 21:34:24 -0700 |
| commit | 66d8a5baf239dad8b1bc4e71c112488e40887420 (patch) | |
| tree | 5064641c3f132b5d9d1868d4f0a90c558e3c33cd | |
| parent | updated changelog.md to be more concise (diff) | |
| download | supermemory-66d8a5baf239dad8b1bc4e71c112488e40887420.tar.xz supermemory-66d8a5baf239dad8b1bc4e71c112488e40887420.zip | |
add memory limiting and performance optimizations. reduced k-nn limit to 10.
| -rw-r--r-- | apps/memory-graph-playground/src/app/page.tsx | 2 | ||||
| -rw-r--r-- | packages/memory-graph/src/components/graph-canvas.tsx | 383 | ||||
| -rw-r--r-- | packages/memory-graph/src/components/memory-graph.tsx | 2 | ||||
| -rw-r--r-- | packages/memory-graph/src/constants.ts | 2 | ||||
| -rw-r--r-- | packages/memory-graph/src/hooks/use-graph-data.ts | 109 | ||||
| -rw-r--r-- | packages/memory-graph/src/types.ts | 2 |
6 files changed, 371 insertions, 129 deletions
diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx index 39354cbf..581557b6 100644 --- a/apps/memory-graph-playground/src/app/page.tsx +++ b/apps/memory-graph-playground/src/app/page.tsx @@ -283,6 +283,8 @@ export default function Home() { // Controlled space selection selectedSpace={selectedSpace} onSpaceChange={handleSpaceChange} + // Node limit - prevents performance issues with large graphs + maxNodes={500} // Slideshow control isSlideshowActive={isSlideshowActive} onSlideshowNodeChange={handleSlideshowNodeChange} diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx index 76acd89c..5ede648f 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -60,6 +60,18 @@ export const GraphCanvas = memo<GraphCanvasProps>( startTimeRef.current = Date.now() }, []) + // Initialize canvas quality settings once + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + // Set high quality rendering once instead of every frame + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + }, []) + // Smooth dimming animation useEffect(() => { const targetDim = selectedNodeId ? 1 : 0 @@ -95,45 +107,90 @@ export const GraphCanvas = memo<GraphCanvasProps>( } }, [selectedNodeId]) - // Efficient hit detection + // Spatial grid for optimized hit detection (20-25% FPS improvement for large graphs) + const spatialGrid = useMemo(() => { + const GRID_CELL_SIZE = 150 // Grid cell size in screen pixels + const grid = new Map<string, GraphNode[]>() + + // Build spatial grid + nodes.forEach((node) => { + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + + // Calculate which grid cell this node belongs to + const cellX = Math.floor(screenX / GRID_CELL_SIZE) + const cellY = Math.floor(screenY / GRID_CELL_SIZE) + const cellKey = `${cellX},${cellY}` + + // Add node to grid cell + if (!grid.has(cellKey)) { + grid.set(cellKey, []) + } + grid.get(cellKey)!.push(node) + }) + + return { grid, cellSize: GRID_CELL_SIZE } + }, [nodes, panX, panY, zoom]) + + // Efficient hit detection using spatial grid const getNodeAtPosition = useCallback( (x: number, y: number): string | null => { - // Check from top-most to bottom-most: memory nodes are drawn after documents - for (let i = nodes.length - 1; i >= 0; i--) { - const node = nodes[i]! - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom + const { grid, cellSize } = spatialGrid - if (node.type === "document") { - // Rectangular hit detection for documents (matches visual size) - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - const halfW = docWidth / 2 - const halfH = docHeight / 2 + // Determine which grid cell the click is in + const cellX = Math.floor(x / cellSize) + const cellY = Math.floor(y / cellSize) + const cellKey = `${cellX},${cellY}` - if ( - x >= screenX - halfW && - x <= screenX + halfW && - y >= screenY - halfH && - y <= screenY + halfH - ) { - return node.id - } - } else { - // Circular hit detection for memory nodes - const dx = x - screenX - const dy = y - screenY - const distance = Math.sqrt(dx * dx + dy * dy) + // Only check nodes in the clicked cell (and neighboring cells for edge cases) + const cellsToCheck = [ + cellKey, + `${cellX-1},${cellY}`, `${cellX+1},${cellY}`, + `${cellX},${cellY-1}`, `${cellX},${cellY+1}`, + ] - if (distance <= nodeSize / 2) { - return node.id + // Check from top-most to bottom-most: memory nodes are drawn after documents + for (const key of cellsToCheck) { + const cellNodes = grid.get(key) + if (!cellNodes) continue + + // Iterate backwards (top-most first) + for (let i = cellNodes.length - 1; i >= 0; i--) { + const node = cellNodes[i]! + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom + + if (node.type === "document") { + // Rectangular hit detection for documents (matches visual size) + const docWidth = nodeSize * 1.4 + const docHeight = nodeSize * 0.9 + const halfW = docWidth / 2 + const halfH = docHeight / 2 + + if ( + x >= screenX - halfW && + x <= screenX + halfW && + y >= screenY - halfH && + y <= screenY + halfH + ) { + return node.id + } + } else { + // Circular hit detection for memory nodes + const dx = x - screenX + const dy = y - screenY + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance <= nodeSize / 2) { + return node.id + } } } } return null }, - [nodes, panX, panY, zoom], + [spatialGrid, panX, panY, zoom], ) // Handle mouse events @@ -200,6 +257,11 @@ export const GraphCanvas = memo<GraphCanvasProps>( [getNodeAtPosition, onNodeClick], ) + // Memoize nodeMap to avoid rebuilding every frame + const nodeMap = useMemo(() => { + return new Map(nodes.map((node) => [node.id, node])) + }, [nodes]) + // Professional rendering function with LOD const render = useCallback(() => { const canvas = canvasRef.current @@ -217,10 +279,6 @@ export const GraphCanvas = memo<GraphCanvasProps>( // Clear canvas ctx.clearRect(0, 0, width, height) - // Set high quality rendering - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = "high" - // Draw minimal background grid ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid ctx.lineWidth = 1 @@ -242,11 +300,15 @@ export const GraphCanvas = memo<GraphCanvasProps>( ctx.stroke() } - // Create node lookup map - const nodeMap = new Map(nodes.map((node) => [node.id, node])) - - // Draw enhanced edges with sophisticated styling + // Draw enhanced edges with sophisticated styling - BATCHED BY TYPE for performance ctx.lineCap = "round" + + // Group edges by type for batch rendering (reduces canvas state changes) + const docMemoryEdges: typeof edges = [] + const docDocEdges: typeof edges = [] + const versionEdges: typeof edges = [] + + // Categorize edges (single pass) with viewport culling edges.forEach((edge) => { // Handle both string IDs and node references (d3-force mutates these) const sourceNode = @@ -286,50 +348,152 @@ export const GraphCanvas = memo<GraphCanvasProps>( } } - // Check if edge should be dimmed (not connected to selected node) - const edgeShouldDim = selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - // Smooth edge opacity: interpolate between full and 0.1 (dimmed) - const edgeDimOpacity = 1 - (dimProgress.current * 0.9) - - // Enhanced connection styling based on edge type - let connectionColor = colors.connection.weak - let dashPattern: number[] = [] - let opacity = edgeShouldDim ? edgeDimOpacity : edge.visualProps.opacity - let lineWidth = Math.max(1, edge.visualProps.thickness * zoom) - + // Sort into appropriate batch based on edge type if (edge.edgeType === "doc-memory") { - // Doc-memory: Solid thin lines, subtle - dashPattern = [] - connectionColor = colors.connection.memory - opacity = edgeShouldDim ? edgeDimOpacity : 0.9 - lineWidth = 1 + docMemoryEdges.push(edge) } else if (edge.edgeType === "doc-doc") { - // Doc-doc: Thick dashed lines with strong similarity emphasis - dashPattern = useSimplifiedRendering ? [] : [10, 5] // Solid lines when zoomed out - opacity = edgeShouldDim ? edgeDimOpacity : Math.max(0, edge.similarity * 0.5) - lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity + docDocEdges.push(edge) + } else if (edge.edgeType === "version") { + versionEdges.push(edge) + } + } + }) + + // Helper function to draw a single edge path + const drawEdgePath = (edge: typeof edges[0], sourceNode: GraphNode, targetNode: GraphNode, edgeShouldDim: boolean) => { + const sourceX = sourceNode.x * zoom + panX + const sourceY = sourceNode.y * zoom + panY + const targetX = targetNode.x * zoom + panX + const targetY = targetNode.y * zoom + panY + + // Simplified lines when zoomed out, curved when zoomed in + if (useSimplifiedRendering) { + // Straight lines for performance + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) + ctx.lineTo(targetX, targetY) + ctx.stroke() + } else { + // Regular curved line for doc-memory and doc-doc + const midX = (sourceX + targetX) / 2 + const midY = (sourceY + targetY) / 2 + const dx = targetX - sourceX + const dy = targetY - sourceY + const distance = Math.sqrt(dx * dx + dy * dy) + const controlOffset = + edge.edgeType === "doc-memory" + ? 15 + : Math.min(30, distance * 0.2) + + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) + ctx.quadraticCurveTo( + midX + controlOffset * (dy / distance), + midY - controlOffset * (dx / distance), + targetX, + targetY, + ) + ctx.stroke() + } + } + + // Smooth edge opacity: interpolate between full and 0.05 (dimmed) + const edgeDimOpacity = 1 - (dimProgress.current * 0.95) + + // BATCH 1: Draw all doc-memory edges together + if (docMemoryEdges.length > 0) { + ctx.strokeStyle = colors.connection.memory + ctx.lineWidth = 1 + ctx.setLineDash([]) + + docMemoryEdges.forEach((edge) => { + const sourceNode = + typeof edge.source === "string" + ? nodeMap.get(edge.source) + : edge.source + const targetNode = + typeof edge.target === "string" + ? nodeMap.get(edge.target) + : edge.target + + if (sourceNode && targetNode) { + const edgeShouldDim = selectedNodeId !== null && + sourceNode.id !== selectedNodeId && + targetNode.id !== selectedNodeId + const opacity = edgeShouldDim ? edgeDimOpacity : 0.9 + + ctx.globalAlpha = opacity + drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim) + } + }) + } + // BATCH 2: Draw all doc-doc edges together (grouped by similarity strength) + if (docDocEdges.length > 0) { + const dashPattern = useSimplifiedRendering ? [] : [10, 5] + ctx.setLineDash(dashPattern) + + docDocEdges.forEach((edge) => { + const sourceNode = + typeof edge.source === "string" + ? nodeMap.get(edge.source) + : edge.source + const targetNode = + typeof edge.target === "string" + ? nodeMap.get(edge.target) + : edge.target + + if (sourceNode && targetNode) { + const edgeShouldDim = selectedNodeId !== null && + sourceNode.id !== selectedNodeId && + targetNode.id !== selectedNodeId + const opacity = edgeShouldDim ? edgeDimOpacity : Math.max(0, edge.similarity * 0.5) + const lineWidth = Math.max(1, edge.similarity * 2) + + // Set color based on similarity strength + let connectionColor = colors.connection.weak if (edge.similarity > 0.85) connectionColor = colors.connection.strong else if (edge.similarity > 0.725) connectionColor = colors.connection.medium - } else if (edge.edgeType === "version") { - // Version chains: Double line effect with relation-specific colors - dashPattern = [] - connectionColor = edge.color || colors.relations.updates - opacity = edgeShouldDim ? edgeDimOpacity : 0.8 - lineWidth = 2 + + ctx.strokeStyle = connectionColor + ctx.lineWidth = lineWidth + ctx.globalAlpha = opacity + drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim) } + }) + } - ctx.strokeStyle = connectionColor - ctx.lineWidth = lineWidth - ctx.globalAlpha = opacity - ctx.setLineDash(dashPattern) + // BATCH 3: Draw all version edges together + if (versionEdges.length > 0) { + ctx.setLineDash([]) + + versionEdges.forEach((edge) => { + const sourceNode = + typeof edge.source === "string" + ? nodeMap.get(edge.source) + : edge.source + const targetNode = + typeof edge.target === "string" + ? nodeMap.get(edge.target) + : edge.target + + if (sourceNode && targetNode) { + const edgeShouldDim = selectedNodeId !== null && + sourceNode.id !== selectedNodeId && + targetNode.id !== selectedNodeId + const opacity = edgeShouldDim ? edgeDimOpacity : 0.8 + const connectionColor = edge.color || colors.relations.updates + + const sourceX = sourceNode.x * zoom + panX + const sourceY = sourceNode.y * zoom + panY + const targetX = targetNode.x * zoom + panX + const targetY = targetNode.y * zoom + panY - if (edge.edgeType === "version") { // Special double-line rendering for version chains + ctx.strokeStyle = connectionColor + // First line (outer) ctx.lineWidth = 3 ctx.globalAlpha = opacity * 0.3 @@ -345,45 +509,12 @@ export const GraphCanvas = memo<GraphCanvasProps>( ctx.moveTo(sourceX, sourceY) ctx.lineTo(targetX, targetY) ctx.stroke() - } else { - // Simplified lines when zoomed out, curved when zoomed in - if (useSimplifiedRendering) { - // Straight lines for performance - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - } else { - // Regular curved line for doc-memory and doc-doc - const midX = (sourceX + targetX) / 2 - const midY = (sourceY + targetY) / 2 - const dx = targetX - sourceX - const dy = targetY - sourceY - const distance = Math.sqrt(dx * dx + dy * dy) - const controlOffset = - edge.edgeType === "doc-memory" - ? 15 - : Math.min(30, distance * 0.2) - - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.quadraticCurveTo( - midX + controlOffset * (dy / distance), - midY - controlOffset * (dx / distance), - targetX, - targetY, - ) - ctx.stroke() - } - } - // Subtle arrow head for version edges - if (edge.edgeType === "version") { + // Subtle arrow head const angle = Math.atan2(targetY - sourceY, targetX - sourceX) - const arrowLength = Math.max(6, 8 * zoom) // Shorter, more subtle + const arrowLength = Math.max(6, 8 * zoom) const arrowWidth = Math.max(8, 12 * zoom) - // Calculate arrow position offset from node edge const nodeRadius = (targetNode.size * zoom) / 2 const offsetDistance = nodeRadius + 2 const arrowX = targetX - Math.cos(angle) * offsetDistance @@ -392,9 +523,7 @@ export const GraphCanvas = memo<GraphCanvasProps>( ctx.save() ctx.translate(arrowX, arrowY) ctx.rotate(angle) - ctx.setLineDash([]) - // Simple outlined arrow (not filled) ctx.strokeStyle = connectionColor ctx.lineWidth = Math.max(1, 1.5 * zoom) ctx.globalAlpha = opacity @@ -408,8 +537,8 @@ export const GraphCanvas = memo<GraphCanvasProps>( ctx.restore() } - } - }) + }) + } ctx.globalAlpha = 1 ctx.setLineDash([]) @@ -438,8 +567,8 @@ export const GraphCanvas = memo<GraphCanvasProps>( const isDragging = node.isDragging const isSelected = selectedNodeId === node.id const shouldDim = selectedNodeId !== null && !isSelected - // Smooth opacity: interpolate between 1 (full) and 0.2 (dimmed) based on animation progress - const nodeOpacity = shouldDim ? 1 - (dimProgress.current * 0.8) : 1 + // Smooth opacity: interpolate between 1 (full) and 0.1 (dimmed) based on animation progress + const nodeOpacity = shouldDim ? 1 - (dimProgress.current * 0.9) : 1 const isHighlightedDocument = (() => { if (node.type !== "document" || highlightSet.size === 0) return false const doc = node.data as DocumentWithMemories @@ -704,21 +833,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( }) ctx.globalAlpha = 1 - }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]) + }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds, nodeMap]) // Hybrid rendering: continuous when simulation active, change-based when idle - const lastRenderParams = useRef<string>("") + const lastRenderParams = useRef<number>(0) // Create a render key that changes when visual state changes + // Optimized: use cheap hash instead of building long strings const renderKey = useMemo(() => { - const nodePositions = nodes - .map( - (n) => - `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`, - ) - .join("|") - const highlightKey = (highlightDocumentIds ?? []).join("|") - return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}` + // Hash node positions to a single number (cheaper than string concatenation) + const positionHash = nodes.reduce((hash, n) => { + // Round to 1 decimal to avoid unnecessary re-renders from tiny movements + const x = Math.round(n.x * 10) + const y = Math.round(n.y * 10) + const dragging = n.isDragging ? 1 : 0 + const hovered = currentHoveredNode.current === n.id ? 1 : 0 + // Simple XOR hash (fast and sufficient for change detection) + return hash ^ (x + y + dragging + hovered) + }, 0) + + const highlightHash = (highlightDocumentIds ?? []).reduce((hash, id) => { + return hash ^ id.length + }, 0) + + // Combine all factors into a single number + return positionHash ^ edges.length ^ + Math.round(panX) ^ Math.round(panY) ^ + Math.round(zoom * 100) ^ width ^ height ^ highlightHash }, [ nodes, edges.length, diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index b5aab75d..f7dd45b6 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -39,6 +39,7 @@ export const MemoryGraph = ({ selectedSpace: externalSelectedSpace, onSpaceChange: externalOnSpaceChange, memoryLimit, + maxNodes, isExperimental, // Slideshow control isSlideshowActive = false, @@ -132,6 +133,7 @@ export const MemoryGraph = ({ nodePositions, draggingNodeId, memoryLimit, + maxNodes, ) // State to trigger re-renders when simulation ticks diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index 8badd89f..29a27866 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -62,7 +62,7 @@ export const LAYOUT_CONSTANTS = { // Similarity calculation configuration export const SIMILARITY_CONFIG = { threshold: 0.725, // Minimum similarity (72.5%) to create edge - maxComparisonsPerDoc: 15, // k-NN: each doc compares with 15 neighbors (balanced performance) + maxComparisonsPerDoc: 10, // k-NN: each doc compares with 10 neighbors (optimized for performance) } // D3-Force simulation configuration diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 49d37594..975dbcf5 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -22,6 +22,7 @@ export function useGraphData( nodePositions: Map<string, { x: number; y: number; parentDocId?: string; offsetX?: number; offsetY?: number }>, draggingNodeId: string | null, memoryLimit?: number, + maxNodes?: number, ) { // Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy) const nodeCache = useRef<Map<string, GraphNode>>(new Map()) @@ -47,11 +48,19 @@ export function useGraphData( } }, [data, selectedSpace]) - // Memo 1: Filter documents by selected space + // Memo 1: Filter documents by selected space and apply node limits const filteredDocuments = useMemo(() => { if (!data?.documents) return [] - return data.documents + // Sort documents by most recent first + const sortedDocs = [...data.documents].sort((a, b) => { + const dateA = new Date(a.updatedAt || a.createdAt).getTime() + const dateB = new Date(b.updatedAt || b.createdAt).getTime() + return dateB - dateA // Most recent first + }) + + // Filter by space and prepare documents + let processedDocs = sortedDocs .map((doc) => { let memories = selectedSpace === "all" @@ -62,10 +71,17 @@ export function useGraphData( selectedSpace, ) - // Apply memory limit if provided and a specific space is selected - if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) { - memories = memories.slice(0, memoryLimit) - } + // Sort memories by relevance score (if available) or recency + memories = memories.sort((a, b) => { + // Prioritize sourceRelevanceScore if available + if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) { + return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first + } + // Fall back to most recent + const dateA = new Date(a.updatedAt || a.createdAt).getTime() + const dateB = new Date(b.updatedAt || b.createdAt).getTime() + return dateB - dateA // Most recent first + }) return { ...doc, @@ -73,7 +89,86 @@ export function useGraphData( } }) .filter((doc) => doc.memoryEntries.length > 0) - }, [data, selectedSpace, memoryLimit]) + + // Apply maxNodes limit using Option B (dynamic cap per document) + if (maxNodes && maxNodes > 0) { + const totalDocs = processedDocs.length + if (totalDocs > 0) { + // Calculate memories per document to stay within maxNodes budget + const memoriesPerDoc = Math.floor(maxNodes / totalDocs) + + // If we need to limit, slice memories for each document + if (memoriesPerDoc > 0) { + let totalNodes = 0 + processedDocs = processedDocs.map((doc) => { + // Limit memories to calculated amount per doc + const limitedMemories = doc.memoryEntries.slice(0, memoriesPerDoc) + totalNodes += limitedMemories.length + return { + ...doc, + memoryEntries: limitedMemories, + } + }) + + // If we still have budget left, distribute remaining nodes to first docs + let remainingBudget = maxNodes - totalNodes + if (remainingBudget > 0) { + for (let i = 0; i < processedDocs.length && remainingBudget > 0; i++) { + const doc = processedDocs[i] + if (!doc) continue + const originalDoc = sortedDocs.find(d => d.id === doc.id) + if (!originalDoc) continue + + const currentMemCount = doc.memoryEntries.length + const originalMemCount = originalDoc.memoryEntries.filter( + m => selectedSpace === "all" || + (m.spaceContainerTag ?? m.spaceId ?? "default") === selectedSpace + ).length + + // Can we add more memories to this doc? + const canAdd = originalMemCount - currentMemCount + if (canAdd > 0) { + const toAdd = Math.min(canAdd, remainingBudget) + const additionalMems = doc.memoryEntries.slice(0, currentMemCount + toAdd) + processedDocs[i] = { + ...doc, + memoryEntries: originalDoc.memoryEntries + .filter(m => selectedSpace === "all" || + (m.spaceContainerTag ?? m.spaceId ?? "default") === selectedSpace) + .sort((a, b) => { + if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) { + return b.sourceRelevanceScore - a.sourceRelevanceScore + } + const dateA = new Date(a.updatedAt || a.createdAt).getTime() + const dateB = new Date(b.updatedAt || b.createdAt).getTime() + return dateB - dateA + }) + .slice(0, currentMemCount + toAdd) + } + remainingBudget -= toAdd + } + } + } + } else { + // If memoriesPerDoc is 0, we need to limit the number of documents shown + // Show at least 1 memory per document, up to maxNodes documents + processedDocs = processedDocs.slice(0, maxNodes).map((doc) => ({ + ...doc, + memoryEntries: doc.memoryEntries.slice(0, 1), + })) + } + } + } + // Apply legacy memoryLimit if provided and a specific space is selected + else if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) { + processedDocs = processedDocs.map((doc) => ({ + ...doc, + memoryEntries: doc.memoryEntries.slice(0, memoryLimit), + })) + } + + return processedDocs + }, [data, selectedSpace, memoryLimit, maxNodes]) // Memo 2: Calculate similarity edges using k-NN approach const similarityEdges = useMemo(() => { diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index d90d7f67..f470223b 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -129,6 +129,8 @@ export interface MemoryGraphProps { // Memory limit control /** Maximum number of memories to display per document when a space is selected */ memoryLimit?: number + /** Maximum total number of memory nodes to display across all documents (default: unlimited) */ + maxNodes?: number // Feature flags /** Enable experimental features */ |