5 Commits

Author SHA1 Message Date
9490cd1db4 Merge feature/context-injection into main 2026-02-03 10:07:32 +01:00
1cad7d6cb9 Merge feature/auto-capture into main 2026-02-03 10:06:48 +01:00
53ac83756f Add temporal versioning core and summary generation
- Add node_versions table with migrations for tracking history
- Add version tracking to addNode/updateNode in store
- Add getNodeHistory, getNodeAtTime, diffVersions, restoreVersion
- Add NodeVersion, HistoricalNode, NodeDiff types
- Add summary generation with caching for memory_summary
2026-02-03 10:05:26 +01:00
516b5ec017 Add context injection system (Milestone 3)
- Add src/core/context.ts with context gathering, ranking, and formatting
- Add src/core/config.ts for persistent configuration storage
- Add CLI commands: cortex context, cortex context-hook, cortex config
- Add memory_context MCP tool for Claude Code integration
- Add GET /api/context endpoint
- Include summary.ts from main branch for heartbeat dependency
2026-02-03 10:02:28 +01:00
761c7a247c Add temporal versioning for node history tracking (Milestone 1)
Enable time-travel queries and history viewing by creating immutable
version records on every node update. Includes database schema changes,
store functions, MCP tools, and CLI commands for viewing history,
comparing versions, and restoring to previous states.
2026-02-03 09:58:16 +01:00
14 changed files with 1325 additions and 48 deletions

View File

@@ -0,0 +1,81 @@
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`));
});

View File

@@ -0,0 +1,85 @@
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
View 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`));
});

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

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

View File

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

View File

@@ -11,7 +11,11 @@ import { graphCommand } from './commands/graph';
import { serveCommand } from './commands/serve';
import { decayCommand } from './commands/decay';
import { childrenCommand } from './commands/children';
import { historyCommand } from './commands/history';
import { diffCommand } from './commands/diff';
import { restoreCommand } from './commands/restore';
import { captureCommand, captureHookCommand, configCommand } from './commands/capture';
import { contextCommand, contextHookCommand } from './commands/context';
import { closeDb } from '../core/db';
const program = new Command();
@@ -32,8 +36,13 @@ program.addCommand(graphCommand);
program.addCommand(serveCommand);
program.addCommand(decayCommand);
program.addCommand(childrenCommand);
program.addCommand(historyCommand);
program.addCommand(diffCommand);
program.addCommand(restoreCommand);
program.addCommand(captureCommand);
program.addCommand(captureHookCommand);
program.addCommand(contextCommand);
program.addCommand(contextHookCommand);
program.addCommand(configCommand);
program.hook('postAction', () => {

119
src/core/config.ts Normal file
View File

@@ -0,0 +1,119 @@
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'),
};
}

291
src/core/context.ts Normal file
View File

@@ -0,0 +1,291 @@
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,
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,6 +367,37 @@ 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';
@@ -463,6 +494,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);

View File

@@ -4,6 +4,8 @@ import { getConnections, buildTree } from '../core/graph';
import { getDb } from '../core/db';
import { determineGroupingStrategy, groupResults } from './queryOrganizer';
import { getLastReport, markDirty, runMaintenance } from './heartbeat';
import { getCachedSummary, generateSummary } from '../core/summary';
import { gatherContext } from '../core/context';
const router = Router();
@@ -149,6 +151,20 @@ router.post('/query/organize', async (req: Request, res: Response) => {
}
});
// Summary — hierarchical pre-computed graph summary
router.get('/summary', async (req: Request, res: Response) => {
try {
const refresh = req.query.refresh === 'true';
let summary = refresh ? null : getCachedSummary();
if (!summary) {
summary = await generateSummary();
}
res.json(summary);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Maintenance status
router.get('/maintenance/status', (_req: Request, res: Response) => {
const report = getLastReport();
@@ -166,6 +182,42 @@ 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 {

View File

@@ -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;
}[];
}