1 Commits

Author SHA1 Message Date
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
15 changed files with 663 additions and 989 deletions

View File

@@ -1,176 +0,0 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { captureConversation, captureText, getCaptureConfig, setCaptureConfig, CaptureMode } from '../../core/capture';
const VALID_MODES: CaptureMode[] = ['always', 'manual', 'decisions', 'off'];
export const captureCommand = new Command('capture')
.description('Capture text or conversation as a memory node')
.argument('[text]', 'Text to capture (or pipe via stdin)')
.option('--tags <tags>', 'Comma-separated tags')
.option('--source <source>', 'Source identifier', 'manual')
.action(async (text: string | undefined, opts) => {
let content = text;
// Read from stdin if no text provided
if (!content) {
content = await readStdin();
}
if (!content || content.trim().length === 0) {
console.error(chalk.red('No text provided. Pass text as argument or pipe via stdin.'));
process.exit(1);
}
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : [];
const result = await captureText(content, { tags, source: opts.source });
if (!result.captured) {
console.log(chalk.yellow(`⚠ Not captured: ${result.reason}`));
return;
}
console.log(chalk.green(`✓ Memory ${result.action}`));
if (result.node) {
console.log(` ID: ${chalk.cyan(result.node.id)}`);
console.log(` Title: ${result.node.title}`);
if (result.node.tags.length) console.log(` Tags: ${result.node.tags.join(', ')}`);
}
if (result.reason) {
console.log(` ${chalk.dim(result.reason)}`);
}
});
export const captureHookCommand = new Command('capture-hook')
.description('Hook handler for Claude Code auto-capture (receives JSON on stdin)')
.option('--session <id>', 'Session ID')
.action(async (opts) => {
const input = await readStdin();
if (!input) {
// Silent exit - hook may be called with empty input
process.exit(0);
}
let data: { conversation?: string; files_changed?: string[]; session_id?: string };
try {
data = JSON.parse(input);
} catch {
// Not JSON, treat as plain conversation text
data = { conversation: input };
}
const conversation = data.conversation;
if (!conversation) {
process.exit(0);
}
const result = await captureConversation({
conversation,
sessionId: data.session_id || opts.session,
filesChanged: data.files_changed,
source: 'claude-code',
});
// Output result as JSON for hook system
console.log(JSON.stringify({
captured: result.captured,
action: result.action,
nodeId: result.node?.id,
reason: result.reason,
}));
});
export const configCommand = new Command('config')
.description('Manage capture configuration')
.argument('<action>', 'Action: get, set, or list')
.argument('[key]', 'Config key (for get/set)')
.argument('[value]', 'Config value (for set)')
.action(async (action: string, key?: string, value?: string) => {
const config = getCaptureConfig();
if (action === 'list') {
console.log(chalk.bold('Capture Configuration:'));
console.log(` mode: ${chalk.cyan(config.mode)}`);
console.log(` minLength: ${config.minLength}`);
console.log(` excludePatterns: ${config.excludePatterns.length ? config.excludePatterns.join(', ') : chalk.dim('(none)')}`);
console.log(` autoTag: ${config.autoTag}`);
console.log(` linkRelated: ${config.linkRelated}`);
console.log(` similarityThreshold: ${config.similarityThreshold}`);
console.log(` mergeThreshold: ${config.mergeThreshold}`);
return;
}
if (action === 'get') {
if (!key) {
console.error(chalk.red('Key required for get'));
process.exit(1);
}
if (!(key in config)) {
console.error(chalk.red(`Unknown config key: ${key}`));
process.exit(1);
}
const val = config[key as keyof typeof config];
console.log(Array.isArray(val) ? val.join(', ') : String(val));
return;
}
if (action === 'set') {
if (!key || value === undefined) {
console.error(chalk.red('Key and value required for set'));
process.exit(1);
}
let parsedValue: any = value;
// Parse value based on key type
if (key === 'mode') {
if (!VALID_MODES.includes(value as CaptureMode)) {
console.error(chalk.red(`Invalid mode. Must be one of: ${VALID_MODES.join(', ')}`));
process.exit(1);
}
parsedValue = value;
} else if (key === 'minLength' || key === 'similarityThreshold' || key === 'mergeThreshold') {
parsedValue = parseFloat(value);
if (isNaN(parsedValue)) {
console.error(chalk.red('Value must be a number'));
process.exit(1);
}
} else if (key === 'autoTag' || key === 'linkRelated') {
parsedValue = value === 'true' || value === '1';
} else if (key === 'excludePatterns') {
parsedValue = value.split(',').map(s => s.trim()).filter(Boolean);
} else {
console.error(chalk.red(`Unknown config key: ${key}`));
process.exit(1);
}
setCaptureConfig({ [key]: parsedValue });
console.log(chalk.green(`✓ Set ${key} = ${JSON.stringify(parsedValue)}`));
return;
}
console.error(chalk.red('Invalid action. Use: get, set, or list'));
process.exit(1);
});
async function readStdin(): Promise<string> {
return new Promise((resolve) => {
// Check if stdin has data (non-TTY mode)
if (process.stdin.isTTY) {
resolve('');
return;
}
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => { resolve(data.trim()); });
// Timeout after 100ms if no data
setTimeout(() => {
if (!data) resolve('');
}, 100);
});
}

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,9 @@ import { graphCommand } from './commands/graph';
import { serveCommand } from './commands/serve';
import { decayCommand } from './commands/decay';
import { childrenCommand } from './commands/children';
import { captureCommand, captureHookCommand, configCommand } from './commands/capture';
import { historyCommand } from './commands/history';
import { diffCommand } from './commands/diff';
import { restoreCommand } from './commands/restore';
import { closeDb } from '../core/db';
const program = new Command();
@@ -32,9 +34,9 @@ program.addCommand(graphCommand);
program.addCommand(serveCommand);
program.addCommand(decayCommand);
program.addCommand(childrenCommand);
program.addCommand(captureCommand);
program.addCommand(captureHookCommand);
program.addCommand(configCommand);
program.addCommand(historyCommand);
program.addCommand(diffCommand);
program.addCommand(restoreCommand);
program.hook('postAction', () => {
closeDb();

View File

@@ -1,68 +0,0 @@
import { getDb } from '../db';
export type CaptureMode = 'always' | 'manual' | 'decisions' | 'off';
export interface CaptureConfig {
mode: CaptureMode;
minLength: number;
excludePatterns: string[];
autoTag: boolean;
linkRelated: boolean;
similarityThreshold: number;
mergeThreshold: number;
}
const DEFAULT_CONFIG: CaptureConfig = {
mode: 'always',
minLength: 100,
excludePatterns: [],
autoTag: true,
linkRelated: true,
similarityThreshold: 0.75,
mergeThreshold: 0.90,
};
function ensureConfigTable(): void {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
export function getCaptureConfig(): CaptureConfig {
ensureConfigTable();
const db = getDb();
const row = db.prepare('SELECT value FROM system_config WHERE key = ?').get('capture') as { value: string } | undefined;
if (!row) return DEFAULT_CONFIG;
try {
return { ...DEFAULT_CONFIG, ...JSON.parse(row.value) };
} catch {
return DEFAULT_CONFIG;
}
}
export function setCaptureConfig(updates: Partial<CaptureConfig>): CaptureConfig {
ensureConfigTable();
const db = getDb();
const current = getCaptureConfig();
const updated = { ...current, ...updates };
db.prepare(`
INSERT INTO system_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run('capture', JSON.stringify(updated), Date.now());
return updated;
}
export function getConfigValue<K extends keyof CaptureConfig>(key: K): CaptureConfig[K] {
return getCaptureConfig()[key];
}
export function setConfigValue<K extends keyof CaptureConfig>(key: K, value: CaptureConfig[K]): void {
setCaptureConfig({ [key]: value } as Partial<CaptureConfig>);
}

View File

@@ -1,113 +0,0 @@
import { listNodes, addEdge, updateNode } from '../store';
import { getEmbedding } from '../search/ollama';
import { cosineSimilarity } from '../search/vector';
import { Node } from '../../types';
import { getCaptureConfig } from './config';
export interface SimilarNode {
node: Node;
similarity: number;
}
export interface DedupeResult {
action: 'create' | 'merge' | 'link';
existingNode?: Node;
similarity?: number;
}
export async function findSimilarNodes(
text: string,
limit: number = 5
): Promise<SimilarNode[]> {
const embedding = await getEmbedding(text);
if (!embedding) return [];
const nodes = listNodes({ includeStale: false });
const withEmbeddings = nodes.filter(n => n.embedding && n.embedding.length > 0);
const scored: SimilarNode[] = [];
for (const node of withEmbeddings) {
const similarity = cosineSimilarity(embedding, node.embedding!);
if (similarity > 0.5) {
scored.push({ node, similarity });
}
}
return scored
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
}
export async function checkDuplicate(
summary: string,
content: string
): Promise<DedupeResult> {
const config = getCaptureConfig();
const textToCompare = `${summary} ${content}`;
const similar = await findSimilarNodes(textToCompare, 1);
if (similar.length === 0) {
return { action: 'create' };
}
const { node, similarity } = similar[0];
if (similarity >= config.mergeThreshold) {
return {
action: 'merge',
existingNode: node,
similarity,
};
}
if (similarity >= config.similarityThreshold) {
return {
action: 'link',
existingNode: node,
similarity,
};
}
return { action: 'create' };
}
export async function mergeIntoNode(
existingId: string,
newSummary: string,
newContent: string,
newTags: string[]
): Promise<Node | null> {
const existing = listNodes({ includeStale: false }).find(n => n.id === existingId);
if (!existing) return null;
// Append new content with timestamp
const timestamp = new Date().toISOString().slice(0, 10);
const mergedContent = existing.content
? `${existing.content}\n\n---\n[${timestamp}]\n${newContent}`
: newContent;
// Merge tags (dedupe)
const mergedTags = [...new Set([...existing.tags, ...newTags])];
// Update the existing node
return updateNode(existingId, {
content: mergedContent,
tags: mergedTags,
metadata: {
...existing.metadata,
lastMergedAt: Date.now(),
mergeCount: (existing.metadata.mergeCount || 0) + 1,
},
});
}
export async function linkRelatedNode(
newNodeId: string,
existingNodeId: string
): Promise<void> {
addEdge(newNodeId, existingNodeId, 'relates_to', {
reason: 'auto-capture-similarity',
linkedAt: Date.now(),
});
}

View File

@@ -1,192 +0,0 @@
import { addNode } from '../store';
import { getCaptureConfig, CaptureConfig } from './config';
import { extractMemoryData, shouldCapture, ExtractedMemory } from './summarize';
import { checkDuplicate, mergeIntoNode, linkRelatedNode } from './dedupe';
import { Node } from '../../types';
export { getCaptureConfig, setCaptureConfig, CaptureMode, CaptureConfig } from './config';
export { extractMemoryData, shouldCapture, ExtractedMemory } from './summarize';
export { findSimilarNodes, checkDuplicate, mergeIntoNode } from './dedupe';
export interface CaptureInput {
conversation: string;
sessionId?: string;
filesChanged?: string[];
source?: string;
}
export interface CaptureResult {
captured: boolean;
action: 'created' | 'merged' | 'linked' | 'skipped';
node?: Node;
reason?: string;
}
export async function captureConversation(input: CaptureInput): Promise<CaptureResult> {
const config = getCaptureConfig();
// Check if capture is enabled
if (config.mode === 'off') {
return { captured: false, action: 'skipped', reason: 'capture disabled' };
}
// Check minimum length
if (!shouldCapture(input.conversation, config.minLength)) {
return { captured: false, action: 'skipped', reason: 'conversation too short or trivial' };
}
// Check exclude patterns
for (const pattern of config.excludePatterns) {
try {
if (new RegExp(pattern, 'i').test(input.conversation)) {
return { captured: false, action: 'skipped', reason: `matched exclude pattern: ${pattern}` };
}
} catch {
// Invalid regex, skip
}
}
// Extract memory data using Ollama
const extracted = await extractMemoryData(input.conversation);
if (!extracted) {
return { captured: false, action: 'skipped', reason: 'failed to extract memory data' };
}
// For "decisions" mode, only capture if decisions were found
if (config.mode === 'decisions' && extracted.decisions.length === 0) {
return { captured: false, action: 'skipped', reason: 'no decisions found (decisions mode)' };
}
// Build content
const contentParts: string[] = [extracted.summary];
if (extracted.decisions.length > 0) {
contentParts.push('\n## Decisions');
for (const d of extracted.decisions) {
contentParts.push(`- ${d}`);
}
}
if (extracted.filesDiscussed.length > 0 || input.filesChanged?.length) {
const files = [...new Set([...extracted.filesDiscussed, ...(input.filesChanged || [])])];
contentParts.push('\n## Files');
for (const f of files) {
contentParts.push(`- ${f}`);
}
}
const content = contentParts.join('\n');
// Check for duplicates
const dedupeResult = await checkDuplicate(extracted.summary, content);
// Build tags
const tags = ['auto-capture'];
if (config.autoTag && extracted.topics.length > 0) {
tags.push(...extracted.topics);
}
if (input.source) {
tags.push(`source:${input.source}`);
}
if (dedupeResult.action === 'merge' && dedupeResult.existingNode) {
// Merge into existing node
const merged = await mergeIntoNode(
dedupeResult.existingNode.id,
extracted.summary,
content,
tags
);
return {
captured: true,
action: 'merged',
node: merged || undefined,
reason: `merged with existing node (similarity: ${(dedupeResult.similarity! * 100).toFixed(1)}%)`,
};
}
// Create new node
const node = await addNode({
kind: 'memory',
title: extracted.summary.slice(0, 100),
content,
tags,
status: 'active',
metadata: {
sessionId: input.sessionId,
filesChanged: input.filesChanged,
source: input.source || 'claude-code',
capturedAt: Date.now(),
decisions: extracted.decisions,
},
});
// Link to related node if found
if (dedupeResult.action === 'link' && dedupeResult.existingNode && config.linkRelated) {
await linkRelatedNode(node.id, dedupeResult.existingNode.id);
return {
captured: true,
action: 'linked',
node,
reason: `linked to related node (similarity: ${(dedupeResult.similarity! * 100).toFixed(1)}%)`,
};
}
return {
captured: true,
action: 'created',
node,
};
}
export async function captureText(
text: string,
options: { tags?: string[]; source?: string } = {}
): Promise<CaptureResult> {
const config = getCaptureConfig();
if (config.mode === 'off') {
return { captured: false, action: 'skipped', reason: 'capture disabled' };
}
// Simple text capture - no summarization needed
const dedupeResult = await checkDuplicate(text, text);
const tags = ['manual-capture', ...(options.tags || [])];
if (options.source) {
tags.push(`source:${options.source}`);
}
if (dedupeResult.action === 'merge' && dedupeResult.existingNode) {
const merged = await mergeIntoNode(
dedupeResult.existingNode.id,
text.slice(0, 100),
text,
tags
);
return {
captured: true,
action: 'merged',
node: merged || undefined,
};
}
const node = await addNode({
kind: 'memory',
title: text.slice(0, 100),
content: text,
tags,
status: 'active',
metadata: {
source: options.source || 'manual',
capturedAt: Date.now(),
},
});
if (dedupeResult.action === 'link' && dedupeResult.existingNode && config.linkRelated) {
await linkRelatedNode(node.id, dedupeResult.existingNode.id);
return { captured: true, action: 'linked', node };
}
return { captured: true, action: 'created', node };
}

View File

@@ -1,160 +0,0 @@
import { generate, isGenAvailable } from '../search/ollamaGen';
export interface ExtractedMemory {
summary: string;
topics: string[];
decisions: string[];
filesDiscussed: string[];
}
const SUMMARIZE_PROMPT = `Summarize this Claude Code conversation in 1-2 sentences.
Focus on: what was accomplished, decisions made, problems solved.
Do NOT include greetings or meta-discussion.
Conversation:
{conversation}
Summary:`;
const EXTRACT_PROMPT = `Extract from this conversation:
1. Main topics (as tags, lowercase, hyphenated, max 5)
2. Decisions made (if any, max 3)
3. Code files discussed or modified (if any)
Conversation:
{conversation}
Output as JSON only, no explanation:
{"topics": [], "decisions": [], "files": []}`;
export async function summarizeConversation(conversation: string): Promise<string | null> {
if (!(await isGenAvailable())) return null;
const prompt = SUMMARIZE_PROMPT.replace('{conversation}', conversation);
return generate(prompt);
}
export async function extractMemoryData(conversation: string): Promise<ExtractedMemory | null> {
const available = await isGenAvailable();
// Get summary
const summary = available
? await summarizeConversation(conversation)
: createFallbackSummary(conversation);
if (!summary) return null;
// Extract structured data
let topics: string[] = [];
let decisions: string[] = [];
let filesDiscussed: string[] = [];
if (available) {
const extractPrompt = EXTRACT_PROMPT.replace('{conversation}', conversation);
const extracted = await generate(extractPrompt);
if (extracted) {
try {
// Find JSON in response (handle cases where model adds explanation)
const jsonMatch = extracted.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const data = JSON.parse(jsonMatch[0]);
topics = Array.isArray(data.topics) ? data.topics.slice(0, 5) : [];
decisions = Array.isArray(data.decisions) ? data.decisions.slice(0, 3) : [];
filesDiscussed = Array.isArray(data.files) ? data.files : [];
}
} catch {
// Fall back to basic extraction
topics = extractTopicsBasic(conversation);
filesDiscussed = extractFilesBasic(conversation);
}
}
} else {
// Basic extraction without AI
topics = extractTopicsBasic(conversation);
filesDiscussed = extractFilesBasic(conversation);
}
return {
summary,
topics: sanitizeTags(topics),
decisions,
filesDiscussed,
};
}
function createFallbackSummary(conversation: string): string {
// Take first meaningful line as summary
const lines = conversation.split('\n').filter(l => l.trim().length > 20);
if (lines.length === 0) return 'Conversation captured';
const first = lines[0].trim();
return first.length > 150 ? first.slice(0, 147) + '...' : first;
}
function extractTopicsBasic(conversation: string): string[] {
const topics: string[] = [];
const lower = conversation.toLowerCase();
// Common programming topics
const keywords = [
'typescript', 'javascript', 'python', 'rust', 'go',
'react', 'vue', 'angular', 'node', 'express',
'database', 'sql', 'api', 'auth', 'authentication',
'bug', 'fix', 'error', 'refactor', 'test', 'deploy',
'git', 'docker', 'kubernetes', 'aws', 'cloud',
];
for (const kw of keywords) {
if (lower.includes(kw) && topics.length < 5) {
topics.push(kw);
}
}
return topics;
}
function extractFilesBasic(conversation: string): string[] {
const files: string[] = [];
// Match file paths
const filePatterns = [
/[\w\-\/]+\.(ts|js|tsx|jsx|py|rs|go|md|json|yaml|yml|toml|sql)/gi,
/src\/[\w\-\/]+/gi,
];
for (const pattern of filePatterns) {
const matches = conversation.match(pattern);
if (matches) {
for (const m of matches) {
if (!files.includes(m) && files.length < 10) {
files.push(m);
}
}
}
}
return files;
}
function sanitizeTags(tags: string[]): string[] {
return tags
.map(t => t.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, ''))
.filter(t => t.length > 0 && t.length < 30);
}
export function shouldCapture(conversation: string, minLength: number): boolean {
// Skip very short conversations
if (conversation.length < minLength) return false;
// Skip if mostly greetings/pleasantries
const lower = conversation.toLowerCase();
const greetings = ['hello', 'hi ', 'hey', 'thanks', 'thank you', 'goodbye', 'bye'];
const greetingCount = greetings.filter(g => lower.includes(g)).length;
// If more than half the "content" is greetings, skip
const words = conversation.split(/\s+/).length;
if (words < 20 && greetingCount > 2) return false;
return true;
}

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,165 +0,0 @@
import { getDb } from './db';
import { isGenAvailable, generate } from './search/ollamaGen';
export interface SummaryData {
generatedAt: number;
overview: string;
components: { title: string; status?: string; summary?: string }[];
decisions: { title: string; status?: string }[];
tasks: { todo: string[]; in_progress: string[]; done: string[] };
memories: { byTag: Record<string, string[]> };
stats: { total: number; stale: number; orphans: number; edges: number };
}
// Ensure cache table exists
function ensureCacheTable(): void {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS system_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
export function getCachedSummary(): SummaryData | null {
ensureCacheTable();
const db = getDb();
const row = db.prepare('SELECT value FROM system_cache WHERE key = ?').get('summary') as { value: string } | undefined;
if (!row) return null;
try {
return JSON.parse(row.value);
} catch {
return null;
}
}
export function cacheSummary(data: SummaryData): void {
ensureCacheTable();
const db = getDb();
db.prepare(`
INSERT INTO system_cache (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run('summary', JSON.stringify(data), Date.now());
}
export async function generateSummary(): Promise<SummaryData> {
const db = getDb();
const now = Date.now();
// Gather all active nodes
const rows = db.prepare(`
SELECT id, kind, title, status, tags, metadata, content
FROM nodes WHERE is_stale = 0
ORDER BY kind, title
`).all() as any[];
// Stats
const totalNodes = rows.length;
const staleCount = (db.prepare('SELECT COUNT(*) as c FROM nodes WHERE is_stale = 1').get() as any).c;
const edgeCount = (db.prepare('SELECT COUNT(*) as c FROM edges').get() as any).c;
const edgeRows = db.prepare('SELECT from_id, to_id FROM edges').all() as any[];
const hasEdge = new Set<string>();
for (const e of edgeRows) {
hasEdge.add(e.from_id);
hasEdge.add(e.to_id);
}
const orphanCount = rows.filter(r => !hasEdge.has(r.id)).length;
// Categorize nodes
const components: SummaryData['components'] = [];
const decisions: SummaryData['decisions'] = [];
const tasks: SummaryData['tasks'] = { todo: [], in_progress: [], done: [] };
const memoryTags: Record<string, string[]> = {};
for (const row of rows) {
const meta = JSON.parse(row.metadata || '{}');
if (row.kind === 'component') {
components.push({
title: row.title,
status: row.status,
summary: meta.summary || (row.content?.slice(0, 100) + (row.content?.length > 100 ? '...' : '')),
});
} else if (row.kind === 'decision') {
decisions.push({ title: row.title, status: row.status });
} else if (row.kind === 'task') {
const status = (row.status || 'todo').toLowerCase();
if (status === 'done' || status === 'completed') {
tasks.done.push(row.title);
} else if (status === 'in_progress' || status === 'in-progress') {
tasks.in_progress.push(row.title);
} else {
tasks.todo.push(row.title);
}
} else if (row.kind === 'memory') {
const tags: string[] = JSON.parse(row.tags || '[]');
// Group by first tag, or 'untagged'
const primaryTag = tags[0] || 'untagged';
if (!memoryTags[primaryTag]) memoryTags[primaryTag] = [];
memoryTags[primaryTag].push(row.title);
}
}
// Sort tags by count descending, limit to top 10
const sortedTags = Object.entries(memoryTags)
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 10);
const limitedMemoryTags: Record<string, string[]> = {};
for (const [tag, titles] of sortedTags) {
// Limit to 5 titles per tag
limitedMemoryTags[tag] = titles.slice(0, 5);
if (titles.length > 5) {
limitedMemoryTags[tag].push(`+${titles.length - 5} more`);
}
}
// Generate overview
let overview: string;
const aiAvailable = await isGenAvailable();
if (aiAvailable) {
const prompt = `Write a single sentence (max 30 words) summarizing this knowledge graph for a developer starting a coding session.
Stats: ${totalNodes} nodes (${components.length} components, ${decisions.length} decisions, ${tasks.todo.length + tasks.in_progress.length + tasks.done.length} tasks, ${totalNodes - components.length - decisions.length - tasks.todo.length - tasks.in_progress.length - tasks.done.length} memories), ${edgeCount} edges.
Components: ${components.map(c => c.title).join(', ')}
Active tasks: ${[...tasks.todo, ...tasks.in_progress].join(', ') || 'none'}
Output ONLY the summary sentence.`;
const aiOverview = await generate(prompt);
overview = aiOverview || buildFallbackOverview(totalNodes, components.length, decisions.length, tasks, edgeCount);
} else {
overview = buildFallbackOverview(totalNodes, components.length, decisions.length, tasks, edgeCount);
}
const summary: SummaryData = {
generatedAt: now,
overview,
components,
decisions,
tasks,
memories: { byTag: limitedMemoryTags },
stats: { total: totalNodes, stale: staleCount, orphans: orphanCount, edges: edgeCount },
};
cacheSummary(summary);
return summary;
}
function buildFallbackOverview(
total: number,
components: number,
decisions: number,
tasks: SummaryData['tasks'],
edges: number
): string {
const pending = tasks.todo.length + tasks.in_progress.length;
const parts = [`${total} nodes`, `${components} components`, `${decisions} decisions`];
if (pending > 0) parts.push(`${pending} open tasks`);
parts.push(`${edges} edges`);
return parts.join(', ') + '.';
}

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';
@@ -385,69 +385,6 @@ server.tool(
}
);
// --- memory_capture ---
import { captureConversation, captureText, getCaptureConfig, setCaptureConfig, CaptureMode } from '../core/capture';
server.tool(
'memory_capture',
'Capture a conversation or context as a memory node. Uses AI to summarize and extract key information.',
{
conversation: z.string().describe('The conversation or context to capture'),
sessionId: z.string().optional().describe('Session identifier'),
filesChanged: z.array(z.string()).optional().describe('List of files that were changed'),
source: z.string().optional().describe('Source identifier (default: claude-code)'),
},
async ({ conversation, sessionId, filesChanged, source }) => {
const result = await captureConversation({
conversation,
sessionId,
filesChanged,
source: source || 'claude-code',
});
return { content: [{ type: 'text' as const, text: serialize(result) }] };
}
);
server.tool(
'memory_remember',
'Remember a piece of text for later. Simpler than memory_capture - for quick notes and facts.',
{
text: z.string().describe('The text to remember'),
tags: z.array(z.string()).optional().describe('Tags to apply'),
},
async ({ text, tags }) => {
const result = await captureText(text, { tags, source: 'remember' });
return { content: [{ type: 'text' as const, text: serialize(result) }] };
}
);
server.tool(
'memory_capture_config',
'Get or set auto-capture configuration',
{
action: z.enum(['get', 'set']).describe('Action to perform'),
mode: z.enum(['always', 'manual', 'decisions', 'off']).optional().describe('Capture mode (for set)'),
minLength: z.number().optional().describe('Minimum conversation length (for set)'),
autoTag: z.boolean().optional().describe('Auto-generate tags (for set)'),
linkRelated: z.boolean().optional().describe('Auto-link related nodes (for set)'),
},
async ({ action, mode, minLength, autoTag, linkRelated }) => {
if (action === 'get') {
const config = getCaptureConfig();
return { content: [{ type: 'text' as const, text: serialize(config) }] };
}
const updates: Partial<{ mode: CaptureMode; minLength: number; autoTag: boolean; linkRelated: boolean }> = {};
if (mode !== undefined) updates.mode = mode;
if (minLength !== undefined) updates.minLength = minLength;
if (autoTag !== undefined) updates.autoTag = autoTag;
if (linkRelated !== undefined) updates.linkRelated = linkRelated;
const config = setCaptureConfig(updates);
return { content: [{ type: 'text' as const, text: serialize({ updated: true, config }) }] };
}
);
// --- memory_prompt ---
import { interpretAndExecute } from '../core/prompt/interpreter';
@@ -463,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);

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