diff --git a/src/cli/commands/export.ts b/src/cli/commands/export.ts new file mode 100644 index 0000000..b819d10 --- /dev/null +++ b/src/cli/commands/export.ts @@ -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 ', 'Output format: html, svg, mermaid', 'html') + .option('-o, --output ', 'Output file path') + .option('-d, --depth ', 'Depth for subgraph export', '3') + .option('-k, --kind ', 'Filter by node kind') + .option('-t, --tags ', 'Filter by tags (comma-separated)') + .option('--theme ', 'Theme: light or dark', 'dark') + .option('--width ', 'Width for SVG', '800') + .option('--height ', 'Height for SVG', '600') + .option('--direction ', 'Mermaid direction: TD, LR, BT, RL', 'TD') + .option('--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); + } + }); diff --git a/src/cli/index.ts b/src/cli/index.ts index b1c46dd..46c63f6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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(); diff --git a/src/core/export/html.ts b/src/core/export/html.ts new file mode 100644 index 0000000..e1f321c --- /dev/null +++ b/src/core/export/html.ts @@ -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} + + + + +
+ +
+ +
+
+
Component
+
Decision
+
Task
+
Memory
+
+ + +`; +} diff --git a/src/core/export/index.ts b/src/core/export/index.ts new file mode 100644 index 0000000..6268f30 --- /dev/null +++ b/src/core/export/index.ts @@ -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 { + 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}`); + } +} diff --git a/src/core/export/mermaid.ts b/src/core/export/mermaid.ts new file mode 100644 index 0000000..d9c55d2 --- /dev/null +++ b/src/core/export/mermaid.ts @@ -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 { + 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(); + 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(); + 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, ''); +} diff --git a/src/core/export/svg.ts b/src/core/export/svg.ts new file mode 100644 index 0000000..dc39cbf --- /dev/null +++ b/src/core/export/svg.ts @@ -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 = { + component: '#4CAF50', + decision: '#2196F3', + task: '#FF9800', + memory: '#9C27B0', +}; + +export async function exportSvg(options: SvgExportOptions = {}): Promise { + 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(` + + + + + + `); + + // 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(``); + } + } + + // 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(` + + + ${escapeXml(label)} + + `); + } + + return ` + + + ${elements.join('\n')} +`; +} + +function getSubgraphNodes(rootId: string, maxDepth: number, db: any): Node[] { + const visited = new Set(); + 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 { + const positions = new Map(); + + // 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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 9e6a844..a0e1fd4 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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';