Compare commits
1 Commits
feature/au
...
761c7a247c
| Author | SHA1 | Date | |
|---|---|---|---|
| 761c7a247c |
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 { Command } from 'commander';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { findNodeByPrefix } from '../../core/store';
|
import { findNodeByPrefix, getNodeAtTime, getCurrentVersion } from '../../core/store';
|
||||||
import { getConnections } from '../../core/graph';
|
import { getConnections } from '../../core/graph';
|
||||||
|
|
||||||
export const showCommand = new Command('show')
|
export const showCommand = new Command('show')
|
||||||
.argument('<id>', 'Node ID (or prefix)')
|
.argument('<id>', 'Node ID (or prefix)')
|
||||||
.option('--format <fmt>', 'Output format: text or json', 'text')
|
.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')
|
.description('Show a node and its connections')
|
||||||
.action(async (idRaw: string, opts) => {
|
.action(async (idRaw: string, opts) => {
|
||||||
const node = findNodeByPrefix(idRaw);
|
const node = findNodeByPrefix(idRaw);
|
||||||
@@ -14,15 +15,56 @@ export const showCommand = new Command('show')
|
|||||||
process.exit(1);
|
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 conns = getConnections(node.id);
|
||||||
|
const version = getCurrentVersion(node.id);
|
||||||
|
|
||||||
if (opts.format === 'json') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
console.log(chalk.bold.cyan(`[${node.kind}] ${node.title}`));
|
||||||
console.log(`ID: ${node.id}`);
|
console.log(`ID: ${node.id}`);
|
||||||
|
console.log(`Version: v${version}`);
|
||||||
if (node.status) console.log(`Status: ${node.status}`);
|
if (node.status) console.log(`Status: ${node.status}`);
|
||||||
if (node.tags.length) console.log(`Tags: ${node.tags.join(', ')}`);
|
if (node.tags.length) console.log(`Tags: ${node.tags.join(', ')}`);
|
||||||
console.log(`Created: ${new Date(node.createdAt).toLocaleString()}`);
|
console.log(`Created: ${new Date(node.createdAt).toLocaleString()}`);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { graphCommand } from './commands/graph';
|
|||||||
import { serveCommand } from './commands/serve';
|
import { serveCommand } from './commands/serve';
|
||||||
import { decayCommand } from './commands/decay';
|
import { decayCommand } from './commands/decay';
|
||||||
import { childrenCommand } from './commands/children';
|
import { childrenCommand } from './commands/children';
|
||||||
|
import { historyCommand } from './commands/history';
|
||||||
|
import { diffCommand } from './commands/diff';
|
||||||
|
import { restoreCommand } from './commands/restore';
|
||||||
import { closeDb } from '../core/db';
|
import { closeDb } from '../core/db';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -31,6 +34,9 @@ program.addCommand(graphCommand);
|
|||||||
program.addCommand(serveCommand);
|
program.addCommand(serveCommand);
|
||||||
program.addCommand(decayCommand);
|
program.addCommand(decayCommand);
|
||||||
program.addCommand(childrenCommand);
|
program.addCommand(childrenCommand);
|
||||||
|
program.addCommand(historyCommand);
|
||||||
|
program.addCommand(diffCommand);
|
||||||
|
program.addCommand(restoreCommand);
|
||||||
|
|
||||||
program.hook('postAction', () => {
|
program.hook('postAction', () => {
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ CREATE TABLE IF NOT EXISTS node_tags (
|
|||||||
PRIMARY KEY (node_id, tag)
|
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_kind ON nodes(kind);
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
|
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_nodes_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_to ON edges(to_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
|
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_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;
|
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');
|
_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;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID as uuid } from 'crypto';
|
import { randomUUID as uuid } from 'crypto';
|
||||||
import { getDb } from './db';
|
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 { hybridSearch, deserializeEmbedding } from './search/index';
|
||||||
import { getEmbedding } from './search/ollama';
|
import { getEmbedding } from './search/ollama';
|
||||||
|
|
||||||
@@ -43,21 +43,44 @@ export async function addNode(input: AddNodeInput): Promise<Node> {
|
|||||||
// Try to get embedding
|
// Try to get embedding
|
||||||
const embedding = await getEmbedding(`${input.title} ${content}`);
|
const embedding = await getEmbedding(`${input.title} ${content}`);
|
||||||
|
|
||||||
db.prepare(`
|
const transaction = db.transaction(() => {
|
||||||
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at, last_accessed_at)
|
db.prepare(`
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
INSERT INTO nodes (id, kind, title, content, status, tags, metadata, embedding, created_at, updated_at, last_accessed_at, version)
|
||||||
`).run(
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
id, input.kind, input.title, content, input.status ?? null,
|
`).run(
|
||||||
JSON.stringify(tags), JSON.stringify(metadata),
|
id, input.kind, input.title, content, input.status ?? null,
|
||||||
embedding ? serializeEmbedding(embedding) : null,
|
JSON.stringify(tags), JSON.stringify(metadata),
|
||||||
now, now, now
|
embedding ? serializeEmbedding(embedding) : null,
|
||||||
);
|
now, now, now, 1
|
||||||
|
);
|
||||||
|
|
||||||
// Insert tags
|
// Insert tags
|
||||||
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
const insertTag = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?, ?)');
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
insertTag.run(id, tag);
|
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();
|
notifyDirty();
|
||||||
return {
|
return {
|
||||||
@@ -114,46 +137,88 @@ export function listNodes(options: ListOptions = {}): Node[] {
|
|||||||
return nodes;
|
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 db = getDb();
|
||||||
const existing = getNode(id);
|
// Get existing node without updating last_accessed_at
|
||||||
if (!existing) return null;
|
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 now = Date.now();
|
||||||
const sets: string[] = ['updated_at = ?'];
|
|
||||||
const params: any[] = [now];
|
|
||||||
|
|
||||||
if (input.title !== undefined) { sets.push('title = ?'); params.push(input.title); }
|
// Get current version number
|
||||||
if (input.content !== undefined) { sets.push('content = ?'); params.push(input.content); }
|
const currentVersion = existingRow.version ?? 1;
|
||||||
if (input.status !== undefined) { sets.push('status = ?'); params.push(input.status); }
|
const newVersion = currentVersion + 1;
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
if (input.title !== undefined || input.content !== undefined) {
|
||||||
const newTitle = input.title ?? existing.title;
|
const newTitle = input.title ?? existing.title;
|
||||||
const newContent = input.content ?? existing.content;
|
const newContent = input.content ?? existing.content;
|
||||||
const embedding = await getEmbedding(`${newTitle} ${newContent}`);
|
const embedding = await getEmbedding(`${newTitle} ${newContent}`);
|
||||||
if (embedding) {
|
if (embedding) {
|
||||||
sets.push('embedding = ?');
|
db.prepare('UPDATE nodes SET embedding = ? WHERE id = ?').run(serializeEmbedding(embedding), id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 });
|
const nodes = listNodes({ kind: options.kind, tags: options.tags, includeStale: options.includeStale });
|
||||||
return hybridSearch(nodes, text, options);
|
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,7 +1,7 @@
|
|||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { z } from 'zod/v3';
|
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 { getConnections, getEdgesByNode } from '../core/graph';
|
||||||
import { cosineSimilarity } from '../core/search/vector';
|
import { cosineSimilarity } from '../core/search/vector';
|
||||||
import { getDb } from '../core/db';
|
import { getDb } from '../core/db';
|
||||||
@@ -400,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() {
|
async function main() {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|||||||
39
src/types.ts
39
src/types.ts
@@ -71,3 +71,42 @@ export interface ListOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
includeStale?: boolean;
|
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