/** * Memory Knowledge Graph Export — generates an interactive HTML visualization * of memory entries as a force-directed graph. * * Uses D3.js v7 for layout and rendering. The output is a self-contained HTML * file that can be opened in any browser. */ import type { MemoryEntry, MemoryStore } from "./store.js"; import { parseEvolution, isActiveMemory } from "./memory-evolution.js"; import { cosineSimilarity } from "./multi-vector.js"; import { parseNarrative } from "./narrative-schema.js"; import { writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { metaDir } from "./compat.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface GraphNode { id: string; label: string; // truncated text category: string; scope: string; importance: number; timestamp: number; accessCount: number; } export interface GraphEdge { source: string; target: string; type: "supersede" | "consolidation" | "cluster" | "scope" | "semantic" | "narrative"; } export interface MemoryGraph { nodes: GraphNode[]; edges: GraphEdge[]; } export interface GraphExportOptions { scope?: string; maxNodes?: number; // default 200 outputPath?: string; // default data/exports/ } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function truncateText(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return text.slice(0, maxLen - 1) + "\u2026"; } interface ParsedMetadata { evolution?: Record; clustered_with?: string; cluster_members?: string[]; [key: string]: unknown; } function parseMetadataSafe(metadata: string | undefined): ParsedMetadata { if (!metadata) return {}; try { return JSON.parse(metadata) as ParsedMetadata; } catch { return {}; } } // --------------------------------------------------------------------------- // Node Selection — diverse, connected, balanced // --------------------------------------------------------------------------- /** * Select nodes for the graph with diversity in mind: * 1. First, include entries that have evolution links (they form interesting connected subgraphs) * 2. Then, fill remaining slots with round-robin across categories (sorted by importance within each) * * This avoids the "all patterns, no edges" problem that pure importance sorting causes. */ function selectDiverseNodes(entries: MemoryEntry[], maxNodes: number): MemoryEntry[] { if (entries.length <= maxNodes) return entries; const selected = new Map(); // Phase 1: Prioritize entries with evolution links (they create edges) for (const entry of entries) { if (selected.size >= maxNodes) break; const evo = parseEvolution(entry.metadata, entry.timestamp); const meta = parseMetadataSafe(entry.metadata); const hasLinks = evo.supersededBy || evo.supersedes || evo.consolidatedInto || typeof meta.clustered_with === "string" || Array.isArray(meta.cluster_members); if (hasLinks && !selected.has(entry.id)) { selected.set(entry.id, entry); } } // Phase 2: Round-robin across categories, picking top-importance entries const byCategory = new Map(); for (const entry of entries) { if (selected.has(entry.id)) continue; const list = byCategory.get(entry.category) ?? []; list.push(entry); byCategory.set(entry.category, list); } // Sort each category by importance descending for (const [, list] of byCategory) { list.sort((a, b) => b.importance - a.importance); } // Round-robin: take one from each category in turn const categories = [...byCategory.keys()]; const indices = new Map(categories.map(c => [c, 0])); let added = true; while (selected.size < maxNodes && added) { added = false; for (const cat of categories) { if (selected.size >= maxNodes) break; const list = byCategory.get(cat)!; const idx = indices.get(cat)!; if (idx < list.length) { selected.set(list[idx].id, list[idx]); indices.set(cat, idx + 1); added = true; } } } return [...selected.values()]; } // --------------------------------------------------------------------------- // Graph Builder // --------------------------------------------------------------------------- export async function buildMemoryGraph( store: Pick & Partial>, options?: GraphExportOptions, ): Promise { const maxNodes = options?.maxNodes ?? 200; const scopeFilter = options?.scope ? [options.scope] : undefined; // 1. List all entries const entries = await store.list(scopeFilter, undefined, 10000, 0); // 2. Filter to active entries only const active = entries.filter(e => isActiveMemory(e.metadata)); // 3. Select nodes with diversity: prioritize connected nodes, then balance categories const selected = selectDiverseNodes(active, maxNodes); const nodeIds = new Set(selected.map(e => e.id)); // 4. Build nodes const nodes: GraphNode[] = selected.map(entry => { const evo = parseEvolution(entry.metadata, entry.timestamp); return { id: entry.id, label: truncateText(entry.text, 80), category: entry.category, scope: entry.scope, importance: entry.importance, timestamp: entry.timestamp, accessCount: evo.accessCount, }; }); // 5. Build edges const edges: GraphEdge[] = []; for (const entry of selected) { const evo = parseEvolution(entry.metadata, entry.timestamp); const meta = parseMetadataSafe(entry.metadata); // supersededBy if (evo.supersededBy && nodeIds.has(evo.supersededBy)) { edges.push({ source: entry.id, target: evo.supersededBy, type: "supersede" }); } // supersedes if (evo.supersedes && nodeIds.has(evo.supersedes)) { edges.push({ source: evo.supersedes, target: entry.id, type: "supersede" }); } // consolidatedInto if (evo.consolidatedInto && nodeIds.has(evo.consolidatedInto)) { edges.push({ source: entry.id, target: evo.consolidatedInto, type: "consolidation" }); } // clustered_with (single ID stored at top level of metadata) if (typeof meta.clustered_with === "string" && nodeIds.has(meta.clustered_with)) { edges.push({ source: entry.id, target: meta.clustered_with, type: "cluster" }); } // cluster_members (array at top level of metadata) if (Array.isArray(meta.cluster_members)) { for (const memberId of meta.cluster_members) { if (typeof memberId === "string" && nodeIds.has(memberId)) { edges.push({ source: entry.id, target: memberId, type: "cluster" }); } } } } // 6. Scope edges — group entries by scope, connect within groups (≤ 20 per scope) const byScope = new Map(); for (const node of nodes) { const list = byScope.get(node.scope) ?? []; list.push(node.id); byScope.set(node.scope, list); } for (const [, ids] of byScope) { if (ids.length > 15 || ids.length < 2) continue; // Connect each node to the next in the group (chain, not full mesh) for (let i = 0; i < ids.length - 1; i++) { edges.push({ source: ids[i], target: ids[i + 1], type: "scope" }); } } // 7. HP-narrative: Narrative edges — connect entries sharing the same generalEventId const byGeneralEvent = new Map(); for (const entry of selected) { const narrative = parseNarrative(entry.metadata); if (!narrative) continue; const list = byGeneralEvent.get(narrative.generalEventId) ?? []; list.push(entry.id); byGeneralEvent.set(narrative.generalEventId, list); } for (const [, ids] of byGeneralEvent) { if (ids.length < 2 || ids.length > 15) continue; // Chain: connect each node to the next in the narrative group for (let i = 0; i < ids.length - 1; i++) { edges.push({ source: ids[i], target: ids[i + 1], type: "narrative" }); } } // 8. Cross-scope semantic bridges — connect entries from DIFFERENT scopes // that are semantically similar (cosine ≥ 0.65). This reveals hidden // cross-domain knowledge connections that the user's interdisciplinary // thinking creates but explicit metadata doesn't capture. // Vectors must be fetched separately since list() omits them for perf. const SEMANTIC_BRIDGE_THRESHOLD = 0.65; const MAX_SEMANTIC_EDGES = 30; // cap to avoid visual clutter if (store.getVectors) { const vectorMap = await store.getVectors(selected.map(e => e.id)); const semanticCandidates: { source: string; target: string; sim: number }[] = []; for (let i = 0; i < selected.length; i++) { for (let j = i + 1; j < selected.length; j++) { const a = selected[i]; const b = selected[j]; // Only cross-scope — same-scope connections are handled by scope chains if (a.scope === b.scope) continue; const vecA = vectorMap.get(a.id); const vecB = vectorMap.get(b.id); if (!vecA || !vecB) continue; const sim = cosineSimilarity(vecA, vecB); if (sim >= SEMANTIC_BRIDGE_THRESHOLD) { semanticCandidates.push({ source: a.id, target: b.id, sim }); } } } // Keep top-N by similarity to avoid clutter semanticCandidates.sort((a, b) => b.sim - a.sim); for (const { source, target } of semanticCandidates.slice(0, MAX_SEMANTIC_EDGES)) { edges.push({ source, target, type: "semantic" }); } } // 8. Deduplicate edges const edgeKey = (e: GraphEdge) => `${e.source}|${e.target}|${e.type}`; const seen = new Set(); const uniqueEdges: GraphEdge[] = []; for (const edge of edges) { const key = edgeKey(edge); if (!seen.has(key)) { seen.add(key); uniqueEdges.push(edge); } } return { nodes, edges: uniqueEdges }; } // --------------------------------------------------------------------------- // HTML Renderer // --------------------------------------------------------------------------- export function renderGraphHTML(graph: MemoryGraph): string { const graphJSON = JSON.stringify(graph); return ` RecallNest Knowledge Graph

Categories

profile
preferences
entities
events
cases
patterns

Edge Types

supersede
cluster
scope
semantic bridge
narrative
`; } // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- export async function exportMemoryGraph( store: Pick, options?: GraphExportOptions, ): Promise<{ path: string; graph: MemoryGraph }> { const graph = await buildMemoryGraph(store, options); const html = renderGraphHTML(graph); const outputDir = options?.outputPath ? resolve(options.outputPath, "..") : resolve(metaDir(import.meta), "../data/exports"); if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } const filePath = options?.outputPath ?? join(outputDir, `memory-graph-${Date.now()}.html`); writeFileSync(filePath, html, "utf-8"); return { path: filePath, graph }; } // --------------------------------------------------------------------------- // Format Result // --------------------------------------------------------------------------- export function formatGraphExportResult(path: string, graph: MemoryGraph): string { // Count categories const catCounts = new Map(); for (const node of graph.nodes) { catCounts.set(node.category, (catCounts.get(node.category) ?? 0) + 1); } const catSummary = Array.from(catCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([cat, count]) => `${cat}(${count})`) .join(", "); // Count edge types const edgeTypeCounts = new Map(); for (const edge of graph.edges) { edgeTypeCounts.set(edge.type, (edgeTypeCounts.get(edge.type) ?? 0) + 1); } const semanticCount = edgeTypeCounts.get("semantic") ?? 0; const narrativeCount = edgeTypeCounts.get("narrative") ?? 0; const edgeDetails: string[] = []; if (semanticCount > 0) edgeDetails.push(`${semanticCount} semantic`); if (narrativeCount > 0) edgeDetails.push(`${narrativeCount} narrative`); const edgeSummary = edgeDetails.length > 0 ? ` (${edgeDetails.join(", ")})` : ""; return [ `Knowledge Graph exported: ${path}`, `Nodes: ${graph.nodes.length} | Edges: ${graph.edges.length}${edgeSummary}`, `Categories: ${catSummary || "none"}`, ].join("\n"); }