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:
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();
|
||||
|
||||
Reference in New Issue
Block a user