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)
This commit is contained in:
192
src/core/capture/index.ts
Normal file
192
src/core/capture/index.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user