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 { 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('<text>', 'Natural language search query')
|
||||
.option('--kind <kind>', 'Filter by node kind')
|
||||
.option('--limit <n>', 'Max results', '10')
|
||||
.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')
|
||||
.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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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 ---
|
||||
import { importObsidian } from '../core/import/obsidian';
|
||||
import { importMarkdown } from '../core/import/markdown';
|
||||
|
||||
Reference in New Issue
Block a user