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:
2026-02-02 14:53:26 +01:00
commit 21107443a7
21 changed files with 1624 additions and 0 deletions

37
src/cli/commands/add.ts Normal file
View 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
View 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
View 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
View 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
View 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(', '))}`);
}
});

View 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
View 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`);
}
}
});

View 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
View 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);
});