1 Commits

Author SHA1 Message Date
7a4dc07038 Add auto-capture system (Milestone 2)
- Add capture configuration system with modes: always, manual, decisions, off
- Add Ollama-based conversation summarization and extraction
- Add deduplication via embedding similarity (merge >0.90, link 0.75-0.90)
- Add CLI commands: capture, capture-hook, config
- Add MCP tools: memory_capture, memory_remember, memory_capture_config
- Include summary.ts (previously uncommitted)
2026-02-03 09:59:49 +01:00
15 changed files with 989 additions and 663 deletions

176
src/cli/commands/capture.ts Normal file
View File

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

View File

@@ -1,90 +0,0 @@
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

@@ -1,48 +0,0 @@
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

@@ -1,65 +0,0 @@
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,12 +1,11 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { findNodeByPrefix, getNodeAtTime, getCurrentVersion } from '../../core/store';
import { findNodeByPrefix } 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);
@@ -15,56 +14,15 @@ 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, version, connections: conns }, null, 2));
console.log(JSON.stringify({ ...node, embedding: undefined, 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,9 +11,7 @@ import { graphCommand } from './commands/graph';
import { serveCommand } from './commands/serve';
import { decayCommand } from './commands/decay';
import { childrenCommand } from './commands/children';
import { historyCommand } from './commands/history';
import { diffCommand } from './commands/diff';
import { restoreCommand } from './commands/restore';
import { captureCommand, captureHookCommand, configCommand } from './commands/capture';
import { closeDb } from '../core/db';
const program = new Command();
@@ -34,9 +32,9 @@ program.addCommand(graphCommand);
program.addCommand(serveCommand);
program.addCommand(decayCommand);
program.addCommand(childrenCommand);
program.addCommand(historyCommand);
program.addCommand(diffCommand);
program.addCommand(restoreCommand);
program.addCommand(captureCommand);
program.addCommand(captureHookCommand);
program.addCommand(configCommand);
program.hook('postAction', () => {
closeDb();

View File

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

113
src/core/capture/dedupe.ts Normal file
View File

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

192
src/core/capture/index.ts Normal file
View File

@@ -0,0 +1,192 @@
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

@@ -0,0 +1,160 @@
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,21 +32,6 @@ 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);
@@ -55,9 +40,6 @@ 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;
@@ -86,41 +68,6 @@ 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, NodeVersion, HistoricalNode, NodeDiff } from '../types';
import { Node, Edge, AddNodeInput, UpdateNodeInput, ListOptions, QueryOptions, SearchResult, EdgeType } from '../types';
import { hybridSearch, deserializeEmbedding } from './search/index';
import { getEmbedding } from './search/ollama';
@@ -43,44 +43,21 @@ export async function addNode(input: AddNodeInput): Promise<Node> {
// Try to get embedding
const embedding = await getEmbedding(`${input.title} ${content}`);
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
);
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
);
// 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();
// 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);
}
notifyDirty();
return {
@@ -137,88 +114,46 @@ export function listNodes(options: ListOptions = {}): Node[] {
return nodes;
}
export async function updateNode(id: string, input: UpdateNodeInput, createdBy: string = 'user'): Promise<Node | null> {
export async function updateNode(id: string, input: UpdateNodeInput): Promise<Node | null> {
const db = getDb();
// 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 existing = getNode(id);
if (!existing) return null;
const now = Date.now();
const sets: string[] = ['updated_at = ?'];
const params: any[] = [now];
// Get current version number
const currentVersion = existingRow.version ?? 1;
const newVersion = currentVersion + 1;
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));
}
// 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)
// Re-embed if title or content changed
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) {
db.prepare('UPDATE nodes SET embedding = ? WHERE id = ?').run(serializeEmbedding(embedding), id);
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);
}
}
@@ -260,114 +195,3 @@ 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;
}

165
src/core/summary.ts Normal file
View File

@@ -0,0 +1,165 @@
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, getNodeHistory, getNodeAtTime, getNodeVersion, diffVersions, restoreVersion } from '../core/store';
import { query, listNodes, getNode, findNodeByPrefix, addNode, addEdge, removeNode, removeEdge, updateNode } from '../core/store';
import { getConnections, getEdgesByNode } from '../core/graph';
import { cosineSimilarity } from '../core/search/vector';
import { getDb } from '../core/db';
@@ -385,6 +385,69 @@ 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';
@@ -400,102 +463,6 @@ 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,42 +71,3 @@ 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;
}[];
}