Compare commits
1 Commits
feature/co
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a4dc07038 |
176
src/cli/commands/capture.ts
Normal file
176
src/cli/commands/capture.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
68
src/core/capture/config.ts
Normal file
68
src/core/capture/config.ts
Normal 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
113
src/core/capture/dedupe.ts
Normal 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
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 };
|
||||
}
|
||||
160
src/core/capture/summarize.ts
Normal file
160
src/core/capture/summarize.ts
Normal 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;
|
||||
}
|
||||
165
src/core/summary.ts
Normal file
165
src/core/summary.ts
Normal 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(', ') + '.';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user