const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const graphToggle = document.querySelector("#memory-graph-toggle"); const graphPanel = document.querySelector("#memory-graph-panel"); const graphClose = document.querySelector("#memory-graph-close"); const graphReset = document.querySelector("#memory-graph-reset"); const graphSvg = document.querySelector("#memory-graph-svg"); const graphStatus = document.querySelector("#memory-graph-status"); const graphViewButtons = [...document.querySelectorAll("[data-graph-view]")]; const relationshipList = document.querySelector("#memory-relationship-list"); const relationshipCount = document.querySelector("#memory-relationship-count"); const relationshipNote = document.querySelector("#memory-relationship-note"); const graphState = { payload: null, loading: null, mode: "retrieved", retrievalQuery: "", retrievedIds: [], retrievedRanks: new Map(), selectedNodeId: null, visibleNodes: [], visibleEdges: [], nodeById: new Map(), positions: new Map(), viewport: { x: 0, y: 0, scale: 1 }, pan: null, }; function svgElement(name, attributes = {}) { const element = document.createElementNS(SVG_NAMESPACE, name); for (const [key, value] of Object.entries(attributes)) { element.setAttribute(key, String(value)); } return element; } function graphNodeLabel(node) { if (!node) return "Unknown"; if (node.kind === "memory") { const rank = graphState.retrievedRanks.get(node.id); return rank ? `Memory ${String(rank).padStart(2, "0")}` : "Memory"; } return node.name || node.id; } function graphNodeDescription(node) { if (!node) return "Unknown node"; if (node.kind === "memory") return node.text || node.evidence_text || node.id; return `${node.name || node.id}${node.type ? ` · ${node.type}` : ""}`; } function predicateLabel(predicate) { return String(predicate || "RELATED_TO") .toLocaleLowerCase() .replaceAll("_", " "); } function shortLabel(value, length = 25) { const text = String(value || ""); return text.length > length ? `${text.slice(0, length - 1)}…` : text; } function hashNumber(value) { let hash = 2166136261; for (const character of value) { hash ^= character.codePointAt(0); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function calculateLayout(nodes, edges) { const positions = nodes.map((node, index) => { const hash = hashNumber(node.id); const angle = ((hash % 3600) / 3600) * Math.PI * 2; const kindRadius = node.kind === "memory" ? 245 : node.kind === "value" ? 185 : 120; const jitter = ((hash >>> 12) % 70) - 35; return { x: 500 + Math.cos(angle) * (kindRadius + jitter) + ((index % 5) - 2), y: 310 + Math.sin(angle) * (kindRadius + jitter) * 0.72, }; }); const indexById = new Map(nodes.map((node, index) => [node.id, index])); const links = edges .map((edge) => [indexById.get(edge.source), indexById.get(edge.target)]) .filter(([source, target]) => source !== undefined && target !== undefined); const iterations = nodes.length > 160 ? 70 : 105; for (let iteration = 0; iteration < iterations; iteration += 1) { const forceX = new Float64Array(nodes.length); const forceY = new Float64Array(nodes.length); for (let index = 0; index < nodes.length; index += 1) { forceX[index] += (500 - positions[index].x) * 0.004; forceY[index] += (310 - positions[index].y) * 0.004; } for (let left = 0; left < nodes.length; left += 1) { for (let right = left + 1; right < nodes.length; right += 1) { let dx = positions[left].x - positions[right].x; let dy = positions[left].y - positions[right].y; let distanceSquared = dx * dx + dy * dy; if (distanceSquared < 4) { dx = ((left % 3) - 1) || 0.5; dy = ((right % 3) - 1) || -0.5; distanceSquared = dx * dx + dy * dy; } const strength = (nodes.length > 160 ? 730 : 1250) / Math.max(distanceSquared, 36); const x = dx * strength; const y = dy * strength; forceX[left] += x; forceY[left] += y; forceX[right] -= x; forceY[right] -= y; } } for (const [source, target] of links) { const dx = positions[target].x - positions[source].x; const dy = positions[target].y - positions[source].y; const distance = Math.max(Math.hypot(dx, dy), 1); const ideal = nodes[source].kind === "memory" || nodes[target].kind === "memory" ? 52 : 86; const strength = (distance - ideal) * 0.018; const x = (dx / distance) * strength; const y = (dy / distance) * strength; forceX[source] += x; forceY[source] += y; forceX[target] -= x; forceY[target] -= y; } const cooling = 0.9 - (iteration / iterations) * 0.48; for (let index = 0; index < nodes.length; index += 1) { positions[index].x += Math.max(-8, Math.min(8, forceX[index])) * cooling; positions[index].y += Math.max(-8, Math.min(8, forceY[index])) * cooling; } } const xs = positions.map((position) => position.x); const ys = positions.map((position) => position.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const width = Math.max(maxX - minX, 1); const height = Math.max(maxY - minY, 1); return new Map(nodes.map((node, index) => [node.id, { x: 65 + ((positions[index].x - minX) / width) * 870, y: 60 + ((positions[index].y - minY) / height) * 500, }])); } function graphBackedRetrievalIds() { if (!graphState.payload) return []; const ids = new Set(graphState.payload.nodes.map((node) => node.id)); return graphState.retrievedIds.filter((id) => ids.has(id)); } function selectSubgraph() { const allNodes = graphState.payload.nodes; const allEdges = graphState.payload.edges; const retrievedIds = new Set(graphBackedRetrievalIds()); if (graphState.mode !== "retrieved" || !retrievedIds.size) { return { nodes: allNodes, edges: allEdges, focused: false }; } const nodeIds = new Set(retrievedIds); const edges = allEdges.filter((edge) => { const related = retrievedIds.has(edge.evidence_id) || retrievedIds.has(edge.source) || retrievedIds.has(edge.target); if (related) { nodeIds.add(edge.source); nodeIds.add(edge.target); if (edge.evidence_id) nodeIds.add(edge.evidence_id); } return related; }); return { nodes: allNodes.filter((node) => nodeIds.has(node.id)), edges, focused: true, }; } function applyViewport() { const scene = graphSvg.querySelector(".memory-graph-scene"); if (!scene) return; const { x, y, scale } = graphState.viewport; scene.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`); } function resetViewport() { graphState.viewport = { x: 0, y: 0, scale: 1 }; applyViewport(); } function selectNode(nodeId) { if (!graphState.nodeById.has(nodeId)) return; graphState.selectedNodeId = nodeId; for (const element of graphSvg.querySelectorAll(".memory-graph-node")) { element.classList.toggle("is-selected", element.dataset.nodeId === nodeId); } renderRelationships(); } function nodeChip(nodeId) { const node = graphState.nodeById.get(nodeId); const chip = document.createElement("button"); chip.type = "button"; chip.className = "graph-node-chip"; chip.dataset.nodeKind = node?.kind || "unknown"; chip.textContent = graphNodeLabel(node) || nodeId; chip.title = graphNodeDescription(node); chip.addEventListener("click", () => selectNode(nodeId)); return chip; } function relationshipCandidates() { const selected = graphState.nodeById.get(graphState.selectedNodeId); let candidates = graphState.visibleEdges; if (selected) { candidates = selected.kind === "memory" ? candidates.filter((edge) => ( edge.evidence_id === selected.id || edge.source === selected.id || edge.target === selected.id )) : candidates.filter((edge) => edge.source === selected.id || edge.target === selected.id); } const semantic = candidates.filter((edge) => edge.predicate !== "ABOUT_SUBJECT"); return semantic.length ? semantic : candidates; } function renderRelationships() { relationshipList.replaceChildren(); const selected = graphState.nodeById.get(graphState.selectedNodeId); const candidates = relationshipCandidates(); const unique = []; const seen = new Set(); for (const edge of candidates) { const key = `${edge.source}\u0000${edge.predicate}\u0000${edge.target}\u0000${edge.evidence_id || ""}`; if (seen.has(key)) continue; seen.add(key); unique.push(edge); } relationshipCount.textContent = String(unique.length); relationshipNote.textContent = selected ? `Showing connections for ${graphNodeDescription(selected)}.` : "Select a node to narrow the connected relationships."; for (const edge of unique.slice(0, 40)) { const row = document.createElement("article"); row.className = "memory-relationship"; const triplet = document.createElement("div"); triplet.className = "memory-relationship-triplet"; const predicate = document.createElement("span"); predicate.className = "graph-predicate-chip"; predicate.textContent = predicateLabel(edge.predicate); triplet.append(nodeChip(edge.source), predicate, nodeChip(edge.target)); row.append(triplet); const evidence = graphState.nodeById.get(edge.evidence_id); if (evidence?.kind === "memory") { const evidenceText = document.createElement("p"); evidenceText.textContent = evidence.text || evidence.evidence_text || ""; row.append(evidenceText); } relationshipList.append(row); } if (!unique.length) { const empty = document.createElement("p"); empty.className = "memory-graph-empty"; empty.textContent = "No connected relationships are available for this node."; relationshipList.append(empty); } else if (unique.length > 40) { const remaining = document.createElement("p"); remaining.className = "memory-graph-empty"; remaining.textContent = `${unique.length - 40} more relationships are visible in the graph.`; relationshipList.append(remaining); } } function renderGraph() { if (!graphState.payload) return; const { nodes, edges, focused } = selectSubgraph(); graphState.visibleNodes = nodes; graphState.visibleEdges = edges; graphState.nodeById = new Map(graphState.payload.nodes.map((node) => [node.id, node])); graphState.positions = calculateLayout(nodes, edges); const visibleIds = new Set(nodes.map((node) => node.id)); const firstRetrieved = graphBackedRetrievalIds().find((id) => visibleIds.has(id)); if (!visibleIds.has(graphState.selectedNodeId)) { graphState.selectedNodeId = firstRetrieved || (visibleIds.has("person-garry-tan") ? "person-garry-tan" : nodes[0]?.id || null); } graphSvg.replaceChildren(); const defs = svgElement("defs"); const marker = svgElement("marker", { id: "graph-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 5, markerHeight: 5, orient: "auto-start-reverse", }); marker.append(svgElement("path", { d: "M 0 0 L 10 5 L 0 10 z" })); defs.append(marker); graphSvg.append(defs); const scene = svgElement("g", { class: "memory-graph-scene" }); const edgeLayer = svgElement("g", { class: "memory-graph-edges" }); const nodeLayer = svgElement("g", { class: "memory-graph-nodes" }); const retrievedSet = new Set(graphBackedRetrievalIds()); for (const edge of edges) { const source = graphState.positions.get(edge.source); const target = graphState.positions.get(edge.target); if (!source || !target) continue; const line = svgElement("line", { x1: source.x, y1: source.y, x2: target.x, y2: target.y, "marker-end": "url(#graph-arrow)", }); line.classList.toggle("is-retrieved", retrievedSet.has(edge.evidence_id)); const title = svgElement("title"); title.textContent = `${graphNodeLabel(graphState.nodeById.get(edge.source))} ${predicateLabel(edge.predicate)} ${graphNodeLabel(graphState.nodeById.get(edge.target))}`; line.append(title); edgeLayer.append(line); } const degrees = new Map(); for (const edge of edges) { degrees.set(edge.source, (degrees.get(edge.source) || 0) + 1); degrees.set(edge.target, (degrees.get(edge.target) || 0) + 1); } for (const node of nodes) { const position = graphState.positions.get(node.id); const group = svgElement("g", { class: "memory-graph-node", transform: `translate(${position.x} ${position.y})`, tabindex: 0, role: "button", "aria-label": graphNodeDescription(node), }); group.dataset.nodeId = node.id; group.dataset.nodeKind = node.kind || "unknown"; group.classList.toggle("is-retrieved", retrievedSet.has(node.id)); group.classList.toggle("is-selected", graphState.selectedNodeId === node.id); const radius = retrievedSet.has(node.id) ? 13 : node.kind === "entity" ? 10 : node.kind === "value" ? 9 : 6; group.append(svgElement("circle", { r: radius })); const shouldLabel = retrievedSet.has(node.id) || node.kind !== "memory" && (focused || (degrees.get(node.id) || 0) >= 2); if (shouldLabel) { const label = svgElement("text", { x: radius + 5, y: 3.5 }); label.textContent = node.kind === "memory" ? graphNodeLabel(node).replace("Memory ", "R") : shortLabel(node.name || node.id); group.append(label); } const title = svgElement("title"); title.textContent = graphNodeDescription(node); group.append(title); group.addEventListener("click", (event) => { event.stopPropagation(); selectNode(node.id); }); group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectNode(node.id); } }); nodeLayer.append(group); } scene.append(edgeLayer, nodeLayer); graphSvg.append(scene); resetViewport(); const graphIds = graphBackedRetrievalIds(); if (focused) { graphStatus.textContent = `${graphIds.length} ranked graph memories · ${nodes.length} nodes · ${edges.length} evidence-linked edges${graphState.retrievalQuery ? ` · query: ${graphState.retrievalQuery}` : ""}`; } else if (graphState.mode === "retrieved" && graphState.retrievedIds.length) { graphStatus.textContent = "The latest results are methodology-only; showing the complete graph."; } else if (graphState.mode === "retrieved") { graphStatus.textContent = `${nodes.length} nodes · ${edges.length} edges · send a prompt to highlight its ranked retrieval`; } else { graphStatus.textContent = `${nodes.length} nodes · ${edges.length} evidence-backed edges`; } renderRelationships(); } async function loadGraph() { if (graphState.payload) return graphState.payload; if (graphState.loading) return graphState.loading; graphStatus.textContent = "Loading graph…"; graphState.loading = fetch("/v1/memory-graph") .then(async (response) => { const payload = await response.json().catch(() => ({})); if (!response.ok || !Array.isArray(payload.nodes) || !Array.isArray(payload.edges)) { throw new Error("Knowledge graph unavailable"); } graphState.payload = payload; renderGraph(); return payload; }) .catch((error) => { graphStatus.textContent = error.message || "Knowledge graph unavailable"; relationshipNote.textContent = "The graph data could not be loaded."; throw error; }) .finally(() => { graphState.loading = null; }); return graphState.loading; } function openGraph() { graphPanel.hidden = false; graphToggle.setAttribute("aria-expanded", "true"); graphToggle.textContent = "Hide knowledge graph"; loadGraph().catch(() => {}); } function closeGraph() { graphPanel.hidden = true; graphToggle.setAttribute("aria-expanded", "false"); graphToggle.textContent = "Knowledge graph"; } function setGraphRetrieval(memory) { const custom = Array.isArray(memory?.systems) ? memory.systems.find((system) => system.id === "mem0-custom") : null; const memories = Array.isArray(custom?.memories) ? custom.memories : []; graphState.retrievedIds = memories.map((item) => item.id).filter(Boolean); graphState.retrievedRanks = new Map( graphState.retrievedIds.map((id, index) => [id, index + 1]), ); graphState.retrievalQuery = typeof memory?.query === "string" ? memory.query : ""; graphState.selectedNodeId = null; if (graphState.payload) renderGraph(); } graphToggle.addEventListener("click", () => { if (graphPanel.hidden) openGraph(); else closeGraph(); }); graphClose.addEventListener("click", closeGraph); graphReset.addEventListener("click", resetViewport); for (const button of graphViewButtons) { button.addEventListener("click", () => { graphState.mode = button.dataset.graphView; for (const candidate of graphViewButtons) { candidate.setAttribute("aria-pressed", String(candidate === button)); } if (graphState.payload) renderGraph(); }); } graphSvg.addEventListener("wheel", (event) => { event.preventDefault(); const rectangle = graphSvg.getBoundingClientRect(); const pointX = ((event.clientX - rectangle.left) / rectangle.width) * 1000; const pointY = ((event.clientY - rectangle.top) / rectangle.height) * 620; const previous = graphState.viewport.scale; const next = Math.max(0.55, Math.min(4, previous * (event.deltaY < 0 ? 1.12 : 0.89))); graphState.viewport.x = pointX - ((pointX - graphState.viewport.x) / previous) * next; graphState.viewport.y = pointY - ((pointY - graphState.viewport.y) / previous) * next; graphState.viewport.scale = next; applyViewport(); }, { passive: false }); graphSvg.addEventListener("pointerdown", (event) => { if (event.target.closest(".memory-graph-node")) return; graphState.pan = { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, x: graphState.viewport.x, y: graphState.viewport.y, }; graphSvg.setPointerCapture(event.pointerId); graphSvg.classList.add("is-panning"); }); graphSvg.addEventListener("pointermove", (event) => { if (!graphState.pan || graphState.pan.pointerId !== event.pointerId) return; const rectangle = graphSvg.getBoundingClientRect(); graphState.viewport.x = graphState.pan.x + ((event.clientX - graphState.pan.clientX) / rectangle.width) * 1000; graphState.viewport.y = graphState.pan.y + ((event.clientY - graphState.pan.clientY) / rectangle.height) * 620; applyViewport(); }); graphSvg.addEventListener("pointerup", (event) => { if (!graphState.pan || graphState.pan.pointerId !== event.pointerId) return; graphState.pan = null; graphSvg.classList.remove("is-panning"); graphSvg.releasePointerCapture(event.pointerId); }); window.memoryGraphNavigator = { setRetrieval: setGraphRetrieval, open: openGraph };