From aea3e93ff7192af24276eac7d0bfbdf6b5b99b0a Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 3 Feb 2026 11:25:44 +0100 Subject: [PATCH] Add multi-graph support (Milestone 9) - Graph manager: create, list, delete, use graphs - Automatic project detection via .cortex.json or git remote - Graph switching in db.ts connection manager - Cross-graph search with --all-graphs flag - CLI: graphs, use, init commands - MCP tools: memory_graphs, memory_use_graph, memory_create_graph, memory_delete_graph - Database migration from .memory to ~/.cortex/graphs --- src/cli/commands/graphs.ts | 156 ++++++++++++++ src/cli/commands/query.ts | 58 +++++ src/cli/index.ts | 8 + src/core/db.ts | 99 ++++++++- src/core/graphs.ts | 432 +++++++++++++++++++++++++++++++++++++ src/mcp/index.ts | 84 ++++++++ 6 files changed, 833 insertions(+), 4 deletions(-) create mode 100644 src/cli/commands/graphs.ts create mode 100644 src/core/graphs.ts diff --git a/src/cli/commands/graphs.ts b/src/cli/commands/graphs.ts new file mode 100644 index 0000000..bdc9d23 --- /dev/null +++ b/src/cli/commands/graphs.ts @@ -0,0 +1,156 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { + listGraphs, + createGraph, + deleteGraph, + useGraph, + getActiveGraph, + initProject, + graphExists, +} from '../../core/graphs'; + +export const graphsCommand = new Command('graphs') + .description('Manage knowledge graphs') + .action(() => { + try { + const graphs = listGraphs(); + const active = getActiveGraph(); + + if (graphs.length === 0) { + console.log(chalk.yellow('No graphs found. Create one with: cortex graphs create ')); + return; + } + + console.log(chalk.cyan('Knowledge Graphs:\n')); + + for (const graph of graphs) { + const isActive = graph.name === active; + const prefix = isActive ? chalk.green('→ ') : ' '; + const name = isActive ? chalk.bold.green(graph.name) : graph.name; + const lastAccessed = new Date(graph.lastAccessed).toLocaleDateString(); + const sizeKb = (graph.size / 1024).toFixed(1); + + console.log(`${prefix}${name}`); + console.log(chalk.dim(` Nodes: ${graph.nodeCount} | Edges: ${graph.edgeCount} | Size: ${sizeKb} KB`)); + console.log(chalk.dim(` Last accessed: ${lastAccessed}`)); + } + + if (graphs.length > 0) { + console.log(chalk.dim(`\n Active graph: ${active}`)); + } + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Subcommand: create +graphsCommand + .command('create ') + .description('Create a new graph') + .action((name: string) => { + try { + const graph = createGraph(name); + console.log(chalk.green(`✓ Created graph '${name}'`)); + console.log(chalk.dim(` Path: ${graph.path}`)); + console.log(chalk.dim(`\nSwitch to it with: cortex use ${name}`)); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Subcommand: delete +graphsCommand + .command('delete ') + .description('Delete a graph') + .option('-y, --yes', 'Skip confirmation') + .action((name: string, opts) => { + try { + if (!graphExists(name)) { + console.error(chalk.red(`Graph '${name}' does not exist`)); + process.exit(1); + } + + if (!opts.yes) { + console.log(chalk.yellow(`Warning: This will permanently delete the '${name}' graph and all its data.`)); + console.log(chalk.dim('Use --yes to skip this warning.')); + // In a real CLI we'd prompt for confirmation + } + + deleteGraph(name); + console.log(chalk.green(`✓ Deleted graph '${name}'`)); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Subcommand: info +graphsCommand + .command('info [name]') + .description('Show detailed info about a graph') + .action((name?: string) => { + try { + const graphName = name || getActiveGraph(); + const graphs = listGraphs(); + const graph = graphs.find(g => g.name === graphName); + + if (!graph) { + console.error(chalk.red(`Graph '${graphName}' not found`)); + process.exit(1); + } + + console.log(chalk.cyan(`Graph: ${chalk.bold(graph.name)}\n`)); + console.log(` Path: ${graph.path}`); + console.log(` Nodes: ${graph.nodeCount}`); + console.log(` Edges: ${graph.edgeCount}`); + console.log(` Size: ${(graph.size / 1024).toFixed(1)} KB`); + console.log(` Created: ${new Date(graph.createdAt).toISOString()}`); + console.log(` Last accessed: ${new Date(graph.lastAccessed).toISOString()}`); + + if (graph.name === getActiveGraph()) { + console.log(chalk.green(`\n ✓ Currently active`)); + } + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Use command (switch active graph) +export const useCommand = new Command('use') + .description('Switch to a different graph') + .argument('', 'Graph name to switch to') + .action((name: string) => { + try { + if (!graphExists(name)) { + console.error(chalk.red(`Graph '${name}' does not exist`)); + console.log(chalk.dim(`Create it with: cortex graphs create ${name}`)); + process.exit(1); + } + + useGraph(name); + console.log(chalk.green(`✓ Switched to graph '${name}'`)); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); + +// Init command (initialize project with graph) +export const initCommand = new Command('init') + .description('Initialize a graph for the current project') + .argument('[name]', 'Graph name (default: from git remote or directory name)') + .action((name?: string) => { + try { + const graphName = initProject(name); + console.log(chalk.green(`✓ Initialized project with graph '${graphName}'`)); + console.log(chalk.dim(' Created .cortex.json')); + console.log(chalk.dim(`\nThis project will now use the '${graphName}' graph automatically.`)); + } catch (err: any) { + console.error(chalk.red(`Error: ${err.message}`)); + process.exit(1); + } + }); diff --git a/src/cli/commands/query.ts b/src/cli/commands/query.ts index 32bf727..f17abb5 100644 --- a/src/cli/commands/query.ts +++ b/src/cli/commands/query.ts @@ -2,14 +2,72 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { query } from '../../core/store'; import { NodeKind } from '../../types'; +import { useGraph, getActiveGraph, listGraphs, graphExists } from '../../core/graphs'; +import { closeDb, getDbForGraph } from '../../core/db'; export const queryCommand = new Command('query') .argument('', 'Natural language search query') .option('--kind ', 'Filter by node kind') .option('--limit ', 'Max results', '10') .option('--format ', 'Output format: text or json', 'text') + .option('--graph ', 'Query specific graph') + .option('--all-graphs', 'Search across all graphs') .description('Search the knowledge graph') .action(async (text: string, opts) => { + // Handle specific graph + if (opts.graph) { + if (!graphExists(opts.graph)) { + console.error(chalk.red(`Graph '${opts.graph}' does not exist`)); + process.exit(1); + } + useGraph(opts.graph); + } + + // Handle all-graphs search + if (opts.allGraphs) { + const allResults: Array<{ graph: string; node: any; score: number }> = []; + const graphs = listGraphs(); + + for (const graph of graphs) { + useGraph(graph.name); + closeDb(); // Force reconnect to new graph + const results = await query(text, { + kind: opts.kind as NodeKind | undefined, + limit: parseInt(opts.limit), + }); + for (const r of results) { + allResults.push({ graph: graph.name, ...r }); + } + } + + // Sort by score and limit + allResults.sort((a, b) => b.score - a.score); + const limited = allResults.slice(0, parseInt(opts.limit)); + + if (limited.length === 0) { + console.log(chalk.yellow('No results found across any graphs.')); + return; + } + + if (opts.format === 'json') { + console.log(JSON.stringify(limited.map(r => ({ + graph: r.graph, + ...r.node, + score: r.score, + embedding: undefined, + })), null, 2)); + return; + } + + for (const r of limited) { + const n = r.node; + console.log(`${chalk.cyan(n.id.slice(0, 8))} [${chalk.blue(r.graph)}] [${chalk.magenta(n.kind)}] ${chalk.bold(n.title)} ${chalk.dim(`(${r.score.toFixed(3)})`)}`); + if (n.content) console.log(` ${chalk.dim(n.content.slice(0, 120))}`); + if (n.tags.length) console.log(` ${chalk.yellow(n.tags.join(', '))}`); + } + return; + } + const results = await query(text, { kind: opts.kind as NodeKind | undefined, limit: parseInt(opts.limit), diff --git a/src/cli/index.ts b/src/cli/index.ts index 2aecfb2..ce0387e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -22,7 +22,9 @@ import { ingestCommand, clipCommand } from './commands/ingest'; import { exportCommand, vizCommand } from './commands/export'; import { importCommand } from './commands/import'; import { backupCommand, restoreDbCommand, listBackupsCommand } from './commands/backup-cmd'; +import { graphsCommand, useCommand, initCommand } from './commands/graphs'; import { closeDb } from '../core/db'; +import { migrateOldDatabase } from '../core/db'; const program = new Command(); @@ -62,6 +64,12 @@ program.addCommand(importCommand); program.addCommand(backupCommand); program.addCommand(restoreDbCommand); program.addCommand(listBackupsCommand); +program.addCommand(graphsCommand); +program.addCommand(useCommand); +program.addCommand(initCommand); + +// Check for old database migration +migrateOldDatabase(); program.hook('postAction', () => { closeDb(); diff --git a/src/core/db.ts b/src/core/db.ts index 9b3cf33..0a7c9f5 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; +import { getActiveGraph, getGraphDbPath, graphExists, createGraph, getGraphsDir } from './graphs'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS nodes ( @@ -61,23 +62,58 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_versions_node_version ON node_versions(nod `; let _db: Database.Database | null = null; +let _currentGraph: string | null = null; +/** + * Get the memory directory for backward compatibility + * Now returns the active graph's directory + */ export function getMemoryDir(): string { - return path.join(process.cwd(), '.memory'); + const activeGraph = getActiveGraph(); + const graphDir = path.dirname(getGraphDbPath(activeGraph)); + + if (!fs.existsSync(graphDir)) { + fs.mkdirSync(graphDir, { recursive: true }); + } + + return graphDir; } +/** + * Get the database connection, creating it if necessary + * Automatically handles graph switching + */ export function getDb(): Database.Database { - if (_db) return _db; + const activeGraph = getActiveGraph(); + + // Check if we need to switch graphs + if (_db && _currentGraph === activeGraph) { + return _db; + } + + // Close existing connection if switching graphs + if (_db && _currentGraph !== activeGraph) { + _db.close(); + _db = null; + } + + // Ensure graph exists + if (!graphExists(activeGraph)) { + createGraph(activeGraph); + } + + const dbPath = getGraphDbPath(activeGraph); + const dir = path.dirname(dbPath); - const dir = getMemoryDir(); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - _db = new Database(path.join(dir, 'cortex.db')); + _db = new Database(dbPath); _db.pragma('journal_mode = WAL'); _db.pragma('foreign_keys = ON'); _db.exec(SCHEMA); + _currentGraph = activeGraph; // Migration: add last_accessed_at column const cols = _db.prepare("PRAGMA table_info(nodes)").all() as any[]; @@ -124,9 +160,64 @@ export function getDb(): Database.Database { return _db; } +/** + * Close the database connection + */ export function closeDb(): void { if (_db) { _db.close(); _db = null; + _currentGraph = null; } } + +/** + * Get a database connection for a specific graph + * Does not change the active graph + */ +export function getDbForGraph(graphName: string): Database.Database { + if (!graphExists(graphName)) { + throw new Error(`Graph '${graphName}' does not exist`); + } + + const dbPath = getGraphDbPath(graphName); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + return db; +} + +/** + * Migrate existing .memory directory to new graphs system + */ +export function migrateOldDatabase(): boolean { + const oldDir = path.join(process.cwd(), '.memory'); + const oldDbPath = path.join(oldDir, 'cortex.db'); + + if (!fs.existsSync(oldDbPath)) { + return false; + } + + // Check if we've already migrated + const defaultDbPath = getGraphDbPath('default'); + if (fs.existsSync(defaultDbPath)) { + return false; + } + + // Create default graph directory + const defaultGraphDir = path.dirname(defaultDbPath); + fs.mkdirSync(defaultGraphDir, { recursive: true }); + + // Copy old database to new location + fs.copyFileSync(oldDbPath, defaultDbPath); + + // Rename old directory as backup + const backupDir = `${oldDir}.migrated-${Date.now()}`; + fs.renameSync(oldDir, backupDir); + + console.log(`Migrated .memory database to new multi-graph system`); + console.log(`Old database backed up to ${backupDir}`); + + return true; +} diff --git a/src/core/graphs.ts b/src/core/graphs.ts new file mode 100644 index 0000000..5860497 --- /dev/null +++ b/src/core/graphs.ts @@ -0,0 +1,432 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import Database from 'better-sqlite3'; + +// Global state for active graph +let _activeGraph: string = 'default'; +let _configDir: string | null = null; + +export interface GraphInfo { + name: string; + path: string; + nodeCount: number; + edgeCount: number; + lastAccessed: number; + createdAt: number; + size: number; +} + +export interface LocalConfig { + graph?: string; + autoCapture?: boolean; + contextInjection?: { + maxTokens?: number; + includeTasks?: boolean; + }; +} + +export interface GlobalConfig { + defaultGraph: string; + lastUsedGraph: string; +} + +/** + * Get the Cortex configuration directory + */ +export function getConfigDir(): string { + if (_configDir) return _configDir; + + // Check environment variable first + if (process.env.CORTEX_HOME) { + _configDir = process.env.CORTEX_HOME; + } else if (process.platform === 'win32') { + // Windows: use %APPDATA%\cortex + _configDir = path.join(process.env.APPDATA || path.join(require('os').homedir(), 'AppData', 'Roaming'), 'cortex'); + } else { + // Unix: use ~/.cortex + _configDir = path.join(require('os').homedir(), '.cortex'); + } + + // Ensure directory exists + if (!fs.existsSync(_configDir)) { + fs.mkdirSync(_configDir, { recursive: true }); + } + + return _configDir; +} + +/** + * Get the graphs directory + */ +export function getGraphsDir(): string { + const dir = path.join(getConfigDir(), 'graphs'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; +} + +/** + * Get path to a specific graph's database + */ +export function getGraphDbPath(graphName: string): string { + return path.join(getGraphsDir(), graphName, 'cortex.db'); +} + +/** + * Check if a graph exists + */ +export function graphExists(name: string): boolean { + return fs.existsSync(getGraphDbPath(name)); +} + +/** + * List all available graphs + */ +export function listGraphs(): GraphInfo[] { + const graphsDir = getGraphsDir(); + + if (!fs.existsSync(graphsDir)) { + return []; + } + + const dirs = fs.readdirSync(graphsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + const graphs: GraphInfo[] = []; + + for (const name of dirs) { + const dbPath = getGraphDbPath(name); + if (!fs.existsSync(dbPath)) continue; + + try { + const stats = fs.statSync(dbPath); + const db = new Database(dbPath, { readonly: true }); + + const nodeCount = (db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; + const edgeCount = (db.prepare('SELECT COUNT(*) as count FROM edges').get() as any)?.count || 0; + + db.close(); + + graphs.push({ + name, + path: path.join(graphsDir, name), + nodeCount, + edgeCount, + lastAccessed: stats.mtimeMs, + createdAt: stats.birthtimeMs || stats.ctimeMs, + size: stats.size, + }); + } catch { + // Skip corrupted databases + } + } + + return graphs.sort((a, b) => b.lastAccessed - a.lastAccessed); +} + +/** + * Create a new graph + */ +export function createGraph(name: string): GraphInfo { + if (!/^[a-z0-9_-]+$/i.test(name)) { + throw new Error('Graph name must be alphanumeric with dashes or underscores'); + } + + const graphDir = path.join(getGraphsDir(), name); + if (fs.existsSync(graphDir)) { + throw new Error(`Graph '${name}' already exists`); + } + + fs.mkdirSync(graphDir, { recursive: true }); + + // Initialize empty database + const dbPath = path.join(graphDir, 'cortex.db'); + const db = new Database(dbPath); + + // Create schema + db.exec(` + CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + status TEXT, + tags TEXT DEFAULT '[]', + metadata TEXT DEFAULT '{}', + embedding BLOB, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_accessed_at INTEGER, + version INTEGER DEFAULT 1, + is_stale INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS edges ( + id TEXT PRIMARY KEY, + from_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + to_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + type TEXT NOT NULL, + metadata TEXT DEFAULT '{}', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS node_tags ( + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (node_id, tag) + ); + + CREATE TABLE IF NOT EXISTS node_versions ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + version INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + status TEXT, + tags TEXT DEFAULT '[]', + metadata TEXT DEFAULT '{}', + valid_from INTEGER NOT NULL, + valid_until INTEGER, + created_by TEXT DEFAULT 'user', + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); + CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status); + CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_nodes_stale ON nodes(is_stale); + CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id); + CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id); + CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type); + CREATE INDEX IF NOT EXISTS idx_tags_tag ON node_tags(tag); + CREATE INDEX IF NOT EXISTS idx_versions_node ON node_versions(node_id, version); + CREATE INDEX IF NOT EXISTS idx_versions_time ON node_versions(valid_from, valid_until); + CREATE UNIQUE INDEX IF NOT EXISTS idx_versions_node_version ON node_versions(node_id, version); + `); + + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.close(); + + const stats = fs.statSync(dbPath); + return { + name, + path: graphDir, + nodeCount: 0, + edgeCount: 0, + lastAccessed: stats.mtimeMs, + createdAt: stats.birthtimeMs || stats.ctimeMs, + size: stats.size, + }; +} + +/** + * Delete a graph + */ +export function deleteGraph(name: string): void { + if (name === 'default') { + throw new Error("Cannot delete the 'default' graph"); + } + + const graphDir = path.join(getGraphsDir(), name); + if (!fs.existsSync(graphDir)) { + throw new Error(`Graph '${name}' does not exist`); + } + + // Recursively delete directory + fs.rmSync(graphDir, { recursive: true }); + + // Reset to default if this was the active graph + if (_activeGraph === name) { + _activeGraph = 'default'; + } +} + +/** + * Set the active graph for the current session + */ +export function useGraph(name: string): void { + if (!graphExists(name)) { + throw new Error(`Graph '${name}' does not exist`); + } + _activeGraph = name; + + // Update last used in global config + updateGlobalConfig({ lastUsedGraph: name }); +} + +/** + * Get the currently active graph name + */ +export function getActiveGraph(): string { + // Check project-local .cortex file first + const localConfig = findLocalConfig(); + if (localConfig?.graph && graphExists(localConfig.graph)) { + return localConfig.graph; + } + + // Check if explicitly set via useGraph() + if (_activeGraph !== 'default' && graphExists(_activeGraph)) { + return _activeGraph; + } + + // Try git remote detection + const gitProject = detectGitProject(); + if (gitProject && graphExists(gitProject)) { + return gitProject; + } + + // Fall back to global config's last used or default + const config = getGlobalConfig(); + if (config.lastUsedGraph && graphExists(config.lastUsedGraph)) { + return config.lastUsedGraph; + } + + // Ensure default graph exists + if (!graphExists('default')) { + createGraph('default'); + } + + return 'default'; +} + +/** + * Find local .cortex or .cortex.json config + */ +export function findLocalConfig(): LocalConfig | null { + let dir = process.cwd(); + const root = path.parse(dir).root; + + while (dir !== root) { + // Check .cortex.json + const jsonPath = path.join(dir, '.cortex.json'); + if (fs.existsSync(jsonPath)) { + try { + return JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch { + // Invalid JSON + } + } + + // Check .cortex (JSON or just graph name) + const cortexPath = path.join(dir, '.cortex'); + if (fs.existsSync(cortexPath)) { + try { + const content = fs.readFileSync(cortexPath, 'utf-8').trim(); + // Try JSON first + if (content.startsWith('{')) { + return JSON.parse(content); + } + // Otherwise treat as graph name + return { graph: content }; + } catch { + // Invalid config + } + } + + dir = path.dirname(dir); + } + + return null; +} + +/** + * Detect project name from git remote + */ +export function detectGitProject(): string | null { + try { + const { execSync } = require('child_process'); + const remote = execSync('git remote get-url origin', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + // Extract project name from various URL formats + // git@github.com:user/repo.git + // https://github.com/user/repo.git + // ssh://git@host/user/repo.git + const match = remote.match(/[/:]([^/]+?)(?:\.git)?$/); + if (match) { + return match[1].toLowerCase().replace(/[^a-z0-9_-]/g, '-'); + } + } catch { + // Not a git repo or no remote + } + + return null; +} + +/** + * Get global config + */ +export function getGlobalConfig(): GlobalConfig { + const configPath = path.join(getConfigDir(), 'config.json'); + + if (fs.existsSync(configPath)) { + try { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // Invalid config + } + } + + return { + defaultGraph: 'default', + lastUsedGraph: 'default', + }; +} + +/** + * Update global config + */ +export function updateGlobalConfig(updates: Partial): void { + const config = { ...getGlobalConfig(), ...updates }; + const configPath = path.join(getConfigDir(), 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); +} + +/** + * Initialize a project with a .cortex.json file + */ +export function initProject(graphName?: string): string { + const name = graphName || detectGitProject() || path.basename(process.cwd()).toLowerCase(); + const configPath = path.join(process.cwd(), '.cortex.json'); + + if (fs.existsSync(configPath)) { + throw new Error('.cortex.json already exists'); + } + + // Create graph if needed + if (!graphExists(name)) { + createGraph(name); + } + + const config: LocalConfig = { + graph: name, + autoCapture: true, + contextInjection: { + maxTokens: 4000, + includeTasks: true, + }, + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return name; +} + +/** + * Parse a node reference that may include graph prefix + * Format: "graph:nodeId" or just "nodeId" + */ +export function parseNodeRef(ref: string): { graph?: string; nodeId: string } { + const colonIndex = ref.indexOf(':'); + if (colonIndex > 0 && colonIndex < ref.length - 1) { + return { + graph: ref.slice(0, colonIndex), + nodeId: ref.slice(colonIndex + 1), + }; + } + return { nodeId: ref }; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 0a7bbbe..98560bf 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -727,6 +727,90 @@ server.tool( } ); +// --- memory_graphs --- +import { listGraphs, createGraph, deleteGraph, useGraph, getActiveGraph, graphExists, initProject } from '../core/graphs'; + +server.tool( + 'memory_graphs', + 'List available knowledge graphs', + {}, + async () => { + const graphs = listGraphs(); + const active = getActiveGraph(); + return { + content: [{ + type: 'text' as const, + text: serialize({ + activeGraph: active, + graphs: graphs.map(g => ({ + name: g.name, + isActive: g.name === active, + nodeCount: g.nodeCount, + edgeCount: g.edgeCount, + size: g.size, + lastAccessed: new Date(g.lastAccessed).toISOString(), + })), + }), + }], + }; + } +); + +server.tool( + 'memory_use_graph', + 'Switch to a different knowledge graph', + { + name: z.string().describe('Graph name to switch to'), + create: z.boolean().optional().describe('Create graph if it does not exist'), + }, + async ({ name, create }) => { + if (!graphExists(name)) { + if (create) { + createGraph(name); + } else { + return { + content: [{ type: 'text' as const, text: serialize({ error: `Graph '${name}' does not exist` }) }], + isError: true, + }; + } + } + useGraph(name); + return { content: [{ type: 'text' as const, text: serialize({ switched: true, graph: name }) }] }; + } +); + +server.tool( + 'memory_create_graph', + 'Create a new knowledge graph', + { + name: z.string().describe('Graph name (alphanumeric, dashes, underscores)'), + }, + async ({ name }) => { + try { + const graph = createGraph(name); + return { content: [{ type: 'text' as const, text: serialize(graph) }] }; + } catch (err: any) { + return { content: [{ type: 'text' as const, text: serialize({ error: err.message }) }], isError: true }; + } + } +); + +server.tool( + 'memory_delete_graph', + 'Delete a knowledge graph (cannot delete "default")', + { + name: z.string().describe('Graph name to delete'), + }, + async ({ name }) => { + try { + deleteGraph(name); + return { content: [{ type: 'text' as const, text: serialize({ deleted: true, graph: name }) }] }; + } catch (err: any) { + return { content: [{ type: 'text' as const, text: serialize({ error: err.message }) }], isError: true }; + } + } +); + // --- memory_import --- import { importObsidian } from '../core/import/obsidian'; import { importMarkdown } from '../core/import/markdown';