From 2d8a6350a265bd41850e1fcb8a4db15a073d8ac4 Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:39:43 +0530 Subject: [PATCH] fix(memory-graph): deduplicate shared graph entities --- .github/workflows/ci.yml | 8 + .../memory-graph/hooks/use-graph-api.test.ts | 276 +++++++++++++++++ .../memory-graph/hooks/use-graph-api.ts | 132 +++++--- .../__tests__/shared-memory-identity.test.tsx | 287 ++++++++++++++++++ .../src/components/memory-graph.tsx | 49 +-- .../memory-graph/src/hooks/use-graph-data.ts | 23 +- 6 files changed, 713 insertions(+), 62 deletions(-) create mode 100644 apps/web/components/memory-graph/hooks/use-graph-api.test.ts create mode 100644 packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..f3e47458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,13 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run memory graph unit tests + working-directory: packages/memory-graph + run: bun run test + + - name: Run web unit tests + working-directory: apps/web + run: bun test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/components/memory-graph/hooks/use-graph-api.test.ts b/apps/web/components/memory-graph/hooks/use-graph-api.test.ts new file mode 100644 index 00000000..b8165362 --- /dev/null +++ b/apps/web/components/memory-graph/hooks/use-graph-api.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, mock } from "bun:test" +import { spawnSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import type { + ApiDocument, + ApiDocumentsResponse, + ApiMemoryEntry, +} from "./use-graph-api" + +const ISOLATED_HOOK_TEST_ENV = "GRAPH_API_HOOK_INTEGRATION_TEST" +const testFilePath = fileURLToPath(import.meta.url) + +if (process.env[ISOLATED_HOOK_TEST_ENV] === "1") { + await registerUseGraphApiIntegrationTests() +} else { + const graphApi = await import("./use-graph-api") + registerGraphApiPaginationTests(graphApi) + + describe("useGraphApi document normalization wiring", () => { + it("passes in an isolated Bun process", () => { + const result = spawnSync(process.execPath, ["test", testFilePath], { + cwd: process.cwd(), + encoding: "utf8", + env: { ...process.env, [ISOLATED_HOOK_TEST_ENV]: "1" }, + }) + + if (result.status !== 0) { + throw new Error( + `Isolated useGraphApi tests failed:\n${result.stdout}\n${result.stderr}`, + ) + } + expect(result.status).toBe(0) + }) + }) +} + +function makeMemory( + id: string, + spaceContainerTag: string, + overrides: Partial> = {}, +): ApiMemoryEntry { + return { + id, + memory: id, + spaceId: "space", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + spaceContainerTag, + ...overrides, + } +} + +function makeDocument( + id: string, + memoryEntries: ApiMemoryEntry[], + overrides: Partial> = {}, +): ApiDocument { + return { + id, + title: id, + type: "text", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + memoryEntries, + } +} + +function makePage( + currentPage: number, + totalPages: number, + documents: ApiDocument[], +): ApiDocumentsResponse { + return { + documents, + pagination: { + currentPage, + limit: 500, + totalItems: documents.length, + totalPages, + }, + } +} + +function makePages(): ApiDocumentsResponse[] { + return [ + makePage(1, 3, [ + makeDocument( + "doc-a", + [ + makeMemory("shared-memory", "included", { + relation: "extends", + }), + makeMemory("filtered-memory", "excluded"), + ], + { title: "First canonical title", summary: "First canonical summary" }, + ), + ]), + makePage(2, 3, [ + makeDocument("doc-b", [ + makeMemory("shared-memory", "included", { relation: "derives" }), + makeMemory("unique-memory", "included"), + ]), + makeDocument( + "doc-a", + [ + makeMemory("shared-memory", "included", { + relation: "updates", + }), + makeMemory("late-memory", "included", { relation: "derives" }), + ], + { title: "Later duplicate title", summary: "Later duplicate summary" }, + ), + ]), + ] +} + +function registerGraphApiPaginationTests( + graphApi: Pick< + typeof import("./use-graph-api"), + "getLoadedGraphNodeCount" | "getNextGraphPageParam" + >, +) { + const pages = makePages() + + describe("graph API pagination node accounting", () => { + it("counts unique document and memory IDs across every loaded page", () => { + expect(graphApi.getLoadedGraphNodeCount(pages)).toBe(6) + expect(graphApi.getLoadedGraphNodeCount(pages, ["included"])).toBe(5) + }) + + it("uses the filtered unique count to decide whether another page is needed", () => { + const lastPage = pages.at(-1) + if (!lastPage) throw new Error("Missing pagination fixture") + + expect( + graphApi.getNextGraphPageParam(lastPage, pages, { + hasDocumentIds: false, + maxNodes: 6, + }), + ).toBeUndefined() + expect( + graphApi.getNextGraphPageParam(lastPage, pages, { + hasDocumentIds: false, + maxNodes: 6, + containerTags: ["included"], + }), + ).toBe(3) + expect( + graphApi.getNextGraphPageParam(lastPage, pages, { + hasDocumentIds: false, + maxNodes: 5, + containerTags: ["included"], + }), + ).toBeUndefined() + }) + + it("never paginates the by-IDs request path", () => { + const lastPage = pages.at(-1) + if (!lastPage) throw new Error("Missing pagination fixture") + + expect( + graphApi.getNextGraphPageParam(lastPage, pages, { + hasDocumentIds: true, + maxNodes: 100, + containerTags: ["included"], + }), + ).toBeUndefined() + }) + }) +} + +async function registerUseGraphApiIntegrationTests() { + const pages = makePages() + type QueryOptions = { + getNextPageParam: ( + lastPage: ApiDocumentsResponse, + allPages: readonly ApiDocumentsResponse[], + ) => number | undefined + } + let queryOptions: QueryOptions | null = null + + mock.module("@tanstack/react-query", () => ({ + useInfiniteQuery: (options: QueryOptions) => { + queryOptions = options + return { + data: { pages }, + error: null, + isPending: false, + isFetchingNextPage: false, + hasNextPage: true, + fetchNextPage: mock(async () => undefined), + } + }, + })) + mock.module("react", () => ({ + useEffect: () => undefined, + useMemo: (factory: () => T) => factory(), + })) + mock.module("@lib/api", () => ({ + $fetch: () => { + throw new Error("The integration fixture must not make a request") + }, + })) + + const { getLoadedGraphNodeCount, useGraphApi } = await import( + "./use-graph-api" + ) + + describe("useGraphApi cross-page document normalization", () => { + it("renders each document once while retaining first metadata and later memories", () => { + const result = useGraphApi({ + containerTags: ["included"], + maxNodes: 5, + }) + const documentIds = result.documents.map((document) => document.id) + + expect(documentIds).toEqual(["doc-a", "doc-b"]) + expect(new Set(documentIds).size).toBe(result.documents.length) + + const documentA = result.documents.find( + (document) => document.id === "doc-a", + ) + const documentB = result.documents.find( + (document) => document.id === "doc-b", + ) + expect(documentA).toBeDefined() + expect(documentB).toBeDefined() + expect(documentA?.title).toBe("First canonical title") + expect(documentA?.summary).toBe("First canonical summary") + expect(documentA?.memories.map((memory) => memory.id)).toEqual([ + "shared-memory", + "late-memory", + ]) + expect(documentA?.memories[0]?.relation).toBe("extends") + expect(documentA?.memories[1]?.relation).toBe("derives") + expect(documentB?.memories[0]?.id).toBe("shared-memory") + expect(documentB?.memories[0]?.relation).toBe("derives") + + const uniqueMemoryIds = new Set( + result.documents.flatMap((document) => + document.memories.map((memory) => memory.id), + ), + ) + const renderedUniqueNodeCount = + new Set(documentIds).size + uniqueMemoryIds.size + expect(renderedUniqueNodeCount).toBe(5) + expect(getLoadedGraphNodeCount(pages, ["included"])).toBe( + renderedUniqueNodeCount, + ) + + if (!queryOptions) throw new Error("Query options were not captured") + const lastPage = pages.at(-1) + if (!lastPage) throw new Error("Missing pagination fixture") + expect(queryOptions.getNextPageParam(lastPage, pages)).toBeUndefined() + }) + + it("keeps production pagination open below the unique-node budget", () => { + queryOptions = null + const result = useGraphApi({ + containerTags: ["included"], + maxNodes: 6, + }) + expect(result.documents.map((document) => document.id)).toEqual([ + "doc-a", + "doc-b", + ]) + expect(getLoadedGraphNodeCount(pages, ["included"])).toBe(5) + + if (!queryOptions) throw new Error("Query options were not captured") + const lastPage = pages.at(-1) + if (!lastPage) throw new Error("Missing pagination fixture") + expect(queryOptions.getNextPageParam(lastPage, pages)).toBe(3) + }) + }) +} 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 30b47672..8fe608c0 100644 --- a/apps/web/components/memory-graph/hooks/use-graph-api.ts +++ b/apps/web/components/memory-graph/hooks/use-graph-api.ts @@ -18,7 +18,7 @@ interface UseGraphApiOptions { maxNodes?: number } -interface ApiMemoryEntry { +export interface ApiMemoryEntry { id: string memory: string content?: string | null @@ -40,7 +40,7 @@ interface ApiMemoryEntry { spaceContainerTag?: string | null } -interface ApiDocument { +export interface ApiDocument { id: string title: string | null summary?: string | null @@ -50,7 +50,7 @@ interface ApiDocument { memoryEntries: ApiMemoryEntry[] } -interface ApiDocumentsResponse { +export interface ApiDocumentsResponse { documents: ApiDocument[] pagination: { currentPage: number @@ -60,13 +60,6 @@ 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, @@ -115,6 +108,83 @@ function toGraphDocument( } } +function normalizeGraphPages( + pages: readonly ApiDocumentsResponse[], + containerTags?: string[], +): GraphApiDocument[] { + // Keep the first document and per-document memory occurrence canonical, but + // merge later unique memories. The same memory may still belong to many docs. + const documentsById = new Map< + string, + { document: GraphApiDocument; memoryIds: Set } + >() + + for (const page of pages) { + for (const apiDocument of page.documents ?? []) { + const graphDocument = toGraphDocument(apiDocument, containerTags) + const existing = documentsById.get(graphDocument.id) + + if (!existing) { + const memoryIds = new Set() + const memories = graphDocument.memories.filter((memory) => { + if (memoryIds.has(memory.id)) return false + memoryIds.add(memory.id) + return true + }) + documentsById.set(graphDocument.id, { + document: { ...graphDocument, memories }, + memoryIds, + }) + continue + } + + for (const memory of graphDocument.memories) { + if (existing.memoryIds.has(memory.id)) continue + existing.memoryIds.add(memory.id) + existing.document.memories.push(memory) + } + } + } + + return Array.from(documentsById.values(), ({ document }) => document) +} + +function getGraphNodeCount(documents: readonly GraphApiDocument[]): number { + const memoryIds = new Set() + for (const document of documents) { + for (const memory of document.memories) memoryIds.add(memory.id) + } + return documents.length + memoryIds.size +} + +export function getLoadedGraphNodeCount( + pages: readonly ApiDocumentsResponse[], + containerTags?: string[], +): number { + return getGraphNodeCount(normalizeGraphPages(pages, containerTags)) +} + +export function getNextGraphPageParam( + lastPage: ApiDocumentsResponse, + allPages: readonly ApiDocumentsResponse[], + options: { + hasDocumentIds: boolean + maxNodes?: number + containerTags?: string[] + }, +): number | undefined { + if (options.hasDocumentIds) return undefined + if ( + options.maxNodes != null && + getLoadedGraphNodeCount(allPages, options.containerTags) >= options.maxNodes + ) { + return undefined + } + + const { currentPage, totalPages } = lastPage.pagination + return currentPage < totalPages ? currentPage + 1 : undefined +} + export function useGraphApi(options: UseGraphApiOptions = {}) { const { containerTags, documentIds, enabled = true, maxNodes } = options const filteredDocumentIds = documentIds?.filter(Boolean) @@ -164,30 +234,25 @@ export function useGraphApi(options: UseGraphApiOptions = {}) { return response.data as unknown as ApiDocumentsResponse }, - 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 - }, + getNextPageParam: (lastPage, allPages) => + getNextGraphPageParam(lastPage, allPages, { + hasDocumentIds, + maxNodes, + containerTags, + }), staleTime: 5 * 60 * 1000, enabled, }) - const loadedNodeCount = useMemo(() => { - if (!data?.pages) return 0 - return data.pages.reduce( - (total, page) => total + getGraphNodeCount(page.documents ?? []), - 0, - ) - }, [data]) + const documents = useMemo(() => { + if (!data?.pages) return [] + return normalizeGraphPages(data.pages, containerTags) + }, [data, containerTags]) + + const loadedNodeCount = useMemo( + () => getGraphNodeCount(documents), + [documents], + ) useEffect(() => { if (!enabled || hasDocumentIds) return @@ -204,13 +269,6 @@ export function useGraphApi(options: UseGraphApiOptions = {}) { fetchNextPage, ]) - const documents = useMemo(() => { - if (!data?.pages) return [] - return data.pages.flatMap((page) => - page.documents.map((doc) => toGraphDocument(doc, containerTags)), - ) - }, [data, containerTags]) - const totalCount = data?.pages[0]?.pagination.totalItems ?? 0 return { diff --git a/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx new file mode 100644 index 00000000..13be1f41 --- /dev/null +++ b/packages/memory-graph/src/__tests__/shared-memory-identity.test.tsx @@ -0,0 +1,287 @@ +import { cleanup, render, renderHook, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { MemoryGraph } from "../components/memory-graph" +import { DEFAULT_COLORS } from "../constants" +import { computeEdges, useGraphData } from "../hooks/use-graph-data" +import type { + GraphApiDocument, + GraphApiMemory, + DocumentNodeData, + GraphEdge, + GraphNode, + MemoryNodeData, +} from "../types" + +const graphCanvasCapture = vi.hoisted(() => ({ + nodes: [] as GraphNode[], + edges: [] as GraphEdge[], +})) + +vi.mock("../components/graph-canvas", () => ({ + GraphCanvas: ({ + nodes, + edges, + }: { + nodes: GraphNode[] + edges: GraphEdge[] + }) => { + graphCanvasCapture.nodes = nodes + graphCanvasCapture.edges = edges + return null + }, +})) + +afterEach(() => { + cleanup() + graphCanvasCapture.nodes = [] + graphCanvasCapture.edges = [] + vi.restoreAllMocks() +}) + +function makeMemory(overrides: Partial = {}): GraphApiMemory { + return { + id: "memory", + memory: "A remembered fact", + isStatic: false, + spaceId: "space", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + memoryRelations: null, + ...overrides, + } +} + +function makeDocument( + id: string, + memories: GraphApiMemory[], +): GraphApiDocument { + return { + id, + title: id, + summary: null, + documentType: "text", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + memories, + } +} + +function getMemoryNode( + nodes: GraphNode[], + id: string, +): GraphNode & { data: MemoryNodeData } { + const node = nodes.find((candidate) => candidate.id === id) + expect(node?.type).toBe("memory") + if (!node || !("documentId" in node.data)) { + throw new Error(`Missing memory ${id}`) + } + return node as GraphNode & { data: MemoryNodeData } +} + +function getDocumentNode( + nodes: GraphNode[], + id: string, +): GraphNode & { data: DocumentNodeData } { + const node = nodes.find((candidate) => candidate.id === id) + expect(node?.type).toBe("document") + if (!node || !("memories" in node.data)) { + throw new Error(`Missing document ${id}`) + } + return node as GraphNode & { data: DocumentNodeData } +} + +describe("shared memory identity", () => { + it("materializes the first source occurrence once while retaining every document edge", () => { + const documents = [ + makeDocument("doc-a", [ + makeMemory({ id: "shared-memory", memory: "first source content" }), + ]), + makeDocument("doc-b", [ + makeMemory({ id: "shared-memory", memory: "later source content" }), + ]), + ] + const { result, unmount } = renderHook(() => + useGraphData(documents, null, 800, 600, DEFAULT_COLORS), + ) + + const nodeIds = result.current.nodes.map((node) => node.id) + const sharedNode = getMemoryNode(result.current.nodes, "shared-memory") + const derivesEdges = result.current.edges.filter( + (edge) => edge.edgeType === "derives", + ) + + expect(nodeIds).toHaveLength(3) + expect(new Set(nodeIds).size).toBe(nodeIds.length) + expect(sharedNode.data.content).toBe("first source content") + expect(sharedNode.data.documentId).toBe("doc-a") + expect(derivesEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "dm-5:doc-a|13:shared-memory", + source: "doc-a", + target: "shared-memory", + }), + expect.objectContaining({ + id: "dm-5:doc-b|13:shared-memory", + source: "doc-b", + target: "shared-memory", + }), + ]), + ) + expect(derivesEdges).toHaveLength(2) + + unmount() + }) + + it("preserves cached position when another source occurrence is appended", () => { + const firstDocuments = [ + makeDocument("doc-a", [makeMemory({ id: "shared-memory" })]), + ] + const { result, rerender, unmount } = renderHook( + ({ documents }: { documents: GraphApiDocument[] }) => + useGraphData(documents, null, 800, 600, DEFAULT_COLORS), + { initialProps: { documents: firstDocuments } }, + ) + const firstNode = getMemoryNode(result.current.nodes, "shared-memory") + + rerender({ + documents: [ + ...firstDocuments, + makeDocument("doc-b", [makeMemory({ id: "shared-memory" })]), + ], + }) + + const rerenderedNode = getMemoryNode(result.current.nodes, "shared-memory") + expect({ x: rerenderedNode.x, y: rerenderedNode.y }).toEqual({ + x: firstNode.x, + y: firstNode.y, + }) + expect(rerenderedNode.data.documentId).toBe("doc-a") + + unmount() + }) + + it("unions relation pairs but keeps direction, type, and first occurrence", () => { + const documents = [ + makeDocument("doc-a", [ + makeMemory({ + id: "shared-memory", + memoryRelations: { "target-a": "updates" }, + }), + makeMemory({ id: "target-a" }), + ]), + makeDocument("doc-b", [ + makeMemory({ + id: "shared-memory", + memoryRelations: { + "target-a": "extends", + "target-b": "extends", + }, + }), + makeMemory({ id: "target-b" }), + ]), + ] + + const relationEdges = computeEdges(documents).filter((edge) => + edge.id.startsWith("rel-"), + ) + + expect(relationEdges).toHaveLength(2) + expect(relationEdges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "rel-8:target-a|13:shared-memory", + source: "target-a", + target: "shared-memory", + edgeType: "updates", + }), + expect.objectContaining({ + id: "rel-8:target-b|13:shared-memory", + source: "target-b", + target: "shared-memory", + edgeType: "extends", + }), + ]), + ) + }) + + it("does not collide for ambiguous relation or derives tuples", () => { + const relationDocuments = [ + makeDocument("relations", [ + makeMemory({ id: "a-b" }), + makeMemory({ id: "c", memoryRelations: { "a-b": "updates" } }), + makeMemory({ id: "a" }), + makeMemory({ id: "b-c", memoryRelations: { a: "extends" } }), + ]), + ] + const relationEdges = computeEdges(relationDocuments).filter((edge) => + edge.id.startsWith("rel-"), + ) + + expect(relationEdges.map((edge) => edge.id).sort()).toEqual([ + "rel-1:a|3:b-c", + "rel-3:a-b|1:c", + ]) + expect(new Set(relationEdges.map((edge) => edge.id)).size).toBe(2) + + const derivesEdges = computeEdges([ + makeDocument("a-b", [makeMemory({ id: "c" })]), + makeDocument("a", [makeMemory({ id: "b-c" })]), + ]).filter((edge) => edge.edgeType === "derives") + + expect(derivesEdges.map((edge) => edge.id).sort()).toEqual([ + "dm-1:a|3:b-c", + "dm-3:a-b|1:c", + ]) + expect(new Set(derivesEdges.map((edge) => edge.id)).size).toBe(2) + }) +}) + +describe("MemoryGraph maxNodes", () => { + it("counts unique memories and retains a later duplicate after saturation", async () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 0, + right: 800, + top: 0, + width: 800, + x: 0, + y: 0, + toJSON: () => ({}), + }) + const documents = [ + makeDocument("doc-a", [ + makeMemory({ id: "shared-memory" }), + makeMemory({ id: "unique-a" }), + ]), + makeDocument("doc-b", [ + makeMemory({ id: "over-budget" }), + makeMemory({ id: "shared-memory" }), + ]), + ] + + render() + + await waitFor(() => { + expect(graphCanvasCapture.nodes).toHaveLength(4) + }) + expect(graphCanvasCapture.nodes.map((node) => node.id).sort()).toEqual([ + "doc-a", + "doc-b", + "shared-memory", + "unique-a", + ]) + const docB = getDocumentNode(graphCanvasCapture.nodes, "doc-b") + expect(docB.data.memories.map((memory) => memory.id)).toEqual([ + "shared-memory", + ]) + }) +}) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index d165ac71..57395025 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -73,37 +73,40 @@ export function MemoryGraph({ // Used as a dependency proxy to recalculate popover positions const [viewportVersion, setViewportVersion] = useState(0) - // Limit documents so total node count (documents + their memories) stays under maxNodes + // Limit documents so total node count (documents + unique memories) stays + // under maxNodes. A memory can belong to more than one source document, so + // repeated occurrences must remain in each admitted document without + // consuming the node budget again. const limitedDocuments = useMemo(() => { if (!maxNodes || documents.length === 0) return documents let totalNodes = 0 + const seenMemoryIds = new Set() const limited: GraphApiDocument[] = [] - for (let i = 0; i < documents.length; i++) { - const doc = documents[i] - if (!doc) continue + for (const doc of documents) { if (totalNodes >= maxNodes) break - const remainingNodes = maxNodes - totalNodes const memories = doc.memories ?? [] - const docNodes = 1 + memories.length - - if (docNodes <= remainingNodes) { - limited.push(doc) - totalNodes += docNodes - continue - } - - if (remainingNodes > 1) { - limited.push({ - ...doc, - memories: memories.slice(0, remainingNodes - 1), - }) - totalNodes = maxNodes - break - } - - limited.push({ ...doc, memories: [] }) + const includedMemories: GraphApiDocument["memories"] = [] totalNodes += 1 + + for (const memory of memories) { + if (seenMemoryIds.has(memory.id)) { + includedMemories.push(memory) + continue + } + + if (totalNodes >= maxNodes) continue + + seenMemoryIds.add(memory.id) + includedMemories.push(memory) + totalNodes += 1 + } + + limited.push( + includedMemories.length === memories.length + ? doc + : { ...doc, memories: includedMemories }, + ) } return limited }, [documents, maxNodes]) diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index aed2d6f0..85d4fbac 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -403,6 +403,14 @@ function getAppendSpatialKey(x: number, y: number): string { * Pure function that computes graph edges from documents. * Extracted from the hook for testability. */ +function createEdgeId( + prefix: "dm" | "rel", + sourceId: string, + targetId: string, +): string { + return `${prefix}-${sourceId.length}:${sourceId}|${targetId.length}:${targetId}` +} + export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { if (!documents || documents.length === 0) return [] @@ -414,10 +422,14 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { } // 1. Derives edges: document -> memory (structural) + const derivesEdgeIds = new Set() for (const doc of documents) { for (const mem of doc.memories) { + const edgeId = createEdgeId("dm", doc.id, mem.id) + if (derivesEdgeIds.has(edgeId)) continue + derivesEdgeIds.add(edgeId) result.push({ - id: `dm-${doc.id}-${mem.id}`, + id: edgeId, source: doc.id, target: mem.id, visualProps: getEdgeVisualProps("derives"), @@ -429,12 +441,16 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { // 2. Memory-to-memory relation edges from backend data. // Uses memoryRelations (Record) as primary source, // falls back to parentMemoryId for legacy data. + const relationEdgeIds = new Set() for (const doc of documents) { for (const mem of doc.memories) { const relations = getMemoryRelationTargets(mem) for (const [targetId, relationType] of Object.entries(relations)) { if (!allNodeIds.has(targetId)) continue + const edgeId = createEdgeId("rel", targetId, mem.id) + if (relationEdgeIds.has(edgeId)) continue + relationEdgeIds.add(edgeId) const edgeType = relationType === "updates" || relationType === "extends" || @@ -442,7 +458,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { ? relationType : "updates" result.push({ - id: `rel-${targetId}-${mem.id}`, + id: edgeId, source: targetId, target: mem.id, visualProps: getEdgeVisualProps(edgeType), @@ -495,6 +511,7 @@ export function useGraphData( const clusterAssignments = computeClusterAssignments(documents) const result: GraphNode[] = [] + const seenMemoryIds = new Set() // Spiral layout: documents form a compact spiral core, memories orbit // around their parent documents. The force simulation then gently // pushes memories outward to create the constellation/starburst effect. @@ -574,6 +591,8 @@ export function useGraphData( for (let i = 0; i < memCount; i++) { const mem = doc.memories[i] if (!mem) continue + if (seenMemoryIds.has(mem.id)) continue + seenMemoryIds.add(mem.id) const previousMemNode = previousCache.get(mem.id) const memData: MemoryNodeData = { ...mem,