Add graph visualization exports (Milestone 7)

- Add interactive HTML export with D3.js force-directed graph
- Add SVG export with simple force-directed layout
- Add Mermaid diagram export for documentation
- Support subgraph export from root node with depth
- Add node filtering by kind and tags
- Add light/dark theme support
- Add CLI commands: export, viz
- Add MCP tool: memory_export
This commit is contained in:
2026-02-03 11:08:19 +01:00
parent c65a5bb03a
commit 3a334d2941
7 changed files with 849 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import { Command } from 'commander';
import * as fs from 'fs';
import chalk from 'chalk';
import { exportGraph, ExportFormat } from '../../core/export';
export const exportCommand = new Command('export')
.description('Export the knowledge graph as HTML, SVG, or Mermaid')
.argument('[rootId]', 'Root node ID for subgraph export')
.option('-f, --format <format>', 'Output format: html, svg, mermaid', 'html')
.option('-o, --output <file>', 'Output file path')
.option('-d, --depth <n>', 'Depth for subgraph export', '3')
.option('-k, --kind <kind>', 'Filter by node kind')
.option('-t, --tags <tags>', 'Filter by tags (comma-separated)')
.option('--theme <theme>', 'Theme: light or dark', 'dark')
.option('--width <n>', 'Width for SVG', '800')
.option('--height <n>', 'Height for SVG', '600')
.option('--direction <dir>', 'Mermaid direction: TD, LR, BT, RL', 'TD')
.option('--title <title>', 'Title for HTML export')
.action(async (rootId: string | undefined, opts) => {
try {
const format = opts.format.toLowerCase() as ExportFormat;
if (!['html', 'svg', 'mermaid'].includes(format)) {
console.error(chalk.red(`Invalid format: ${format}. Use html, svg, or mermaid.`));
process.exit(1);
}
console.log(chalk.cyan(`Exporting graph as ${format}...`));
const content = await exportGraph({
format,
rootId,
depth: parseInt(opts.depth),
kind: opts.kind,
tags: opts.tags?.split(',').map((t: string) => t.trim()),
theme: opts.theme,
width: parseInt(opts.width),
height: parseInt(opts.height),
direction: opts.direction,
title: opts.title,
});
if (opts.output) {
fs.writeFileSync(opts.output, content);
console.log(chalk.green(`✓ Exported to ${opts.output}`));
// Show file size
const stats = fs.statSync(opts.output);
console.log(chalk.dim(` Size: ${(stats.size / 1024).toFixed(1)} KB`));
} else {
// Output to stdout
console.log(content);
}
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exit(1);
}
});
// Shorthand for HTML visualization
export const vizCommand = new Command('viz')
.description('Export interactive HTML visualization')
.argument('[rootId]', 'Root node ID for subgraph')
.option('-o, --output <file>', 'Output file', 'graph.html')
.option('--theme <theme>', 'Theme: light or dark', 'dark')
.option('-d, --depth <n>', 'Depth for subgraph', '3')
.action(async (rootId: string | undefined, opts) => {
try {
console.log(chalk.cyan('Generating visualization...'));
const content = await exportGraph({
format: 'html',
rootId,
depth: parseInt(opts.depth),
theme: opts.theme,
});
fs.writeFileSync(opts.output, content);
console.log(chalk.green(`✓ Created ${opts.output}`));
console.log(chalk.dim(` Open in browser to view interactive graph`));
} catch (err: any) {
console.error(chalk.red(`Error: ${err.message}`));
process.exit(1);
}
});

View File

@@ -19,6 +19,7 @@ import { contextCommand, contextHookCommand } from './commands/context';
import { indexCommand } from './commands/index-cmd';
import { journalCommand, journalAliasCommand, quickCaptureCommand } from './commands/journal';
import { ingestCommand, clipCommand } from './commands/ingest';
import { exportCommand, vizCommand } from './commands/export';
import { closeDb } from '../core/db';
const program = new Command();
@@ -53,6 +54,8 @@ program.addCommand(journalAliasCommand);
program.addCommand(quickCaptureCommand);
program.addCommand(ingestCommand);
program.addCommand(clipCommand);
program.addCommand(exportCommand);
program.addCommand(vizCommand);
program.hook('postAction', () => {
closeDb();

368
src/core/export/html.ts Normal file
View File

@@ -0,0 +1,368 @@
import { listNodes } from '../store';
import { getDb } from '../db';
import { Node } from '../../types';
export interface HtmlExportOptions {
rootId?: string;
depth?: number;
kind?: string;
tags?: string[];
theme?: 'light' | 'dark';
layout?: 'force' | 'radial';
title?: string;
}
interface GraphNode {
id: string;
label: string;
kind: string;
tags: string[];
group: number;
}
interface GraphLink {
source: string;
target: string;
type: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
const KIND_GROUPS: Record<string, number> = {
component: 1,
decision: 2,
task: 3,
memory: 4,
};
const KIND_COLORS: Record<string, { light: string; dark: string }> = {
component: { light: '#4CAF50', dark: '#81C784' },
decision: { light: '#2196F3', dark: '#64B5F6' },
task: { light: '#FF9800', dark: '#FFB74D' },
memory: { light: '#9C27B0', dark: '#BA68C8' },
};
export async function exportHtml(options: HtmlExportOptions = {}): Promise<string> {
const data = await getGraphData(options);
return generateHtmlTemplate(data, options);
}
async function getGraphData(options: HtmlExportOptions): Promise<GraphData> {
const db = getDb();
// Get nodes
let nodes: Node[];
if (options.rootId) {
nodes = getSubgraphNodes(options.rootId, options.depth || 3);
} else {
nodes = listNodes({
kind: options.kind as any,
tags: options.tags,
limit: 500,
includeStale: false,
});
}
// Get edges between these nodes
const nodeIds = new Set(nodes.map(n => n.id));
const edges = db.prepare(`
SELECT * FROM edges
WHERE from_id IN (${[...nodeIds].map(() => '?').join(',')})
AND to_id IN (${[...nodeIds].map(() => '?').join(',')})
`).all([...nodeIds, ...nodeIds]) as any[];
return {
nodes: nodes.map(n => ({
id: n.id,
label: n.title.length > 40 ? n.title.slice(0, 37) + '...' : n.title,
kind: n.kind,
tags: n.tags,
group: KIND_GROUPS[n.kind] || 4,
})),
links: edges.map(e => ({
source: e.from_id,
target: e.to_id,
type: e.type,
})),
};
}
function getSubgraphNodes(rootId: string, maxDepth: number): Node[] {
const db = getDb();
const visited = new Set<string>();
const nodes: Node[] = [];
function traverse(id: string, depth: number) {
if (depth > maxDepth || visited.has(id)) return;
visited.add(id);
const row = db.prepare('SELECT * FROM nodes WHERE id = ? AND is_stale = 0').get(id) as any;
if (!row) return;
nodes.push({
id: row.id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
embedding: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastAccessedAt: row.last_accessed_at,
isStale: false,
});
// Get connected nodes
const edges = db.prepare('SELECT to_id FROM edges WHERE from_id = ?').all(id) as any[];
for (const edge of edges) {
traverse(edge.to_id, depth + 1);
}
const reverseEdges = db.prepare('SELECT from_id FROM edges WHERE to_id = ?').all(id) as any[];
for (const edge of reverseEdges) {
traverse(edge.from_id, depth + 1);
}
}
traverse(rootId, 0);
return nodes;
}
function generateHtmlTemplate(data: GraphData, options: HtmlExportOptions): string {
const theme = options.theme || 'dark';
const title = options.title || 'Cortex Knowledge Graph';
const isDark = theme === 'dark';
const styles = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: ${isDark ? '#1a1a2e' : '#f5f5f5'};
color: ${isDark ? '#eee' : '#333'};
overflow: hidden;
}
#graph { width: 100vw; height: 100vh; }
#sidebar {
position: fixed;
top: 20px;
right: 20px;
width: 300px;
max-height: calc(100vh - 40px);
background: ${isDark ? '#16213e' : '#fff'};
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
overflow-y: auto;
display: none;
}
#sidebar.active { display: block; }
#sidebar h3 { margin-bottom: 8px; font-size: 16px; }
#sidebar .kind {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
}
#sidebar .content {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
#sidebar .tags { margin-top: 8px; }
#sidebar .tag {
display: inline-block;
padding: 2px 6px;
background: ${isDark ? '#0f3460' : '#e0e0e0'};
border-radius: 3px;
font-size: 11px;
margin: 2px;
}
#legend {
position: fixed;
bottom: 20px;
left: 20px;
background: ${isDark ? '#16213e' : '#fff'};
border-radius: 8px;
padding: 12px;
font-size: 12px;
}
#legend div { display: flex; align-items: center; margin: 4px 0; }
#legend .dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; }
#controls {
position: fixed;
top: 20px;
left: 20px;
background: ${isDark ? '#16213e' : '#fff'};
border-radius: 8px;
padding: 12px;
}
#search {
padding: 8px;
border: 1px solid ${isDark ? '#333' : '#ddd'};
border-radius: 4px;
background: ${isDark ? '#1a1a2e' : '#fff'};
color: ${isDark ? '#eee' : '#333'};
width: 200px;
}
.node { cursor: pointer; }
.node:hover { filter: brightness(1.2); }
.link { stroke-opacity: 0.6; }
`;
const script = `
const data = ${JSON.stringify(data)};
const width = window.innerWidth;
const height = window.innerHeight;
const kindColors = ${JSON.stringify(isDark ?
Object.fromEntries(Object.entries(KIND_COLORS).map(([k, v]) => [k, v.dark])) :
Object.fromEntries(Object.entries(KIND_COLORS).map(([k, v]) => [k, v.light]))
)};
const svg = d3.select("#graph")
.append("svg")
.attr("width", width)
.attr("height", height);
const g = svg.append("g");
// Zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on("zoom", (event) => g.attr("transform", event.transform));
svg.call(zoom);
// Arrow markers
svg.append("defs").selectAll("marker")
.data(["depends_on", "contains", "relates_to", "implements"])
.join("marker")
.attr("id", d => "arrow-" + d)
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("fill", "${isDark ? '#666' : '#999'}")
.attr("d", "M0,-5L10,0L0,5");
const simulation = d3.forceSimulation(data.nodes)
.force("link", d3.forceLink(data.links).id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(40));
const link = g.append("g")
.selectAll("line")
.data(data.links)
.join("line")
.attr("class", "link")
.attr("stroke", "${isDark ? '#444' : '#ccc'}")
.attr("stroke-width", 1.5)
.attr("marker-end", d => "url(#arrow-" + d.type + ")");
const node = g.append("g")
.selectAll("g")
.data(data.nodes)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
node.append("circle")
.attr("r", 12)
.attr("fill", d => kindColors[d.kind] || "#888");
node.append("text")
.text(d => d.label)
.attr("x", 16)
.attr("y", 4)
.attr("font-size", "11px")
.attr("fill", "${isDark ? '#ccc' : '#333'}");
node.on("click", (event, d) => {
const sidebar = document.getElementById("sidebar");
sidebar.classList.add("active");
sidebar.innerHTML = \`
<h3>\${d.label}</h3>
<span class="kind" style="background:\${kindColors[d.kind]}">\${d.kind}</span>
<div class="tags">\${d.tags.map(t => '<span class="tag">' + t + '</span>').join('')}</div>
<p style="margin-top:12px;font-size:11px;color:${isDark ? '#888' : '#666'}">ID: \${d.id.slice(0,8)}</p>
\`;
});
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node.attr("transform", d => "translate(" + d.x + "," + d.y + ")");
});
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
// Search
document.getElementById("search").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase();
node.attr("opacity", d =>
query === "" || d.label.toLowerCase().includes(query) ? 1 : 0.2
);
});
// Close sidebar on click outside
svg.on("click", () => {
document.getElementById("sidebar").classList.remove("active");
});
`;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>${styles}</style>
</head>
<body>
<div id="graph"></div>
<div id="sidebar"></div>
<div id="controls">
<input type="text" id="search" placeholder="Search nodes...">
</div>
<div id="legend">
<div><span class="dot" style="background:${KIND_COLORS.component[theme]}"></span>Component</div>
<div><span class="dot" style="background:${KIND_COLORS.decision[theme]}"></span>Decision</div>
<div><span class="dot" style="background:${KIND_COLORS.task[theme]}"></span>Task</div>
<div><span class="dot" style="background:${KIND_COLORS.memory[theme]}"></span>Memory</div>
</div>
<script>${script}</script>
</body>
</html>`;
}

34
src/core/export/index.ts Normal file
View File

@@ -0,0 +1,34 @@
export { exportHtml, HtmlExportOptions } from './html';
export { exportMermaid, MermaidExportOptions } from './mermaid';
export { exportSvg, SvgExportOptions } from './svg';
export type ExportFormat = 'html' | 'mermaid' | 'svg';
export interface ExportOptions {
format: ExportFormat;
rootId?: string;
depth?: number;
kind?: string;
tags?: string[];
theme?: 'light' | 'dark';
width?: number;
height?: number;
direction?: 'TD' | 'LR' | 'BT' | 'RL';
title?: string;
}
export async function exportGraph(options: ExportOptions): Promise<string> {
switch (options.format) {
case 'html':
const { exportHtml } = await import('./html');
return exportHtml(options);
case 'mermaid':
const { exportMermaid } = await import('./mermaid');
return exportMermaid(options);
case 'svg':
const { exportSvg } = await import('./svg');
return exportSvg(options);
default:
throw new Error(`Unknown format: ${options.format}`);
}
}

133
src/core/export/mermaid.ts Normal file
View File

@@ -0,0 +1,133 @@
import { listNodes } from '../store';
import { getDb } from '../db';
import { Node } from '../../types';
export interface MermaidExportOptions {
rootId?: string;
depth?: number;
kind?: string;
tags?: string[];
direction?: 'TD' | 'LR' | 'BT' | 'RL';
}
export async function exportMermaid(options: MermaidExportOptions = {}): Promise<string> {
const db = getDb();
const direction = options.direction || 'TD';
// Get nodes
let nodes: Node[];
if (options.rootId) {
nodes = getSubgraphNodes(options.rootId, options.depth || 3, db);
} else {
nodes = listNodes({
kind: options.kind as any,
tags: options.tags,
limit: 100,
includeStale: false,
});
}
// Get edges between these nodes
const nodeIds = new Set(nodes.map(n => n.id));
const edges = db.prepare(`
SELECT * FROM edges
WHERE from_id IN (${[...nodeIds].map(() => '?').join(',')})
AND to_id IN (${[...nodeIds].map(() => '?').join(',')})
`).all([...nodeIds, ...nodeIds]) as any[];
const lines: string[] = [`graph ${direction}`];
// Generate short IDs for readability
const shortIds = new Map<string, string>();
nodes.forEach((n, i) => {
shortIds.set(n.id, `N${i}`);
});
// Define nodes with shapes based on kind
for (const node of nodes) {
const shortId = shortIds.get(node.id)!;
const label = escapeLabel(node.title);
const shape = kindToShape(node.kind);
lines.push(` ${shortId}${shape.open}"${label}"${shape.close}`);
}
// Add empty line before edges
lines.push('');
// Define edges
for (const edge of edges) {
const fromShort = shortIds.get(edge.from_id);
const toShort = shortIds.get(edge.to_id);
if (fromShort && toShort) {
const arrow = typeToArrow(edge.type);
lines.push(` ${fromShort} ${arrow} ${toShort}`);
}
}
return lines.join('\n');
}
function getSubgraphNodes(rootId: string, maxDepth: number, db: any): Node[] {
const visited = new Set<string>();
const nodes: Node[] = [];
function traverse(id: string, depth: number) {
if (depth > maxDepth || visited.has(id)) return;
visited.add(id);
const row = db.prepare('SELECT * FROM nodes WHERE id = ? AND is_stale = 0').get(id) as any;
if (!row) return;
nodes.push({
id: row.id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
embedding: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastAccessedAt: row.last_accessed_at,
isStale: false,
});
const edges = db.prepare('SELECT to_id FROM edges WHERE from_id = ?').all(id) as any[];
for (const edge of edges) {
traverse(edge.to_id, depth + 1);
}
}
traverse(rootId, 0);
return nodes;
}
function kindToShape(kind: string): { open: string; close: string } {
switch (kind) {
case 'component': return { open: '[', close: ']' }; // Rectangle
case 'decision': return { open: '{', close: '}' }; // Diamond
case 'task': return { open: '([', close: '])' }; // Stadium
case 'memory': return { open: '(', close: ')' }; // Rounded
default: return { open: '[', close: ']' };
}
}
function typeToArrow(type: string): string {
switch (type) {
case 'contains': return '-->'; // Solid arrow
case 'depends_on': return '-.->'; // Dotted arrow
case 'implements': return '==>'; // Thick arrow
case 'relates_to': return '---'; // Line (no arrow)
default: return '-->';
}
}
function escapeLabel(text: string): string {
// Truncate and escape for Mermaid
const truncated = text.length > 30 ? text.slice(0, 27) + '...' : text;
return truncated
.replace(/"/g, "'")
.replace(/\n/g, ' ')
.replace(/[[\]{}()]/g, '');
}

200
src/core/export/svg.ts Normal file
View File

@@ -0,0 +1,200 @@
import { listNodes } from '../store';
import { getDb } from '../db';
import { Node } from '../../types';
export interface SvgExportOptions {
rootId?: string;
depth?: number;
kind?: string;
tags?: string[];
width?: number;
height?: number;
}
const KIND_COLORS: Record<string, string> = {
component: '#4CAF50',
decision: '#2196F3',
task: '#FF9800',
memory: '#9C27B0',
};
export async function exportSvg(options: SvgExportOptions = {}): Promise<string> {
const db = getDb();
const width = options.width || 800;
const height = options.height || 600;
// Get nodes
let nodes: Node[];
if (options.rootId) {
nodes = getSubgraphNodes(options.rootId, options.depth || 3, db);
} else {
nodes = listNodes({
kind: options.kind as any,
tags: options.tags,
limit: 100,
includeStale: false,
});
}
// Get edges
const nodeIds = new Set(nodes.map(n => n.id));
const edges = db.prepare(`
SELECT * FROM edges
WHERE from_id IN (${[...nodeIds].map(() => '?').join(',')})
AND to_id IN (${[...nodeIds].map(() => '?').join(',')})
`).all([...nodeIds, ...nodeIds]) as any[];
// Simple force-directed layout (basic version)
const positions = calculateLayout(nodes, edges, width, height);
// Generate SVG
const elements: string[] = [];
// Arrow marker definition
elements.push(`
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="20" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666"/>
</marker>
</defs>
`);
// Draw edges
for (const edge of edges) {
const from = positions.get(edge.from_id);
const to = positions.get(edge.to_id);
if (from && to) {
elements.push(`<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="#666" stroke-width="1" marker-end="url(#arrowhead)"/>`);
}
}
// Draw nodes
for (const node of nodes) {
const pos = positions.get(node.id);
if (!pos) continue;
const color = KIND_COLORS[node.kind] || '#888';
const label = node.title.length > 20 ? node.title.slice(0, 17) + '...' : node.title;
elements.push(`
<g transform="translate(${pos.x}, ${pos.y})">
<circle r="15" fill="${color}"/>
<text x="20" y="5" font-size="11" font-family="sans-serif" fill="#333">${escapeXml(label)}</text>
</g>
`);
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
<rect width="100%" height="100%" fill="#f5f5f5"/>
${elements.join('\n')}
</svg>`;
}
function getSubgraphNodes(rootId: string, maxDepth: number, db: any): Node[] {
const visited = new Set<string>();
const nodes: Node[] = [];
function traverse(id: string, depth: number) {
if (depth > maxDepth || visited.has(id)) return;
visited.add(id);
const row = db.prepare('SELECT * FROM nodes WHERE id = ? AND is_stale = 0').get(id) as any;
if (!row) return;
nodes.push({
id: row.id,
kind: row.kind,
title: row.title,
content: row.content,
status: row.status,
tags: JSON.parse(row.tags || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
embedding: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastAccessedAt: row.last_accessed_at,
isStale: false,
});
const edges = db.prepare('SELECT to_id FROM edges WHERE from_id = ?').all(id) as any[];
for (const edge of edges) {
traverse(edge.to_id, depth + 1);
}
}
traverse(rootId, 0);
return nodes;
}
function calculateLayout(nodes: Node[], edges: any[], width: number, height: number): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
// Simple grid layout with some randomization
const cols = Math.ceil(Math.sqrt(nodes.length));
const cellWidth = (width - 100) / cols;
const cellHeight = (height - 100) / Math.ceil(nodes.length / cols);
nodes.forEach((node, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
positions.set(node.id, {
x: 50 + col * cellWidth + cellWidth / 2 + (Math.random() - 0.5) * 30,
y: 50 + row * cellHeight + cellHeight / 2 + (Math.random() - 0.5) * 30,
});
});
// Simple force-directed adjustment (few iterations)
for (let iter = 0; iter < 50; iter++) {
// Repulsion between nodes
for (const n1 of nodes) {
const p1 = positions.get(n1.id)!;
for (const n2 of nodes) {
if (n1.id === n2.id) continue;
const p2 = positions.get(n2.id)!;
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
if (dist < 100) {
const force = (100 - dist) / dist * 0.5;
p1.x += dx * force;
p1.y += dy * force;
}
}
}
// Attraction along edges
for (const edge of edges) {
const p1 = positions.get(edge.from_id);
const p2 = positions.get(edge.to_id);
if (p1 && p2) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
if (dist > 100) {
const force = (dist - 100) / dist * 0.1;
p1.x += dx * force;
p1.y += dy * force;
p2.x -= dx * force;
p2.y -= dy * force;
}
}
}
// Keep in bounds
for (const [id, pos] of positions) {
pos.x = Math.max(50, Math.min(width - 50, pos.x));
pos.y = Math.max(50, Math.min(height - 50, pos.y));
}
}
return positions;
}
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -650,6 +650,33 @@ server.tool(
}
);
// --- memory_export ---
import { exportGraph, ExportFormat } from '../core/export';
server.tool(
'memory_export',
'Export the knowledge graph as HTML, SVG, or Mermaid diagram',
{
format: z.enum(['html', 'svg', 'mermaid']).describe('Export format'),
rootId: z.string().optional().describe('Root node ID for subgraph export'),
depth: z.number().optional().describe('Depth for subgraph (default: 3)'),
kind: z.string().optional().describe('Filter by node kind'),
tags: z.array(z.string()).optional().describe('Filter by tags'),
theme: z.enum(['light', 'dark']).optional().describe('Theme for HTML (default: dark)'),
},
async ({ format, rootId, depth, kind, tags, theme }) => {
const content = await exportGraph({
format: format as ExportFormat,
rootId,
depth,
kind,
tags,
theme,
});
return { content: [{ type: 'text' as const, text: content }] };
}
);
// --- memory_ingest ---
import { ingest } from '../core/ingest';