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:
156
src/cli/commands/graphs.ts
Normal file
156
src/cli/commands/graphs.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
432
src/core/graphs.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user