Compare commits
1 Commits
feature/co
...
761c7a247c
| Author | SHA1 | Date | |
|---|---|---|---|
| 761c7a247c |
@@ -1,81 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { getConfig, setConfig, listConfig, resetConfig, CortexConfig } from '../../core/config';
|
||||
|
||||
export const configCommand = new Command('config')
|
||||
.description('Manage Cortex configuration');
|
||||
|
||||
configCommand
|
||||
.command('get <key>')
|
||||
.description('Get a config value')
|
||||
.action((key: string) => {
|
||||
try {
|
||||
const value = getConfig(key as keyof CortexConfig);
|
||||
console.log(value);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Unknown config key: ${key}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
configCommand
|
||||
.command('set <key> <value>')
|
||||
.description('Set a config value')
|
||||
.action((key: string, value: string) => {
|
||||
// Validate key exists
|
||||
const validKeys = [
|
||||
'context.maxTokens',
|
||||
'context.maxNodes',
|
||||
'context.includeRecent',
|
||||
'context.includeProject',
|
||||
'context.includeTasks',
|
||||
'context.includeDecisions',
|
||||
];
|
||||
|
||||
if (!validKeys.includes(key)) {
|
||||
console.error(chalk.red(`Unknown config key: ${key}`));
|
||||
console.error(chalk.dim(`Valid keys: ${validKeys.join(', ')}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse value based on key type
|
||||
let parsed: string | number | boolean = value;
|
||||
if (key.startsWith('context.max')) {
|
||||
parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed)) {
|
||||
console.error(chalk.red(`Invalid number: ${value}`));
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (key.startsWith('context.include')) {
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
console.error(chalk.red(`Invalid boolean: ${value} (use true or false)`));
|
||||
process.exit(1);
|
||||
}
|
||||
parsed = value === 'true';
|
||||
}
|
||||
|
||||
setConfig(key as keyof CortexConfig, parsed as any);
|
||||
console.log(chalk.green(`Set ${key} = ${parsed}`));
|
||||
});
|
||||
|
||||
configCommand
|
||||
.command('list')
|
||||
.description('List all config values')
|
||||
.action(() => {
|
||||
const configs = listConfig();
|
||||
console.log(chalk.bold('Configuration:'));
|
||||
console.log();
|
||||
|
||||
for (const { key, value, isDefault } of configs) {
|
||||
const displayValue = isDefault ? chalk.dim(`${value} (default)`) : chalk.cyan(value);
|
||||
console.log(` ${chalk.yellow(key)}: ${displayValue}`);
|
||||
}
|
||||
});
|
||||
|
||||
configCommand
|
||||
.command('reset <key>')
|
||||
.description('Reset a config key to default')
|
||||
.action((key: string) => {
|
||||
resetConfig(key as keyof CortexConfig);
|
||||
console.log(chalk.green(`Reset ${key} to default`));
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import * as path from 'path';
|
||||
import { gatherContext, DEFAULT_CONTEXT_CONFIG } from '../../core/context';
|
||||
|
||||
export const contextCommand = new Command('context')
|
||||
.description('Preview or inject context for a Claude session')
|
||||
.option('-p, --project <name>', 'Project name (defaults to current directory name)')
|
||||
.option('-q, --query <text>', 'Additional semantic search query')
|
||||
.option('--max-tokens <n>', 'Maximum tokens', String(DEFAULT_CONTEXT_CONFIG.maxTokens))
|
||||
.option('--max-nodes <n>', 'Maximum nodes', String(DEFAULT_CONTEXT_CONFIG.maxNodes))
|
||||
.option('--format <fmt>', 'Output format: markdown or json', 'markdown')
|
||||
.option('--no-recent', 'Exclude recent activity')
|
||||
.option('--no-tasks', 'Exclude open tasks')
|
||||
.option('--no-decisions', 'Exclude decisions')
|
||||
.action(async (opts) => {
|
||||
const project = opts.project ?? path.basename(process.cwd());
|
||||
|
||||
const result = await gatherContext({
|
||||
project,
|
||||
semanticQuery: opts.query,
|
||||
config: {
|
||||
maxTokens: parseInt(opts.maxTokens),
|
||||
maxNodes: parseInt(opts.maxNodes),
|
||||
includeRecent: opts.recent !== false,
|
||||
includeTasks: opts.tasks !== false,
|
||||
includeDecisions: opts.decisions !== false,
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({
|
||||
project,
|
||||
nodeCount: result.nodes.length,
|
||||
nodes: result.nodes.map(r => ({
|
||||
id: r.node.id,
|
||||
kind: r.node.kind,
|
||||
title: r.node.title,
|
||||
score: r.score,
|
||||
reason: r.reason,
|
||||
})),
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Markdown output
|
||||
if (result.nodes.length === 0) {
|
||||
console.log(chalk.yellow('No relevant context found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(`# Context for ${project}`));
|
||||
console.log(chalk.dim(`(${result.nodes.length} nodes)`));
|
||||
console.log();
|
||||
console.log(result.formatted);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook command for Claude Code integration
|
||||
* Outputs context to stdout for injection
|
||||
*/
|
||||
export const contextHookCommand = new Command('context-hook')
|
||||
.description('Hook handler for Claude Code session start (outputs context to stdout)')
|
||||
.option('-p, --project <name>', 'Project name (defaults to current directory name)')
|
||||
.option('--max-tokens <n>', 'Maximum tokens', String(DEFAULT_CONTEXT_CONFIG.maxTokens))
|
||||
.action(async (opts) => {
|
||||
const project = opts.project ?? path.basename(process.cwd());
|
||||
|
||||
const result = await gatherContext({
|
||||
project,
|
||||
config: {
|
||||
maxTokens: parseInt(opts.maxTokens),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.nodes.length === 0) {
|
||||
// Output nothing if no context
|
||||
return;
|
||||
}
|
||||
|
||||
// Output markdown directly to stdout for Claude to consume
|
||||
console.log(`<cortex-context project="${project}">`);
|
||||
console.log(result.formatted);
|
||||
console.log('</cortex-context>');
|
||||
});
|
||||
90
src/cli/commands/diff.ts
Normal file
90
src/cli/commands/diff.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, diffVersions, getNodeAtTime, getNodeHistory } from '../../core/store';
|
||||
|
||||
export const diffCommand = new Command('diff')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--v1 <n>', 'First version number')
|
||||
.option('--v2 <n>', 'Second version number')
|
||||
.option('--from <date>', 'Start date (ISO format or timestamp)')
|
||||
.option('--to <date>', 'End date (ISO format or timestamp)')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Compare two versions of a node')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let v1: number;
|
||||
let v2: number;
|
||||
|
||||
if (opts.v1 && opts.v2) {
|
||||
v1 = parseInt(opts.v1);
|
||||
v2 = parseInt(opts.v2);
|
||||
} else if (opts.from && opts.to) {
|
||||
// Parse dates
|
||||
const fromTs = Date.parse(opts.from);
|
||||
const toTs = Date.parse(opts.to);
|
||||
|
||||
if (isNaN(fromTs) || isNaN(toTs)) {
|
||||
console.error(chalk.red('Invalid date format. Use ISO format (e.g., 2024-01-01) or timestamp.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fromNode = getNodeAtTime(node.id, fromTs);
|
||||
const toNode = getNodeAtTime(node.id, toTs);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
console.error(chalk.red('Could not find versions for the specified dates.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
v1 = fromNode.version;
|
||||
v2 = toNode.version;
|
||||
} else {
|
||||
// Default: compare latest two versions
|
||||
const history = getNodeHistory(node.id);
|
||||
if (history.length < 2) {
|
||||
console.log(chalk.yellow('Not enough versions to compare.'));
|
||||
return;
|
||||
}
|
||||
v1 = history[1].version; // Second latest
|
||||
v2 = history[0].version; // Latest
|
||||
}
|
||||
|
||||
const diff = diffVersions(node.id, v1, v2);
|
||||
if (!diff) {
|
||||
console.error(chalk.red('One or both versions not found.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify(diff, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(`Comparing: ${chalk.cyan(`v${v1}`)} -> ${chalk.cyan(`v${v2}`)}`);
|
||||
console.log('');
|
||||
|
||||
if (diff.changes.length === 0) {
|
||||
console.log(chalk.green('No changes between versions.'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const change of diff.changes) {
|
||||
console.log(chalk.bold(`${change.field}:`));
|
||||
|
||||
const oldStr = typeof change.old === 'string' ? change.old : JSON.stringify(change.old);
|
||||
const newStr = typeof change.new === 'string' ? change.new : JSON.stringify(change.new);
|
||||
|
||||
console.log(chalk.red(` - ${oldStr}`));
|
||||
console.log(chalk.green(` + ${newStr}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`${diff.changes.length} field(s) changed`));
|
||||
});
|
||||
48
src/cli/commands/history.ts
Normal file
48
src/cli/commands/history.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, getNodeHistory } from '../../core/store';
|
||||
|
||||
export const historyCommand = new Command('history')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Show version history for 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 history = getNodeHistory(node.id);
|
||||
|
||||
if (history.length === 0) {
|
||||
console.log(chalk.yellow('No version history found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ nodeId: node.id, title: node.title, versions: history }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(chalk.bold('\nVersion History:'));
|
||||
console.log('');
|
||||
|
||||
for (const v of history) {
|
||||
const validFrom = new Date(v.validFrom).toLocaleString();
|
||||
const validUntil = v.validUntil ? new Date(v.validUntil).toLocaleString() : chalk.green('current');
|
||||
const createdBy = chalk.dim(`(${v.createdBy})`);
|
||||
|
||||
console.log(` ${chalk.cyan(`v${v.version}`)} ${createdBy}`);
|
||||
console.log(` ${chalk.dim('From:')} ${validFrom}`);
|
||||
console.log(` ${chalk.dim('Until:')} ${validUntil}`);
|
||||
console.log(` ${chalk.dim('Title:')} ${v.title}`);
|
||||
if (v.status) console.log(` ${chalk.dim('Status:')} ${v.status}`);
|
||||
if (v.tags.length) console.log(` ${chalk.dim('Tags:')} ${v.tags.join(', ')}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`${history.length} version(s)`));
|
||||
});
|
||||
65
src/cli/commands/restore.ts
Normal file
65
src/cli/commands/restore.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix, restoreVersion, getNodeHistory, getCurrentVersion } from '../../core/store';
|
||||
|
||||
export const restoreCommand = new Command('restore')
|
||||
.argument('<id>', 'Node ID (or prefix)')
|
||||
.option('-v, --to-version <n>', 'Version number to restore')
|
||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
||||
.description('Restore a node to a previous version (creates new version)')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
if (!node) {
|
||||
console.error(chalk.red(`Node not found: ${idRaw}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!opts.toVersion) {
|
||||
// Show available versions and ask user to specify
|
||||
const history = getNodeHistory(node.id);
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(`Current version: ${chalk.cyan(`v${getCurrentVersion(node.id)}`)}`);
|
||||
console.log('');
|
||||
console.log(chalk.bold('Available versions:'));
|
||||
for (const v of history) {
|
||||
const validFrom = new Date(v.validFrom).toLocaleString();
|
||||
const current = v.validUntil === null ? chalk.green(' (current)') : '';
|
||||
console.log(` ${chalk.cyan(`v${v.version}`)} - ${validFrom} - ${v.title}${current}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(chalk.yellow('Use --to-version <n> or -v <n> to restore to a specific version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetVersion = parseInt(opts.toVersion);
|
||||
const currentVersion = getCurrentVersion(node.id);
|
||||
|
||||
if (targetVersion === currentVersion) {
|
||||
console.log(chalk.yellow('Cannot restore to the current version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const restored = await restoreVersion(node.id, targetVersion, 'restore');
|
||||
if (!restored) {
|
||||
console.error(chalk.red(`Version ${targetVersion} not found.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ message: `Restored to version ${targetVersion}`, node: { ...restored, embedding: undefined } }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Restored node to version ${targetVersion}`));
|
||||
console.log(`New version: ${chalk.cyan(`v${getCurrentVersion(node.id)}`)}`);
|
||||
console.log('');
|
||||
console.log(chalk.bold.cyan(`[${restored.kind}] ${restored.title}`));
|
||||
console.log(`ID: ${restored.id}`);
|
||||
if (restored.status) console.log(`Status: ${restored.status}`);
|
||||
if (restored.tags.length) console.log(`Tags: ${restored.tags.join(', ')}`);
|
||||
if (restored.content) {
|
||||
console.log('');
|
||||
console.log(restored.content);
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { findNodeByPrefix } from '../../core/store';
|
||||
import { findNodeByPrefix, getNodeAtTime, getCurrentVersion } 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')
|
||||
.option('--at <timestamp>', 'Show node at a specific point in time (ISO date or timestamp)')
|
||||
.description('Show a node and its connections')
|
||||
.action(async (idRaw: string, opts) => {
|
||||
const node = findNodeByPrefix(idRaw);
|
||||
@@ -14,15 +15,56 @@ export const showCommand = new Command('show')
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --at is specified, show historical state
|
||||
if (opts.at) {
|
||||
const ts = isNaN(Number(opts.at)) ? Date.parse(opts.at) : Number(opts.at);
|
||||
if (isNaN(ts)) {
|
||||
console.error(chalk.red('Invalid timestamp format. Use ISO date (e.g., 2024-01-01) or Unix timestamp.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const historical = getNodeAtTime(node.id, ts);
|
||||
if (!historical) {
|
||||
console.error(chalk.red('No version found for the specified time.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify(historical, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`Viewing historical state at: ${new Date(ts).toLocaleString()}`));
|
||||
console.log('');
|
||||
console.log(chalk.bold.cyan(`[${historical.kind}] ${historical.title}`));
|
||||
console.log(`ID: ${historical.id}`);
|
||||
console.log(`Version: v${historical.version}`);
|
||||
if (historical.status) console.log(`Status: ${historical.status}`);
|
||||
if (historical.tags.length) console.log(`Tags: ${historical.tags.join(', ')}`);
|
||||
console.log(`Valid: ${new Date(historical.validFrom).toLocaleString()} - ${historical.validUntil ? new Date(historical.validUntil).toLocaleString() : 'current'}`);
|
||||
if (historical.content) console.log(`\n${historical.content}`);
|
||||
|
||||
// Render structured sections
|
||||
if (historical.metadata?.sections && Array.isArray(historical.metadata.sections)) {
|
||||
for (const sec of historical.metadata.sections) {
|
||||
console.log(`\n${chalk.bold(`-- ${sec.label} --`)}`);
|
||||
if (sec.body) console.log(sec.body);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const conns = getConnections(node.id);
|
||||
const version = getCurrentVersion(node.id);
|
||||
|
||||
if (opts.format === 'json') {
|
||||
console.log(JSON.stringify({ ...node, embedding: undefined, connections: conns }, null, 2));
|
||||
console.log(JSON.stringify({ ...node, embedding: undefined, version, connections: conns }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||
console.log(`ID: ${node.id}`);
|
||||
console.log(`Version: v${version}`);
|
||||
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()}`);
|
||||
|
||||
@@ -11,8 +11,9 @@ import { graphCommand } from './commands/graph';
|
||||
import { serveCommand } from './commands/serve';
|
||||
import { decayCommand } from './commands/decay';
|
||||
import { childrenCommand } from './commands/children';
|
||||
import { contextCommand, contextHookCommand } from './commands/context';
|
||||
import { configCommand } from './commands/config';
|
||||
import { historyCommand } from './commands/history';
|
||||
import { diffCommand } from './commands/diff';
|
||||
import { restoreCommand } from './commands/restore';
|
||||
import { closeDb } from '../core/db';
|
||||
|
||||
const program = new Command();
|
||||
@@ -33,9 +34,9 @@ program.addCommand(graphCommand);
|
||||
program.addCommand(serveCommand);
|
||||
program.addCommand(decayCommand);
|
||||
program.addCommand(childrenCommand);
|
||||
program.addCommand(contextCommand);
|
||||
program.addCommand(contextHookCommand);
|
||||
program.addCommand(configCommand);
|
||||
program.addCommand(historyCommand);
|
||||
program.addCommand(diffCommand);
|
||||
program.addCommand(restoreCommand);
|
||||
|
||||
program.hook('postAction', () => {
|
||||
closeDb();
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { getDb } from './db';
|
||||
|
||||
export interface CortexConfig {
|
||||
// Context injection settings
|
||||
'context.maxTokens': number;
|
||||
'context.maxNodes': number;
|
||||
'context.includeRecent': boolean;
|
||||
'context.includeProject': boolean;
|
||||
'context.includeTasks': boolean;
|
||||
'context.includeDecisions': boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: CortexConfig = {
|
||||
'context.maxTokens': 4000,
|
||||
'context.maxNodes': 20,
|
||||
'context.includeRecent': true,
|
||||
'context.includeProject': true,
|
||||
'context.includeTasks': true,
|
||||
'context.includeDecisions': true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure the config table exists
|
||||
*/
|
||||
function ensureConfigTable(): void {
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a config value
|
||||
*/
|
||||
export function getConfig<K extends keyof CortexConfig>(key: K): CortexConfig[K] {
|
||||
ensureConfigTable();
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
return DEFAULTS[key];
|
||||
}
|
||||
|
||||
// Parse based on expected type
|
||||
const defaultValue = DEFAULTS[key];
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return (row.value === 'true') as unknown as CortexConfig[K];
|
||||
}
|
||||
if (typeof defaultValue === 'number') {
|
||||
return parseInt(row.value, 10) as unknown as CortexConfig[K];
|
||||
}
|
||||
return row.value as unknown as CortexConfig[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a config value
|
||||
*/
|
||||
export function setConfig<K extends keyof CortexConfig>(key: K, value: CortexConfig[K]): void {
|
||||
ensureConfigTable();
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
const strValue = String(value);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO config (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?
|
||||
`).run(key, strValue, now, strValue, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all config values
|
||||
*/
|
||||
export function listConfig(): Array<{ key: string; value: string; isDefault: boolean }> {
|
||||
ensureConfigTable();
|
||||
const db = getDb();
|
||||
const rows = db.prepare('SELECT key, value FROM config').all() as Array<{ key: string; value: string }>;
|
||||
const stored = new Map(rows.map(r => [r.key, r.value]));
|
||||
|
||||
const result: Array<{ key: string; value: string; isDefault: boolean }> = [];
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(DEFAULTS)) {
|
||||
const storedValue = stored.get(key);
|
||||
if (storedValue !== undefined) {
|
||||
result.push({ key, value: storedValue, isDefault: false });
|
||||
} else {
|
||||
result.push({ key, value: String(defaultValue), isDefault: true });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a config key to default
|
||||
*/
|
||||
export function resetConfig(key: keyof CortexConfig): void {
|
||||
ensureConfigTable();
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM config WHERE key = ?').run(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context config as a structured object
|
||||
*/
|
||||
export function getContextConfig() {
|
||||
return {
|
||||
maxTokens: getConfig('context.maxTokens'),
|
||||
maxNodes: getConfig('context.maxNodes'),
|
||||
includeRecent: getConfig('context.includeRecent'),
|
||||
includeProject: getConfig('context.includeProject'),
|
||||
includeTasks: getConfig('context.includeTasks'),
|
||||
includeDecisions: getConfig('context.includeDecisions'),
|
||||
};
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { getDb } from './db';
|
||||
import { query, listNodes } from './store';
|
||||
import { Node, NodeKind } from '../types';
|
||||
import { getContextConfig } from './config';
|
||||
|
||||
export interface RankedNode {
|
||||
node: Node;
|
||||
score: number;
|
||||
reason: 'recent' | 'project' | 'task' | 'decision' | 'semantic';
|
||||
}
|
||||
|
||||
export interface ContextConfig {
|
||||
maxTokens: number;
|
||||
maxNodes: number;
|
||||
includeRecent: boolean;
|
||||
includeProject: boolean;
|
||||
includeTasks: boolean;
|
||||
includeDecisions: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONTEXT_CONFIG: ContextConfig = {
|
||||
maxTokens: 4000,
|
||||
maxNodes: 20,
|
||||
includeRecent: true,
|
||||
includeProject: true,
|
||||
includeTasks: true,
|
||||
includeDecisions: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get nodes accessed within the last N hours
|
||||
*/
|
||||
export function getRecentNodes(hours: number = 48, limit: number = 10): Node[] {
|
||||
const db = getDb();
|
||||
const cutoff = Date.now() - hours * 60 * 60 * 1000;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM nodes
|
||||
WHERE is_stale = 0 AND last_accessed_at > ?
|
||||
ORDER BY last_accessed_at DESC
|
||||
LIMIT ?
|
||||
`).all(cutoff, limit) as any[];
|
||||
|
||||
return rows.map(rowToNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes tagged with a specific project name
|
||||
*/
|
||||
export function getProjectNodes(projectName: string, limit: number = 10): Node[] {
|
||||
return listNodes({ tags: [projectName.toLowerCase()], limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open tasks (status: todo, in_progress, or active)
|
||||
*/
|
||||
export function getOpenTasks(limit: number = 10): Node[] {
|
||||
const db = getDb();
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM nodes
|
||||
WHERE is_stale = 0
|
||||
AND kind = 'task'
|
||||
AND (status = 'todo' OR status = 'in_progress' OR status = 'active' OR status IS NULL)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit) as any[];
|
||||
|
||||
return rows.map(rowToNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent decisions
|
||||
*/
|
||||
export function getRecentDecisions(limit: number = 5): Node[] {
|
||||
return listNodes({ kind: 'decision', limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get semantic matches for a query
|
||||
*/
|
||||
export async function getSemanticMatches(queryText: string, limit: number = 10): Promise<RankedNode[]> {
|
||||
const results = await query(queryText, { limit });
|
||||
return results.map(r => ({
|
||||
node: r.node,
|
||||
score: r.score,
|
||||
reason: 'semantic' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a node (rough approximation)
|
||||
*/
|
||||
function estimateTokens(node: Node): number {
|
||||
const text = `${node.title} ${node.content || ''} ${node.tags.join(' ')}`;
|
||||
// Rough estimate: 1 token ≈ 4 characters
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate and rank nodes, keeping highest score per node
|
||||
*/
|
||||
function dedupeAndRank(candidates: RankedNode[]): RankedNode[] {
|
||||
const byId = new Map<string, RankedNode>();
|
||||
|
||||
for (const c of candidates) {
|
||||
const existing = byId.get(c.node.id);
|
||||
if (!existing || c.score > existing.score) {
|
||||
byId.set(c.node.id, c);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values()).sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select nodes within token budget
|
||||
*/
|
||||
function selectWithinBudget(candidates: RankedNode[], maxTokens: number, maxNodes: number): RankedNode[] {
|
||||
const selected: RankedNode[] = [];
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const c of candidates) {
|
||||
if (selected.length >= maxNodes) break;
|
||||
|
||||
const tokens = estimateTokens(c.node);
|
||||
if (totalTokens + tokens > maxTokens) continue;
|
||||
|
||||
selected.push(c);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group nodes by their reason
|
||||
*/
|
||||
function groupByReason(nodes: RankedNode[]): Record<string, RankedNode[]> {
|
||||
const groups: Record<string, RankedNode[]> = {};
|
||||
|
||||
for (const n of nodes) {
|
||||
if (!groups[n.reason]) groups[n.reason] = [];
|
||||
groups[n.reason].push(n);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single node for context output
|
||||
*/
|
||||
function formatNode(ranked: RankedNode): string {
|
||||
const n = ranked.node;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`### ${n.title}`);
|
||||
if (n.content) {
|
||||
// Truncate long content
|
||||
const content = n.content.length > 500 ? n.content.slice(0, 500) + '...' : n.content;
|
||||
lines.push(content);
|
||||
}
|
||||
if (n.tags.length) {
|
||||
lines.push(`*Tags: ${n.tags.join(', ')}*`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format context as markdown
|
||||
*/
|
||||
export function formatContext(nodes: RankedNode[]): string {
|
||||
if (nodes.length === 0) {
|
||||
return '*No relevant context found.*';
|
||||
}
|
||||
|
||||
const groups = groupByReason(nodes);
|
||||
const sections: string[] = [];
|
||||
|
||||
// Order: project > decisions > tasks > recent > semantic
|
||||
const order: Array<{ key: string; title: string }> = [
|
||||
{ key: 'project', title: 'Project Context' },
|
||||
{ key: 'decision', title: 'Key Decisions' },
|
||||
{ key: 'task', title: 'Open Tasks' },
|
||||
{ key: 'recent', title: 'Recent Activity' },
|
||||
{ key: 'semantic', title: 'Related Memories' },
|
||||
];
|
||||
|
||||
for (const { key, title } of order) {
|
||||
const group = groups[key];
|
||||
if (group?.length) {
|
||||
sections.push(`## ${title}\n\n${group.map(formatNode).join('\n\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main context gathering function
|
||||
*/
|
||||
export async function gatherContext(
|
||||
options: {
|
||||
project?: string;
|
||||
semanticQuery?: string;
|
||||
config?: Partial<ContextConfig>;
|
||||
} = {}
|
||||
): Promise<{ nodes: RankedNode[]; formatted: string }> {
|
||||
// Load stored config, then override with any passed options
|
||||
const storedConfig = getContextConfig();
|
||||
const config = { ...storedConfig, ...options.config };
|
||||
const candidates: RankedNode[] = [];
|
||||
|
||||
// 1. Project-specific nodes (highest priority)
|
||||
if (config.includeProject && options.project) {
|
||||
const projectNodes = getProjectNodes(options.project, 10);
|
||||
candidates.push(...projectNodes.map(node => ({
|
||||
node,
|
||||
score: 0.95,
|
||||
reason: 'project' as const,
|
||||
})));
|
||||
}
|
||||
|
||||
// 2. Recent decisions
|
||||
if (config.includeDecisions) {
|
||||
const decisions = getRecentDecisions(5);
|
||||
candidates.push(...decisions.map(node => ({
|
||||
node,
|
||||
score: 0.85,
|
||||
reason: 'decision' as const,
|
||||
})));
|
||||
}
|
||||
|
||||
// 3. Open tasks
|
||||
if (config.includeTasks) {
|
||||
const tasks = getOpenTasks(5);
|
||||
candidates.push(...tasks.map(node => ({
|
||||
node,
|
||||
score: 0.80,
|
||||
reason: 'task' as const,
|
||||
})));
|
||||
}
|
||||
|
||||
// 4. Recent activity
|
||||
if (config.includeRecent) {
|
||||
const recent = getRecentNodes(48, 10);
|
||||
candidates.push(...recent.map(node => ({
|
||||
node,
|
||||
score: 0.70,
|
||||
reason: 'recent' as const,
|
||||
})));
|
||||
}
|
||||
|
||||
// 5. Semantic search on project context
|
||||
if (options.semanticQuery) {
|
||||
const semantic = await getSemanticMatches(options.semanticQuery, 10);
|
||||
// Scale semantic scores to fit our priority scheme
|
||||
candidates.push(...semantic.map(r => ({
|
||||
...r,
|
||||
score: r.score * 0.6, // Max 0.6 for semantic matches
|
||||
})));
|
||||
}
|
||||
|
||||
// Dedupe, rank, and select within budget
|
||||
const ranked = dedupeAndRank(candidates);
|
||||
const selected = selectWithinBudget(ranked, config.maxTokens, config.maxNodes);
|
||||
|
||||
return {
|
||||
nodes: selected,
|
||||
formatted: formatContext(selected),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to convert DB row to Node (duplicated from store.ts to avoid circular deps)
|
||||
function rowToNode(row: any): Node {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
status: row.status ?? undefined,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
embedding: null, // Don't load embeddings for context
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastAccessedAt: row.last_accessed_at ?? row.updated_at,
|
||||
isStale: !!row.is_stale,
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,21 @@ CREATE TABLE IF NOT EXISTS node_tags (
|
||||
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);
|
||||
@@ -40,6 +55,9 @@ 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);
|
||||
`;
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
@@ -68,6 +86,41 @@ export function getDb(): Database.Database {
|
||||
_db.exec('UPDATE nodes SET last_accessed_at = updated_at WHERE last_accessed_at IS NULL');
|
||||
}
|
||||
|
||||
// Migration: add version column to nodes table
|
||||
if (!cols.some((c: any) => c.name === 'version')) {
|
||||
_db.exec('ALTER TABLE nodes ADD COLUMN version INTEGER DEFAULT 1');
|
||||
_db.exec('UPDATE nodes SET version = 1 WHERE version IS NULL');
|
||||
}
|
||||
|
||||
// Migration: backfill node_versions for existing nodes without versions
|
||||
const existingWithoutVersion = _db.prepare(`
|
||||
SELECT * FROM nodes WHERE id NOT IN (SELECT DISTINCT node_id FROM node_versions)
|
||||
`).all() as any[];
|
||||
|
||||
if (existingWithoutVersion.length > 0) {
|
||||
const insertVersion = _db.prepare(`
|
||||
INSERT INTO node_versions (id, node_id, version, title, content, status, tags, metadata, valid_from, valid_until, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of existingWithoutVersion) {
|
||||
const versionId = require('crypto').randomUUID();
|
||||
insertVersion.run(
|
||||
versionId,
|
||||
node.id,
|
||||
1,
|
||||
node.title,
|
||||
node.content,
|
||||
node.status,
|
||||
node.tags,
|
||||
node.metadata,
|
||||
node.created_at,
|
||||
null,
|
||||
'migration'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID as uuid } from 'crypto';
|
||||
import { getDb } from './db';
|
||||
import { Node, Edge, AddNodeInput, UpdateNodeInput, ListOptions, QueryOptions, SearchResult, EdgeType } from '../types';
|
||||
import { Node, Edge, AddNodeInput, UpdateNodeInput, ListOptions, QueryOptions, SearchResult, EdgeType, NodeVersion, HistoricalNode, NodeDiff } from '../types';
|
||||
import { hybridSearch, deserializeEmbedding } from './search/index';
|
||||
import { getEmbedding } from './search/ollama';
|
||||
|
||||
@@ -43,21 +43,44 @@ export async function addNode(input: AddNodeInput): Promise<Node> {
|
||||
// Try to get embedding
|
||||
const embedding = await getEmbedding(`${input.title} ${content}`);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, input.kind, input.title, content, input.status ?? null,
|
||||
JSON.stringify(tags), JSON.stringify(metadata),
|
||||
embedding ? serializeEmbedding(embedding) : null,
|
||||
now, now, now
|
||||
);
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at, last_accessed_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, input.kind, input.title, content, input.status ?? null,
|
||||
JSON.stringify(tags), JSON.stringify(metadata),
|
||||
embedding ? serializeEmbedding(embedding) : null,
|
||||
now, now, now, 1
|
||||
);
|
||||
|
||||
// Insert tags
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of tags) {
|
||||
insertTag.run(id, tag);
|
||||
}
|
||||
// Insert tags
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of tags) {
|
||||
insertTag.run(id, tag);
|
||||
}
|
||||
|
||||
// Create initial version record
|
||||
const versionId = uuid();
|
||||
db.prepare(`
|
||||
INSERT INTO node_versions (id, node_id, version, title, content, status, tags, metadata, valid_from, valid_until, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
versionId,
|
||||
id,
|
||||
1,
|
||||
input.title,
|
||||
content,
|
||||
input.status ?? null,
|
||||
JSON.stringify(tags),
|
||||
JSON.stringify(metadata),
|
||||
now,
|
||||
null,
|
||||
'user'
|
||||
);
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
notifyDirty();
|
||||
return {
|
||||
@@ -114,46 +137,88 @@ export function listNodes(options: ListOptions = {}): Node[] {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function updateNode(id: string, input: UpdateNodeInput): Promise<Node | null> {
|
||||
export async function updateNode(id: string, input: UpdateNodeInput, createdBy: string = 'user'): Promise<Node | null> {
|
||||
const db = getDb();
|
||||
const existing = getNode(id);
|
||||
if (!existing) return null;
|
||||
// Get existing node without updating last_accessed_at
|
||||
const existingRow = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
|
||||
if (!existingRow) return null;
|
||||
const existing = rowToNode(existingRow);
|
||||
|
||||
const now = Date.now();
|
||||
const sets: string[] = ['updated_at = ?'];
|
||||
const params: any[] = [now];
|
||||
|
||||
if (input.title !== undefined) { sets.push('title = ?'); params.push(input.title); }
|
||||
if (input.content !== undefined) { sets.push('content = ?'); params.push(input.content); }
|
||||
if (input.status !== undefined) { sets.push('status = ?'); params.push(input.status); }
|
||||
if (input.isStale !== undefined) { sets.push('is_stale = ?'); params.push(input.isStale ? 1 : 0); }
|
||||
if (input.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(input.tags)); }
|
||||
if (input.metadata !== undefined) {
|
||||
const merged = { ...existing.metadata, ...input.metadata };
|
||||
sets.push('metadata = ?');
|
||||
params.push(JSON.stringify(merged));
|
||||
}
|
||||
// Get current version number
|
||||
const currentVersion = existingRow.version ?? 1;
|
||||
const newVersion = currentVersion + 1;
|
||||
|
||||
// Re-embed if title or content changed
|
||||
// Create version snapshot in a transaction
|
||||
const transaction = db.transaction(() => {
|
||||
// Close out the current version by setting valid_until
|
||||
db.prepare(`
|
||||
UPDATE node_versions SET valid_until = ? WHERE node_id = ? AND valid_until IS NULL
|
||||
`).run(now, id);
|
||||
|
||||
// Insert new version record with the NEW state (after update)
|
||||
const versionId = uuid();
|
||||
const newTitle = input.title ?? existing.title;
|
||||
const newContent = input.content ?? existing.content;
|
||||
const newStatus = input.status !== undefined ? input.status : existing.status;
|
||||
const newTags = input.tags ?? existing.tags;
|
||||
const newMetadata = input.metadata !== undefined ? { ...existing.metadata, ...input.metadata } : existing.metadata;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO node_versions (id, node_id, version, title, content, status, tags, metadata, valid_from, valid_until, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
versionId,
|
||||
id,
|
||||
newVersion,
|
||||
newTitle,
|
||||
newContent,
|
||||
newStatus ?? null,
|
||||
JSON.stringify(newTags),
|
||||
JSON.stringify(newMetadata),
|
||||
now,
|
||||
null,
|
||||
createdBy
|
||||
);
|
||||
|
||||
// Build the update query
|
||||
const sets: string[] = ['updated_at = ?', 'version = ?'];
|
||||
const params: any[] = [now, newVersion];
|
||||
|
||||
if (input.title !== undefined) { sets.push('title = ?'); params.push(input.title); }
|
||||
if (input.content !== undefined) { sets.push('content = ?'); params.push(input.content); }
|
||||
if (input.status !== undefined) { sets.push('status = ?'); params.push(input.status); }
|
||||
if (input.isStale !== undefined) { sets.push('is_stale = ?'); params.push(input.isStale ? 1 : 0); }
|
||||
if (input.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(input.tags)); }
|
||||
if (input.metadata !== undefined) {
|
||||
const merged = { ...existing.metadata, ...input.metadata };
|
||||
sets.push('metadata = ?');
|
||||
params.push(JSON.stringify(merged));
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE nodes SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
// Update tags if changed
|
||||
if (input.tags !== undefined) {
|
||||
db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id);
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of input.tags) {
|
||||
insertTag.run(id, tag);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
// Re-embed if title or content changed (outside transaction since it's async)
|
||||
if (input.title !== undefined || input.content !== undefined) {
|
||||
const newTitle = input.title ?? existing.title;
|
||||
const newContent = input.content ?? existing.content;
|
||||
const embedding = await getEmbedding(`${newTitle} ${newContent}`);
|
||||
if (embedding) {
|
||||
sets.push('embedding = ?');
|
||||
params.push(serializeEmbedding(embedding));
|
||||
}
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE nodes SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
// Update tags if changed
|
||||
if (input.tags !== undefined) {
|
||||
db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id);
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||
for (const tag of input.tags) {
|
||||
insertTag.run(id, tag);
|
||||
db.prepare('UPDATE nodes SET embedding = ? WHERE id = ?').run(serializeEmbedding(embedding), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,3 +260,114 @@ export async function query(text: string, options: QueryOptions = {}): Promise<S
|
||||
const nodes = listNodes({ kind: options.kind, tags: options.tags, includeStale: options.includeStale });
|
||||
return hybridSearch(nodes, text, options);
|
||||
}
|
||||
|
||||
// Version tracking functions
|
||||
|
||||
function rowToNodeVersion(row: any): NodeVersion {
|
||||
return {
|
||||
id: row.id,
|
||||
nodeId: row.node_id,
|
||||
version: row.version,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
status: row.status ?? undefined,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
validFrom: row.valid_from,
|
||||
validUntil: row.valid_until ?? null,
|
||||
createdBy: row.created_by,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToHistoricalNode(row: any, nodeRow: any): HistoricalNode {
|
||||
return {
|
||||
id: nodeRow.id,
|
||||
kind: nodeRow.kind,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
status: row.status ?? undefined,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
version: row.version,
|
||||
validFrom: row.valid_from,
|
||||
validUntil: row.valid_until ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeHistory(id: string): NodeVersion[] {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM node_versions WHERE node_id = ? ORDER BY version DESC
|
||||
`).all(id) as any[];
|
||||
return rows.map(rowToNodeVersion);
|
||||
}
|
||||
|
||||
export function getNodeAtTime(id: string, timestamp: number): HistoricalNode | null {
|
||||
const db = getDb();
|
||||
const nodeRow = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
|
||||
if (!nodeRow) return null;
|
||||
|
||||
const versionRow = db.prepare(`
|
||||
SELECT * FROM node_versions
|
||||
WHERE node_id = ? AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)
|
||||
ORDER BY version DESC LIMIT 1
|
||||
`).get(id, timestamp, timestamp) as any;
|
||||
|
||||
if (!versionRow) return null;
|
||||
return rowToHistoricalNode(versionRow, nodeRow);
|
||||
}
|
||||
|
||||
export function getNodeVersion(id: string, version: number): HistoricalNode | null {
|
||||
const db = getDb();
|
||||
const nodeRow = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as any;
|
||||
if (!nodeRow) return null;
|
||||
|
||||
const versionRow = db.prepare(`
|
||||
SELECT * FROM node_versions WHERE node_id = ? AND version = ?
|
||||
`).get(id, version) as any;
|
||||
|
||||
if (!versionRow) return null;
|
||||
return rowToHistoricalNode(versionRow, nodeRow);
|
||||
}
|
||||
|
||||
export function diffVersions(id: string, v1: number, v2: number): NodeDiff | null {
|
||||
const version1 = getNodeVersion(id, v1);
|
||||
const version2 = getNodeVersion(id, v2);
|
||||
|
||||
if (!version1 || !version2) return null;
|
||||
|
||||
const changes: NodeDiff['changes'] = [];
|
||||
const fieldsToCompare: (keyof HistoricalNode)[] = ['title', 'content', 'status', 'tags', 'metadata'];
|
||||
|
||||
for (const field of fieldsToCompare) {
|
||||
const oldVal = version1[field];
|
||||
const newVal = version2[field];
|
||||
const oldStr = JSON.stringify(oldVal);
|
||||
const newStr = JSON.stringify(newVal);
|
||||
if (oldStr !== newStr) {
|
||||
changes.push({ field, old: oldVal, new: newVal });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodeId: id, v1, v2, changes };
|
||||
}
|
||||
|
||||
export async function restoreVersion(id: string, version: number, createdBy: string = 'restore'): Promise<Node | null> {
|
||||
const historical = getNodeVersion(id, version);
|
||||
if (!historical) return null;
|
||||
|
||||
// updateNode will handle creating a new version
|
||||
return updateNode(id, {
|
||||
title: historical.title,
|
||||
content: historical.content,
|
||||
status: historical.status,
|
||||
tags: historical.tags,
|
||||
metadata: historical.metadata,
|
||||
}, createdBy);
|
||||
}
|
||||
|
||||
export function getCurrentVersion(id: string): number {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT version FROM nodes WHERE id = ?').get(id) as any;
|
||||
return row?.version ?? 1;
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { getDb } from './db';
|
||||
import { isGenAvailable, generate } from './search/ollamaGen';
|
||||
|
||||
export interface SummaryData {
|
||||
generatedAt: number;
|
||||
overview: string;
|
||||
components: { title: string; status?: string; summary?: string }[];
|
||||
decisions: { title: string; status?: string }[];
|
||||
tasks: { todo: string[]; in_progress: string[]; done: string[] };
|
||||
memories: { byTag: Record<string, string[]> };
|
||||
stats: { total: number; stale: number; orphans: number; edges: number };
|
||||
}
|
||||
|
||||
// Ensure cache table exists
|
||||
function ensureCacheTable(): void {
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS system_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function getCachedSummary(): SummaryData | null {
|
||||
ensureCacheTable();
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT value FROM system_cache WHERE key = ?').get('summary') as { value: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheSummary(data: SummaryData): void {
|
||||
ensureCacheTable();
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO system_cache (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`).run('summary', JSON.stringify(data), Date.now());
|
||||
}
|
||||
|
||||
export async function generateSummary(): Promise<SummaryData> {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
|
||||
// Gather all active nodes
|
||||
const rows = db.prepare(`
|
||||
SELECT id, kind, title, status, tags, metadata, content
|
||||
FROM nodes WHERE is_stale = 0
|
||||
ORDER BY kind, title
|
||||
`).all() as any[];
|
||||
|
||||
// Stats
|
||||
const totalNodes = rows.length;
|
||||
const staleCount = (db.prepare('SELECT COUNT(*) as c FROM nodes WHERE is_stale = 1').get() as any).c;
|
||||
const edgeCount = (db.prepare('SELECT COUNT(*) as c FROM edges').get() as any).c;
|
||||
|
||||
const edgeRows = db.prepare('SELECT from_id, to_id FROM edges').all() as any[];
|
||||
const hasEdge = new Set<string>();
|
||||
for (const e of edgeRows) {
|
||||
hasEdge.add(e.from_id);
|
||||
hasEdge.add(e.to_id);
|
||||
}
|
||||
const orphanCount = rows.filter(r => !hasEdge.has(r.id)).length;
|
||||
|
||||
// Categorize nodes
|
||||
const components: SummaryData['components'] = [];
|
||||
const decisions: SummaryData['decisions'] = [];
|
||||
const tasks: SummaryData['tasks'] = { todo: [], in_progress: [], done: [] };
|
||||
const memoryTags: Record<string, string[]> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const meta = JSON.parse(row.metadata || '{}');
|
||||
|
||||
if (row.kind === 'component') {
|
||||
components.push({
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
summary: meta.summary || (row.content?.slice(0, 100) + (row.content?.length > 100 ? '...' : '')),
|
||||
});
|
||||
} else if (row.kind === 'decision') {
|
||||
decisions.push({ title: row.title, status: row.status });
|
||||
} else if (row.kind === 'task') {
|
||||
const status = (row.status || 'todo').toLowerCase();
|
||||
if (status === 'done' || status === 'completed') {
|
||||
tasks.done.push(row.title);
|
||||
} else if (status === 'in_progress' || status === 'in-progress') {
|
||||
tasks.in_progress.push(row.title);
|
||||
} else {
|
||||
tasks.todo.push(row.title);
|
||||
}
|
||||
} else if (row.kind === 'memory') {
|
||||
const tags: string[] = JSON.parse(row.tags || '[]');
|
||||
// Group by first tag, or 'untagged'
|
||||
const primaryTag = tags[0] || 'untagged';
|
||||
if (!memoryTags[primaryTag]) memoryTags[primaryTag] = [];
|
||||
memoryTags[primaryTag].push(row.title);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort tags by count descending, limit to top 10
|
||||
const sortedTags = Object.entries(memoryTags)
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.slice(0, 10);
|
||||
const limitedMemoryTags: Record<string, string[]> = {};
|
||||
for (const [tag, titles] of sortedTags) {
|
||||
// Limit to 5 titles per tag
|
||||
limitedMemoryTags[tag] = titles.slice(0, 5);
|
||||
if (titles.length > 5) {
|
||||
limitedMemoryTags[tag].push(`+${titles.length - 5} more`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate overview
|
||||
let overview: string;
|
||||
const aiAvailable = await isGenAvailable();
|
||||
|
||||
if (aiAvailable) {
|
||||
const prompt = `Write a single sentence (max 30 words) summarizing this knowledge graph for a developer starting a coding session.
|
||||
|
||||
Stats: ${totalNodes} nodes (${components.length} components, ${decisions.length} decisions, ${tasks.todo.length + tasks.in_progress.length + tasks.done.length} tasks, ${totalNodes - components.length - decisions.length - tasks.todo.length - tasks.in_progress.length - tasks.done.length} memories), ${edgeCount} edges.
|
||||
|
||||
Components: ${components.map(c => c.title).join(', ')}
|
||||
Active tasks: ${[...tasks.todo, ...tasks.in_progress].join(', ') || 'none'}
|
||||
|
||||
Output ONLY the summary sentence.`;
|
||||
|
||||
const aiOverview = await generate(prompt);
|
||||
overview = aiOverview || buildFallbackOverview(totalNodes, components.length, decisions.length, tasks, edgeCount);
|
||||
} else {
|
||||
overview = buildFallbackOverview(totalNodes, components.length, decisions.length, tasks, edgeCount);
|
||||
}
|
||||
|
||||
const summary: SummaryData = {
|
||||
generatedAt: now,
|
||||
overview,
|
||||
components,
|
||||
decisions,
|
||||
tasks,
|
||||
memories: { byTag: limitedMemoryTags },
|
||||
stats: { total: totalNodes, stale: staleCount, orphans: orphanCount, edges: edgeCount },
|
||||
};
|
||||
|
||||
cacheSummary(summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildFallbackOverview(
|
||||
total: number,
|
||||
components: number,
|
||||
decisions: number,
|
||||
tasks: SummaryData['tasks'],
|
||||
edges: number
|
||||
): string {
|
||||
const pending = tasks.todo.length + tasks.in_progress.length;
|
||||
const parts = [`${total} nodes`, `${components} components`, `${decisions} decisions`];
|
||||
if (pending > 0) parts.push(`${pending} open tasks`);
|
||||
parts.push(`${edges} edges`);
|
||||
return parts.join(', ') + '.';
|
||||
}
|
||||
129
src/mcp/index.ts
129
src/mcp/index.ts
@@ -1,7 +1,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod/v3';
|
||||
import { query, listNodes, getNode, findNodeByPrefix, addNode, addEdge, removeNode, removeEdge, updateNode } from '../core/store';
|
||||
import { query, listNodes, getNode, findNodeByPrefix, addNode, addEdge, removeNode, removeEdge, updateNode, getNodeHistory, getNodeAtTime, getNodeVersion, diffVersions, restoreVersion } from '../core/store';
|
||||
import { getConnections, getEdgesByNode } from '../core/graph';
|
||||
import { cosineSimilarity } from '../core/search/vector';
|
||||
import { getDb } from '../core/db';
|
||||
@@ -367,37 +367,6 @@ server.tool(
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_context ---
|
||||
import { gatherContext, DEFAULT_CONTEXT_CONFIG } from '../core/context';
|
||||
|
||||
server.tool(
|
||||
'memory_context',
|
||||
'Get relevant context for a Claude session. Gathers recent activity, project-specific nodes, open tasks, and decisions. Use at session start.',
|
||||
{
|
||||
project: z.string().optional().describe('Project name to filter by (e.g. "cortex")'),
|
||||
query: z.string().optional().describe('Optional semantic search query'),
|
||||
maxTokens: z.number().optional().describe(`Max tokens in output (default ${DEFAULT_CONTEXT_CONFIG.maxTokens})`),
|
||||
maxNodes: z.number().optional().describe(`Max nodes to include (default ${DEFAULT_CONTEXT_CONFIG.maxNodes})`),
|
||||
},
|
||||
async ({ project, query: semanticQuery, maxTokens, maxNodes }) => {
|
||||
const result = await gatherContext({
|
||||
project,
|
||||
semanticQuery,
|
||||
config: {
|
||||
maxTokens: maxTokens ?? DEFAULT_CONTEXT_CONFIG.maxTokens,
|
||||
maxNodes: maxNodes ?? DEFAULT_CONTEXT_CONFIG.maxNodes,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: result.formatted,
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_summary ---
|
||||
import { getCachedSummary, generateSummary } from '../core/summary';
|
||||
|
||||
@@ -431,6 +400,102 @@ server.tool(
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_history ---
|
||||
server.tool(
|
||||
'memory_history',
|
||||
'Get version history for a node',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
},
|
||||
async ({ id }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
const history = getNodeHistory(node.id);
|
||||
return { content: [{ type: 'text' as const, text: serialize({ nodeId: node.id, title: node.title, versions: history }) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_show_at ---
|
||||
server.tool(
|
||||
'memory_show_at',
|
||||
'Show node at a specific point in time',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
timestamp: z.union([z.number(), z.string()]).describe('Unix ms or ISO date string'),
|
||||
},
|
||||
async ({ id, timestamp }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
let ts: number;
|
||||
if (typeof timestamp === 'number') {
|
||||
ts = timestamp;
|
||||
} else {
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (isNaN(parsed)) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Invalid timestamp format' }) }], isError: true };
|
||||
}
|
||||
ts = parsed;
|
||||
}
|
||||
|
||||
const historical = getNodeAtTime(node.id, ts);
|
||||
if (!historical) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'No version found for the specified time' }) }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: serialize(historical) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_diff ---
|
||||
server.tool(
|
||||
'memory_diff',
|
||||
'Compare two versions of a node',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
v1: z.number().describe('First version number'),
|
||||
v2: z.number().describe('Second version number'),
|
||||
},
|
||||
async ({ id, v1, v2 }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
|
||||
const diff = diffVersions(node.id, v1, v2);
|
||||
if (!diff) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'One or both versions not found' }) }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: serialize(diff) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- memory_restore ---
|
||||
server.tool(
|
||||
'memory_restore',
|
||||
'Restore a node to a previous version (creates new version)',
|
||||
{
|
||||
id: z.string().describe('Node ID or prefix'),
|
||||
version: z.number().describe('Version number to restore'),
|
||||
},
|
||||
async ({ id, version }) => {
|
||||
const node = getNode(id) ?? findNodeByPrefix(id);
|
||||
if (!node) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Node not found' }) }], isError: true };
|
||||
}
|
||||
|
||||
const restored = await restoreVersion(node.id, version);
|
||||
if (!restored) {
|
||||
return { content: [{ type: 'text' as const, text: serialize({ error: 'Version not found' }) }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: serialize({ message: `Restored to version ${version}`, node: restored }) }] };
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getConnections, buildTree } from '../core/graph';
|
||||
import { getDb } from '../core/db';
|
||||
import { determineGroupingStrategy, groupResults } from './queryOrganizer';
|
||||
import { getLastReport, markDirty, runMaintenance } from './heartbeat';
|
||||
import { gatherContext } from '../core/context';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -167,42 +166,6 @@ router.post('/maintenance/run', async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Context — get session context for Claude
|
||||
router.get('/context', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const project = req.query.project as string | undefined;
|
||||
const semanticQuery = req.query.query as string | undefined;
|
||||
const maxTokens = req.query.maxTokens ? parseInt(req.query.maxTokens as string) : undefined;
|
||||
const maxNodes = req.query.maxNodes ? parseInt(req.query.maxNodes as string) : undefined;
|
||||
const format = req.query.format as string || 'markdown';
|
||||
|
||||
const result = await gatherContext({
|
||||
project,
|
||||
semanticQuery,
|
||||
config: { maxTokens, maxNodes } as any,
|
||||
});
|
||||
|
||||
if (format === 'json') {
|
||||
res.json({
|
||||
project,
|
||||
nodeCount: result.nodes.length,
|
||||
nodes: result.nodes.map(r => ({
|
||||
id: r.node.id,
|
||||
kind: r.node.kind,
|
||||
title: r.node.title,
|
||||
score: r.score,
|
||||
reason: r.reason,
|
||||
})),
|
||||
formatted: result.formatted,
|
||||
});
|
||||
} else {
|
||||
res.type('text/markdown').send(result.formatted);
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Prompt — AI-driven natural language instruction
|
||||
router.post('/prompt', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -71,3 +71,42 @@ export interface ListOptions {
|
||||
limit?: number;
|
||||
includeStale?: boolean;
|
||||
}
|
||||
|
||||
// Version tracking types
|
||||
export interface NodeVersion {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status?: string;
|
||||
tags: string[];
|
||||
metadata: Record<string, any>;
|
||||
validFrom: number;
|
||||
validUntil: number | null;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface HistoricalNode {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
title: string;
|
||||
content: string;
|
||||
status?: string;
|
||||
tags: string[];
|
||||
metadata: Record<string, any>;
|
||||
version: number;
|
||||
validFrom: number;
|
||||
validUntil: number | null;
|
||||
}
|
||||
|
||||
export interface NodeDiff {
|
||||
nodeId: string;
|
||||
v1: number;
|
||||
v2: number;
|
||||
changes: {
|
||||
field: string;
|
||||
old: any;
|
||||
new: any;
|
||||
}[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user