#!/usr/bin/env node
/**
* Claude Code Auto-Capture Hook
*
* Automatically captures valuable conversation content after tool operations.
* Uses pattern detection to identify decisions, errors, learnings, and implementations.
*
* Trigger: PostToolUse (Edit, Write, Bash)
* Input: JSON via stdin with transcript_path and tool info
*
* @module auto-capture-hook
* @version 1.0.0
*/
'use strict';
const fs = require('fs').promises;
const path = require('path');
const http = require('http');
const https = require('https');
// Import pattern detection
const {
detectPatterns,
hasUserOverride,
generateTags,
truncateContent,
computeContentHash,
extractProjectName,
DEFAULT_CONFIG
} = require('../utilities/auto-capture-patterns');
/**
* Load hook configuration
*/
async function loadConfig() {
try {
const configPath = path.join(__dirname, '../config.json');
const configData = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configData);
return {
memoryService: config.memoryService || {
http: {
endpoint: 'http://127.0.0.1:8000',
apiKey: ''
}
},
autoCapture: config.autoCapture || {
enabled: true,
minLength: 300,
maxLength: 4000,
patterns: ['decision', 'error', 'learning', 'implementation', 'important', 'code'],
debugMode: false
}
};
} catch (error) {
console.warn('[auto-capture] Using default configuration:', error.message);
return {
memoryService: {
http: {
endpoint: 'http://127.0.0.1:8000',
apiKey: ''
}
},
autoCapture: {
enabled: true,
minLength: 300,
maxLength: 4000,
patterns: ['decision', 'error', 'learning', 'implementation', 'important', 'code'],
debugMode: false
}
};
}
}
/**
* Read input from stdin
*/
async function readStdin() {
return new Promise((resolve, reject) => {
let data = '';
const timeout = setTimeout(() => {
resolve(data || '{}');
}, 1000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => {
clearTimeout(timeout);
resolve(data);
});
process.stdin.on('error', reject);
// Resume stdin in case it's paused
process.stdin.resume();
});
}
/**
* Parse transcript file to extract last user and assistant messages
*/
async function parseTranscript(transcriptPath) {
try {
const content = await fs.readFile(transcriptPath, 'utf8');
const transcript = JSON.parse(content);
if (!Array.isArray(transcript) || transcript.length === 0) {
return null;
}
// Find last user and assistant messages
let lastUser = null;
let lastAssistant = null;
for (let i = transcript.length - 1; i >= 0; i--) {
const msg = transcript[i];
const role = msg.role || msg.type;
if (!lastAssistant && role === 'assistant') {
lastAssistant = extractTextContent(msg.content);
}
if (!lastUser && role === 'user') {
lastUser = extractTextContent(msg.content);
}
if (lastUser && lastAssistant) break;
}
return {
userMessage: lastUser || '',
assistantMessage: lastAssistant || '',
combined: `User: ${lastUser || '[no message]'}\n\nAssistant: ${lastAssistant || '[no response]'}`
};
} catch (error) {
console.error('[auto-capture] Failed to parse transcript:', error.message);
return null;
}
}
/**
* Extract text content from various message formats
*/
function extractTextContent(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.filter(item => item.type === 'text')
.map(item => item.text)
.join('\n');
}
return '';
}
/**
* Store memory via HTTP API
*/
async function storeMemory(config, content, memoryType, tags) {
const endpoint = config.memoryService.http.endpoint;
const apiKey = config.memoryService.http.apiKey;
const url = new URL('/api/memories', endpoint);
const isHttps = url.protocol === 'https:';
const payload = JSON.stringify({
content: content,
memory_type: memoryType,
tags: tags,
metadata: {
source: 'auto-capture',
hook: 'PostToolUse',
captured_at: new Date().toISOString()
}
});
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
...(apiKey ? { 'X-API-Key': apiKey } : {})
},
timeout: 5000
};
return new Promise((resolve, reject) => {
const client = isHttps ? https : http;
const req = client.request(options, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(data));
} catch {
resolve({ success: true, raw: data });
}
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.write(payload);
req.end();
});
}
/**
* Main hook execution
*/
async function main() {
const startTime = Date.now();
try {
// Load configuration
const config = await loadConfig();
// Check if auto-capture is enabled
if (!config.autoCapture.enabled) {
if (config.autoCapture.debugMode) {
console.log('[auto-capture] Disabled in configuration');
}
process.exit(0);
}
// Read stdin input
const stdinData = await readStdin();
let input = {};
try {
input = JSON.parse(stdinData);
} catch {
// No valid input, might be empty
if (config.autoCapture.debugMode) {
console.log('[auto-capture] No valid stdin input');
}
process.exit(0);
}
// Extract transcript path and cwd
const transcriptPath = input.transcript_path || input.transcriptPath;
const cwd = input.cwd || process.cwd();
if (!transcriptPath) {
if (config.autoCapture.debugMode) {
console.log('[auto-capture] No transcript path provided');
}
process.exit(0);
}
// Parse transcript
const transcript = await parseTranscript(transcriptPath);
if (!transcript) {
process.exit(0);
}
// Check user overrides
const overrides = hasUserOverride(transcript.userMessage);
if (overrides.forceSkip) {
if (config.autoCapture.debugMode) {
console.log('[auto-capture] Skipped by user override (#skip)');
}
process.exit(0);
}
const content = transcript.combined;
// Detect patterns (unless force remember)
let detection;
if (overrides.forceRemember) {
detection = {
isValuable: true,
memoryType: 'Context',
matchedPattern: 'user-override',
confidence: 1.0
};
if (config.autoCapture.debugMode) {
console.log('[auto-capture] Force remember by user override (#remember)');
}
} else {
detection = detectPatterns(content, {
minLength: config.autoCapture.minLength,
enabledPatterns: config.autoCapture.patterns,
debugMode: config.autoCapture.debugMode
});
}
if (!detection.isValuable) {
if (config.autoCapture.debugMode) {
console.log(`[auto-capture] Not valuable: ${detection.reason}`);
}
process.exit(0);
}
// Prepare content for storage
const truncatedContent = truncateContent(content, config.autoCapture.maxLength);
const projectName = extractProjectName(cwd);
const tags = generateTags(detection, projectName);
// Store memory
if (config.autoCapture.debugMode) {
console.log(`[auto-capture] Storing ${detection.memoryType} memory...`);
console.log(`[auto-capture] Pattern: ${detection.matchedPattern}`);
console.log(`[auto-capture] Tags: ${tags.join(', ')}`);
}
const result = await storeMemory(
config,
truncatedContent,
detection.memoryType,
tags
);
const elapsed = Date.now() - startTime;
if (config.autoCapture.debugMode) {
console.log(`[auto-capture] Stored successfully in ${elapsed}ms`);
console.log(`[auto-capture] Hash: ${result.content_hash || 'unknown'}`);
}
process.exit(0);
} catch (error) {
const elapsed = Date.now() - startTime;
console.error(`[auto-capture] Error after ${elapsed}ms:`, error.message);
// Exit gracefully - don't block the user's workflow
process.exit(0);
}
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = { main, loadConfig, parseTranscript, storeMemory };