- 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)
193 lines
5.4 KiB
TypeScript
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 };
|
|
}
|