Initial commit: Cortex — AI project memory & knowledge graph
SQLite-backed knowledge graph with CLI interface. Supports nodes (memory, component, task, decision) connected by typed edges, with hybrid search (BM25 + Ollama embeddings).
This commit is contained in:
37
src/cli/commands/add.ts
Normal file
37
src/cli/commands/add.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { addNode } from '../../core/store';
|
||||
import { NodeKind } from '../../types';
|
||||
|
||||
const VALID_KINDS: NodeKind[] = ['memory', 'component', 'task', 'decision'];
|
||||
|
||||
export const addCommand = new Command('add')
|
||||
.argument('<kind>', `Node kind: ${VALID_KINDS.join(', ')}`)
|
||||
.requiredOption('-t, --title <title>', 'Node title')
|
||||
.option('-c, --content <content>', 'Node content/description', '')
|
||||
.option('--tags <tags>', 'Comma-separated tags')
|
||||
.option('--status <status>', 'Status (e.g. todo, doing, done, active, deprecated)')
|
||||
.description('Add a node to the knowledge graph')
|
||||
.action(async (kind: string, opts) => {
|
||||
if (!VALID_KINDS.includes(kind as NodeKind)) {
|
||||
console.error(chalk.red(`Invalid kind "${kind}". Must be one of: ${VALID_KINDS.join(', ')}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : [];
|
||||
const node = await addNode({
|
||||
kind: kind as NodeKind,
|
||||
title: opts.title,
|
||||
content: opts.content,
|
||||
status: opts.status,
|
||||
tags,
|
||||
});
|
||||
|
||||
console.log(chalk.green('✓ Added node'));
|
||||
console.log(` ID: ${chalk.cyan(node.id)}`);
|
||||
console.log(` Kind: ${node.kind}`);
|
||||
console.log(` Title: ${node.title}`);
|
||||
if (node.status) console.log(` Status: ${node.status}`);
|
||||
if (node.tags.length) console.log(` Tags: ${node.tags.join(', ')}`);
|
||||
if (node.embedding) console.log(` ${chalk.dim('(embedded)')}`);
|
||||
});
|
||||
27
src/cli/commands/graph.ts
Normal file
27
src/cli/commands/graph.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix } from '../../core/store';
|
||||
import { buildTree, renderTree } from '../../core/graph';
|
||||
|
||||
export const graphCommand = new Command('graph')
|
||||
.argument('[id]', 'Root node ID (or prefix). Omit for full graph.')
|
||||
.description('Visualize the knowledge graph as a tree')
|
||||
.action(async (idRaw?: string) => {
|
||||
let rootId: string | undefined;
|
||||
if (idRaw) {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
rootId = node.id;
|
||||
}
|
||||
|
||||
const trees = buildTree(rootId);
|
||||
if (trees.length === 0) {
|
||||
console.log(chalk.yellow('Graph is empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(renderTree(trees));
|
||||
});
|
||||
29
src/cli/commands/link.ts
Normal file
29
src/cli/commands/link.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { addEdge, findNodeByPrefix } from '../../core/store';
|
||||
import { EdgeType } from '../../types';
|
||||
|
||||
const VALID_TYPES: EdgeType[] = ['depends_on', 'contains', 'implements', 'blocked_by', 'subtask_of', 'relates_to', 'supersedes', 'about'];
|
||||
|
||||
export const linkCommand = new Command('link')
|
||||
.argument('<fromId>', 'Source node ID (or prefix)')
|
||||
.argument('<toId>', 'Target node ID (or prefix)')
|
||||
.requiredOption('--type <type>', `Edge type: ${VALID_TYPES.join(', ')}`)
|
||||
.description('Create a link between two nodes')
|
||||
.action(async (fromIdRaw: string, toIdRaw: string, opts) => {
|
||||
if (!VALID_TYPES.includes(opts.type)) {
|
||||
console.error(chalk.red(`Invalid edge type "${opts.type}". Must be one of: ${VALID_TYPES.join(', ')}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fromNode = findNodeByPrefix(fromIdRaw);
|
||||
const toNode = findNodeByPrefix(toIdRaw);
|
||||
|
||||
if (!fromNode) { console.error(chalk.red(`Node not found: ${fromIdRaw}`)); process.exit(1); }
|
||||
if (!toNode) { console.error(chalk.red(`Node not found: ${toIdRaw}`)); process.exit(1); }
|
||||
|
||||
const edge = addEdge(fromNode.id, toNode.id, opts.type as EdgeType);
|
||||
|
||||
console.log(chalk.green('✓ Linked'));
|
||||
console.log(` ${fromNode.title} ${chalk.dim(`-[${edge.type}]->`)} ${toNode.title}`);
|
||||
});
|
||||
42
src/cli/commands/list.ts
Normal file
42
src/cli/commands/list.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { listNodes } from '../../core/store';
|
||||
import { NodeKind } from '../../types';
|
||||
|
||||
export const listCommand = new Command('list')
|
||||
.option('--kind <kind>', 'Filter by kind')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.option('--tags <tags>', 'Comma-separated tags to filter')
|
||||
.option('--limit <n>', 'Max results')
|
||||
.option('--stale', 'Include stale nodes')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('List nodes')
|
||||
.action(async (opts) => {
|
||||
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : undefined;
|
||||
const nodes = listNodes({
|
||||
kind: opts.kind as NodeKind | undefined,
|
||||
status: opts.status,
|
||||
tags,
|
||||
limit: opts.limit ? parseInt(opts.limit) : undefined,
|
||||
includeStale: opts.stale,
|
||||
});
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.log(chalk.yellow('No nodes found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify(nodes.map(n => ({ ...n, embedding: undefined })), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const status = n.status ? chalk.yellow(` [${n.status}]`) : '';
|
||||
const tags = n.tags.length ? chalk.dim(` (${n.tags.join(', ')})`) : '';
|
||||
const stale = n.isStale ? chalk.red(' STALE') : '';
|
||||
console.log(`${chalk.cyan(n.id.slice(0, 8))} [${chalk.magenta(n.kind)}] ${n.title}${status}${tags}${stale}`);
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`\n${nodes.length} node(s)`));
|
||||
});
|
||||
34
src/cli/commands/query.ts
Normal file
34
src/cli/commands/query.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { query } from '../../core/store';
|
||||
import { NodeKind } from '../../types';
|
||||
|
||||
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')
|
||||
.description('Search the knowledge graph')
|
||||
.action(async (text: string, opts) => {
|
||||
const results = await query(text, {
|
||||
kind: opts.kind as NodeKind | undefined,
|
||||
limit: parseInt(opts.limit),
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log(chalk.yellow('No results found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify(results.map(r => ({ ...r.node, score: r.score, embedding: undefined })), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
const n = r.node;
|
||||
console.log(`${chalk.cyan(n.id.slice(0, 8))} [${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(', '))}`);
|
||||
}
|
||||
});
|
||||
23
src/cli/commands/remove.ts
Normal file
23
src/cli/commands/remove.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, removeNode } from '../../core/store';
|
||||
|
||||
export const removeCommand = new Command('remove')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--hard', 'Permanently delete (default: soft delete / mark stale)')
|
||||
.description('Remove a node')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const success = removeNode(node.id, opts.hard);
|
||||
if (success) {
|
||||
const method = opts.hard ? 'Deleted' : 'Marked stale';
|
||||
console.log(chalk.green(`✓ ${method}: [${node.kind}] ${node.title}`));
|
||||
} else {
|
||||
console.error(chalk.red('Remove failed.'));
|
||||
}
|
||||
});
|
||||
46
src/cli/commands/show.ts
Normal file
46
src/cli/commands/show.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix } from '../../core/store';
|
||||
import { getConnections } from '../../core/graph';
|
||||
|
||||
export const showCommand = new Command('show')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Show a node and its connections')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const conns = getConnections(node.id);
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ ...node, embedding: undefined, connections: conns }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
if (node.status) console.log(`Status: ${node.status}`);
|
||||
if (node.tags.length) console.log(`Tags: ${node.tags.join(', ')}`);
|
||||
console.log(`Created: ${new Date(node.createdAt).toLocaleString()}`);
|
||||
console.log(`Updated: ${new Date(node.updatedAt).toLocaleString()}`);
|
||||
if (node.isStale) console.log(chalk.red('STALE'));
|
||||
if (node.content) console.log(`\n${node.content}`);
|
||||
|
||||
if (conns.outgoing.length) {
|
||||
console.log(chalk.bold('\nOutgoing:'));
|
||||
for (const c of conns.outgoing) {
|
||||
console.log(` ${chalk.dim(`-[${c.type}]->`)} [${c.node.kind}] ${c.node.title} (${c.node.id.slice(0, 8)})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (conns.incoming.length) {
|
||||
console.log(chalk.bold('\nIncoming:'));
|
||||
for (const c of conns.incoming) {
|
||||
console.log(` [${c.node.kind}] ${c.node.title} (${c.node.id.slice(0, 8)}) ${chalk.dim(`-[${c.type}]->`)} this`);
|
||||
}
|
||||
}
|
||||
});
|
||||
41
src/cli/commands/update.ts
Normal file
41
src/cli/commands/update.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, updateNode } from '../../core/store';
|
||||
|
||||
export const updateCommand = new Command('update')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('-t, --title <title>', 'New title')
|
||||
.option('-c, --content <content>', 'New content')
|
||||
.option('--status <status>', 'New status')
|
||||
.option('--tags <tags>', 'Replace tags (comma-separated)')
|
||||
.option('--stale', 'Mark as stale')
|
||||
.description('Update a node')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const input: any = {};
|
||||
if (opts.title !== undefined) input.title = opts.title;
|
||||
if (opts.content !== undefined) input.content = opts.content;
|
||||
if (opts.status !== undefined) input.status = opts.status;
|
||||
if (opts.tags !== undefined) input.tags = opts.tags.split(',').map((t: string) => t.trim());
|
||||
if (opts.stale) input.isStale = true;
|
||||
|
||||
if (Object.keys(input).length === 0) {
|
||||
console.log(chalk.yellow('Nothing to update. Use --title, --content, --status, --tags, or --stale.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateNode(node.id, input);
|
||||
if (!updated) {
|
||||
console.error(chalk.red('Update failed.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✓ Updated'));
|
||||
console.log(` [${updated.kind}] ${updated.title}`);
|
||||
if (updated.status) console.log(` Status: ${updated.status}`);
|
||||
});
|
||||
37
src/cli/index.ts
Normal file
37
src/cli/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { addCommand } from './commands/add';
|
||||
import { queryCommand } from './commands/query';
|
||||
import { linkCommand } from './commands/link';
|
||||
import { showCommand } from './commands/show';
|
||||
import { listCommand } from './commands/list';
|
||||
import { updateCommand } from './commands/update';
|
||||
import { removeCommand } from './commands/remove';
|
||||
import { graphCommand } from './commands/graph';
|
||||
import { closeDb } from '../core/db';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('memory')
|
||||
.description('Cortex — AI project memory & knowledge graph')
|
||||
.version('1.0.0');
|
||||
|
||||
program.addCommand(addCommand);
|
||||
program.addCommand(queryCommand);
|
||||
program.addCommand(linkCommand);
|
||||
program.addCommand(showCommand);
|
||||
program.addCommand(listCommand);
|
||||
program.addCommand(updateCommand);
|
||||
program.addCommand(removeCommand);
|
||||
program.addCommand(graphCommand);
|
||||
|
||||
program.hook('postAction', () => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(err);
|
||||
closeDb();
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user