diff --git a/src/cli/commands/diff.ts b/src/cli/commands/diff.ts new file mode 100644 index 0000000..2c1e0c2 --- /dev/null +++ b/src/cli/commands/diff.ts @@ -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('', 'Node ID (or prefix)') + .option('--v1 ', 'First version number') + .option('--v2 ', 'Second version number') + .option('--from ', 'Start date (ISO format or timestamp)') + .option('--to ', 'End date (ISO format or timestamp)') + .option('--format ', '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`)); + }); diff --git a/src/cli/commands/history.ts b/src/cli/commands/history.ts new file mode 100644 index 0000000..f61a300 --- /dev/null +++ b/src/cli/commands/history.ts @@ -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('', 'Node ID (or prefix)') + .option('--format ', '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)`)); + }); diff --git a/src/cli/commands/restore.ts b/src/cli/commands/restore.ts new file mode 100644 index 0000000..b03c9c6 --- /dev/null +++ b/src/cli/commands/restore.ts @@ -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('', 'Node ID (or prefix)') + .option('-v, --to-version ', 'Version number to restore') + .option('--format ', '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 or -v 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); + } + }); diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index 6220c8c..8bd3260 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -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('', 'Node ID (or prefix)') .option('--format ', 'Output format: text or json', 'text') + .option('--at ', '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()}`); diff --git a/src/cli/index.ts b/src/cli/index.ts index 0a1f253..0c8ed26 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,9 @@ 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 { closeDb } from '../core/db'; const program = new Command(); @@ -31,6 +34,9 @@ program.addCommand(graphCommand); program.addCommand(serveCommand); program.addCommand(decayCommand); program.addCommand(childrenCommand); +program.addCommand(historyCommand); +program.addCommand(diffCommand); +program.addCommand(restoreCommand); program.hook('postAction', () => { closeDb(); diff --git a/src/core/db.ts b/src/core/db.ts index c0e2d49..9b3cf33 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -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; } diff --git a/src/core/store.ts b/src/core/store.ts index 156a4b9..8e589d7 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -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 { // 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 { +export async function updateNode(id: string, input: UpdateNodeInput, createdBy: string = 'user'): Promise { 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 ?) + 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 { + 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; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index b499fe0..583b926 100644 --- a/src/mcp/index.ts +++ b/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'; @@ -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() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/types.ts b/src/types.ts index 0ae3317..8ce70db 100644 --- a/src/types.ts +++ b/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; + validFrom: number; + validUntil: number | null; + createdBy: string; +} + +export interface HistoricalNode { + id: string; + kind: NodeKind; + title: string; + content: string; + status?: string; + tags: string[]; + metadata: Record; + version: number; + validFrom: number; + validUntil: number | null; +} + +export interface NodeDiff { + nodeId: string; + v1: number; + v2: number; + changes: { + field: string; + old: any; + new: any; + }[]; +}