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
This commit is contained in:
2026-02-03 11:25:44 +01:00
parent 45998c73d0
commit aea3e93ff7
6 changed files with 833 additions and 4 deletions

156
src/cli/commands/graphs.ts Normal file
View File

@@ -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 <name>'));
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 <name>')
.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 <name>')
.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('<name>', '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);
}
});

View File

@@ -2,14 +2,72 @@ import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import { query } from '../../core/store'; import { query } from '../../core/store';
import { NodeKind } from '../../types'; import { NodeKind } from '../../types';
import { useGraph, getActiveGraph, listGraphs, graphExists } from '../../core/graphs';
import { closeDb, getDbForGraph } from '../../core/db';
export const queryCommand = new Command('query') export const queryCommand = new Command('query')
.argument('<text>', 'Natural language search query') .argument('<text>', 'Natural language search query')
.option('--kind <kind>', 'Filter by node kind') .option('--kind <kind>', 'Filter by node kind')
.option('--limit <n>', 'Max results', '10') .option('--limit <n>', 'Max results', '10')
.option('--format <fmt>', 'Output format: text or json', 'text') .option('--format <fmt>', 'Output format: text or json', 'text')
.option('--graph <name>', 'Query specific graph')
.option('--all-graphs', 'Search across all graphs')
.description('Search the knowledge graph') .description('Search the knowledge graph')
.action(async (text: string, opts) => { .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, { const results = await query(text, {
kind: opts.kind as NodeKind | undefined, kind: opts.kind as NodeKind | undefined,
limit: parseInt(opts.limit), limit: parseInt(opts.limit),

View File

@@ -22,7 +22,9 @@ import { ingestCommand, clipCommand } from './commands/ingest';
import { exportCommand, vizCommand } from './commands/export'; import { exportCommand, vizCommand } from './commands/export';
import { importCommand } from './commands/import'; import { importCommand } from './commands/import';
import { backupCommand, restoreDbCommand, listBackupsCommand } from './commands/backup-cmd'; import { backupCommand, restoreDbCommand, listBackupsCommand } from './commands/backup-cmd';
import { graphsCommand, useCommand, initCommand } from './commands/graphs';
import { closeDb } from '../core/db'; import { closeDb } from '../core/db';
import { migrateOldDatabase } from '../core/db';
const program = new Command(); const program = new Command();
@@ -62,6 +64,12 @@ program.addCommand(importCommand);
program.addCommand(backupCommand); program.addCommand(backupCommand);
program.addCommand(restoreDbCommand); program.addCommand(restoreDbCommand);
program.addCommand(listBackupsCommand); program.addCommand(listBackupsCommand);
program.addCommand(graphsCommand);
program.addCommand(useCommand);
program.addCommand(initCommand);
// Check for old database migration
migrateOldDatabase();
program.hook('postAction', () => { program.hook('postAction', () => {
closeDb(); closeDb();

View File

@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { getActiveGraph, getGraphDbPath, graphExists, createGraph, getGraphsDir } from './graphs';
const SCHEMA = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS nodes ( 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 _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 { 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 { 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)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
_db = new Database(path.join(dir, 'cortex.db')); _db = new Database(dbPath);
_db.pragma('journal_mode = WAL'); _db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON'); _db.pragma('foreign_keys = ON');
_db.exec(SCHEMA); _db.exec(SCHEMA);
_currentGraph = activeGraph;
// Migration: add last_accessed_at column // Migration: add last_accessed_at column
const cols = _db.prepare("PRAGMA table_info(nodes)").all() as any[]; const cols = _db.prepare("PRAGMA table_info(nodes)").all() as any[];
@@ -124,9 +160,64 @@ export function getDb(): Database.Database {
return _db; return _db;
} }
/**
* Close the database connection
*/
export function closeDb(): void { export function closeDb(): void {
if (_db) { if (_db) {
_db.close(); _db.close();
_db = null; _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;
}

432
src/core/graphs.ts Normal file
View File

@@ -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<GlobalConfig>): 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 };
}

View File

@@ -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 --- // --- memory_import ---
import { importObsidian } from '../core/import/obsidian'; import { importObsidian } from '../core/import/obsidian';
import { importMarkdown } from '../core/import/markdown'; import { importMarkdown } from '../core/import/markdown';