Files
cortex/src/core/capture/index.ts
omigamedev 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

193 lines
5.4 KiB
TypeScript

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