diff --git a/src/cli/commands/capture.ts b/src/cli/commands/capture.ts new file mode 100644 index 0000000..3d72b14 --- /dev/null +++ b/src/cli/commands/capture.ts @@ -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 ', 'Comma-separated tags') + .option('--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 ', '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: 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 { + 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); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 0a1f253..d08af04 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,7 @@ 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 { closeDb } from '../core/db'; const program = new Command(); @@ -31,6 +32,9 @@ program.addCommand(graphCommand); program.addCommand(serveCommand); program.addCommand(decayCommand); program.addCommand(childrenCommand); +program.addCommand(captureCommand); +program.addCommand(captureHookCommand); +program.addCommand(configCommand); program.hook('postAction', () => { closeDb(); diff --git a/src/core/capture/config.ts b/src/core/capture/config.ts new file mode 100644 index 0000000..6b21887 --- /dev/null +++ b/src/core/capture/config.ts @@ -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 { + 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(key: K): CaptureConfig[K] { + return getCaptureConfig()[key]; +} + +export function setConfigValue(key: K, value: CaptureConfig[K]): void { + setCaptureConfig({ [key]: value } as Partial); +} diff --git a/src/core/capture/dedupe.ts b/src/core/capture/dedupe.ts new file mode 100644 index 0000000..6f918e7 --- /dev/null +++ b/src/core/capture/dedupe.ts @@ -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 { + 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 { + 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 { + 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 { + addEdge(newNodeId, existingNodeId, 'relates_to', { + reason: 'auto-capture-similarity', + linkedAt: Date.now(), + }); +} diff --git a/src/core/capture/index.ts b/src/core/capture/index.ts new file mode 100644 index 0000000..8dcc0cc --- /dev/null +++ b/src/core/capture/index.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/core/capture/summarize.ts b/src/core/capture/summarize.ts new file mode 100644 index 0000000..8bd1b26 --- /dev/null +++ b/src/core/capture/summarize.ts @@ -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 { + if (!(await isGenAvailable())) return null; + + const prompt = SUMMARIZE_PROMPT.replace('{conversation}', conversation); + return generate(prompt); +} + +export async function extractMemoryData(conversation: string): Promise { + 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; +} diff --git a/src/core/summary.ts b/src/core/summary.ts new file mode 100644 index 0000000..7e46f3a --- /dev/null +++ b/src/core/summary.ts @@ -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 }; + 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 { + 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(); + 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 = {}; + + 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 = {}; + 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(', ') + '.'; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index b499fe0..7ff1514 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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';