HookManager.js•14.8 kB
import { promises as fs } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
export class HookManager {
constructor(processor) {
this.processor = processor;
this.sessionDir = join(homedir(), '.mcp_sequential_thinking');
this.currentSessionFile = join(this.sessionDir, 'current_session.json');
this.hooks = new Map();
this.debounceTimeout = null;
this.debounceDelay = 2000;
this.sessionData = [];
this.lastUserPrompt = null;
this.slashCommandProcessor = null;
this.initialized = false;
}
setSlashCommandProcessor(processor) {
this.slashCommandProcessor = processor;
}
async initialize() {
try {
await this.ensureSessionDirectory();
await this.loadCurrentSession();
await this.setupHooks();
this.initialized = true;
console.log('HookManager initialized with sequential thinking hooks');
} catch (error) {
console.error('Failed to initialize HookManager:', error);
throw error;
}
}
async ensureSessionDirectory() {
try {
await fs.mkdir(this.sessionDir, { recursive: true });
} catch (error) {
console.error('Failed to create session directory:', error);
throw error;
}
}
async loadCurrentSession() {
try {
const data = await fs.readFile(this.currentSessionFile, 'utf-8');
this.sessionData = JSON.parse(data);
} catch (error) {
this.sessionData = [];
}
}
async saveCurrentSession() {
try {
await fs.writeFile(
this.currentSessionFile,
JSON.stringify(this.sessionData, null, 2)
);
} catch (error) {
console.error('Failed to save current session:', error);
}
}
async setupHooks() {
this.registerHook('post-tool-use', this.handlePostToolUse.bind(this));
this.registerHook('user-prompt-submit', this.handleUserPromptSubmit.bind(this));
this.registerHook('session-stop', this.handleSessionStop.bind(this));
this.registerHook('sequential-thinking', this.handleSequentialThinking.bind(this));
process.on('SIGINT', () => this.handleSessionStop());
process.on('SIGTERM', () => this.handleSessionStop());
}
registerHook(hookName, handler) {
if (!this.hooks.has(hookName)) {
this.hooks.set(hookName, []);
}
this.hooks.get(hookName).push(handler);
}
async triggerHook(hookName, data) {
if (!this.hooks.has(hookName)) return;
const handlers = this.hooks.get(hookName);
for (const handler of handlers) {
try {
await handler(data);
} catch (error) {
console.error(`Error in hook ${hookName}:`, error);
}
}
}
async handlePostToolUse(data) {
if (!this.shouldProcessToolUse(data)) return;
const context = this.extractContext(data);
// Add working directory context
context.workingDirectory = data.workingDirectory || process.cwd();
// Correlate with the latest user prompt if available
if (this.lastUserPrompt) {
context.userPromptCorrelation = {
prompt: this.lastUserPrompt.prompt,
promptTimestamp: this.lastUserPrompt.timestamp,
timeSincePrompt: new Date().getTime() - new Date(this.lastUserPrompt.timestamp).getTime(),
hasSlashCommand: this.lastUserPrompt.context.hasSlashCommand,
};
}
const sessionEntry = {
type: 'tool_use',
toolName: data.toolName,
arguments: data.arguments,
result: data.result,
timestamp: new Date().toISOString(),
context,
};
this.sessionData.push(sessionEntry);
await this.saveCurrentSession();
this.debouncedProcess();
}
shouldProcessToolUse(data) {
const relevantTools = [
'Edit', 'MultiEdit', 'Write', 'TodoWrite',
'Bash', 'Grep', 'Read'
];
return relevantTools.includes(data.toolName);
}
extractContext(data) {
const context = {
toolName: data.toolName,
timestamp: new Date().toISOString(),
};
if (data.arguments) {
if (data.arguments.file_path) {
context.filePath = data.arguments.file_path;
context.fileType = this.inferFileType(data.arguments.file_path);
}
if (data.arguments.command) {
context.command = data.arguments.command;
}
if (data.arguments.pattern) {
context.searchPattern = data.arguments.pattern;
}
}
if (data.result && typeof data.result === 'string') {
context.hasOutput = data.result.length > 0;
context.outputLength = data.result.length;
if (data.result.includes('error') || data.result.includes('Error')) {
context.hasError = true;
}
}
return context;
}
inferFileType(filePath) {
const extension = filePath.split('.').pop()?.toLowerCase();
const typeMap = {
js: 'javascript',
ts: 'typescript',
jsx: 'react',
tsx: 'react-typescript',
py: 'python',
java: 'java',
cpp: 'cpp',
c: 'c',
cs: 'csharp',
go: 'go',
rs: 'rust',
php: 'php',
rb: 'ruby',
html: 'html',
css: 'css',
scss: 'sass',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
md: 'markdown',
sql: 'sql',
};
return typeMap[extension] || 'unknown';
}
async handleUserPromptSubmit(data) {
const prompt = data.prompt || data.content;
const hasSlashCommand = prompt?.startsWith('/');
const sessionEntry = {
type: 'user_prompt_submit',
prompt,
workingDirectory: data.workingDirectory || process.cwd(),
timestamp: new Date().toISOString(),
context: {
promptLength: (prompt || '').length,
hasSlashCommand,
workingDirectory: data.workingDirectory || process.cwd(),
},
};
// Process slash commands if available
if (hasSlashCommand && this.slashCommandProcessor) {
try {
const slashResult = await this.slashCommandProcessor.processSlashCommand(prompt);
sessionEntry.slashCommandResult = slashResult;
// Log slash command execution
console.log(`Slash command executed: ${prompt.split(' ')[0]} - Success: ${slashResult.success}`);
} catch (error) {
sessionEntry.slashCommandError = error.message;
console.error(`Slash command failed: ${prompt.split(' ')[0]} - ${error.message}`);
}
}
this.sessionData.push(sessionEntry);
await this.saveCurrentSession();
// Store the latest prompt for correlation with subsequent tool uses
this.lastUserPrompt = sessionEntry;
}
async handleSequentialThinking(data) {
const sessionEntry = {
type: 'sequential_thinking',
content: data.content,
context: data.context || {},
timestamp: new Date().toISOString(),
};
this.sessionData.push(sessionEntry);
await this.saveCurrentSession();
this.debouncedProcess();
}
debouncedProcess() {
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = setTimeout(() => {
this.processRecentSession();
}, this.debounceDelay);
}
async processRecentSession() {
if (this.sessionData.length === 0) return;
try {
const recentData = this.sessionData.slice(-10);
const context = this.analyzeSessionContext(recentData);
await this.processor.queueProcessing(recentData, context);
console.log(`Processed ${recentData.length} session entries`);
} catch (error) {
console.error('Error processing recent session:', error);
}
}
analyzeSessionContext(sessionData) {
const context = {
sessionLength: sessionData.length,
timespan: this.calculateTimespan(sessionData),
dominantFileType: this.findDominantFileType(sessionData),
toolsUsed: this.extractToolsUsed(sessionData),
hasErrors: this.hasErrors(sessionData),
workflowPattern: this.identifyWorkflowPattern(sessionData),
projectContext: this.extractProjectContext(sessionData),
userPromptContext: this.analyzeUserPrompts(sessionData),
};
return context;
}
calculateTimespan(sessionData) {
if (sessionData.length < 2) return 0;
const first = new Date(sessionData[0].timestamp);
const last = new Date(sessionData[sessionData.length - 1].timestamp);
return last.getTime() - first.getTime();
}
findDominantFileType(sessionData) {
const typeCounts = {};
for (const entry of sessionData) {
if (entry.context?.fileType) {
typeCounts[entry.context.fileType] = (typeCounts[entry.context.fileType] || 0) + 1;
}
}
return Object.entries(typeCounts)
.sort(([, a], [, b]) => b - a)[0]?.[0] || 'unknown';
}
extractToolsUsed(sessionData) {
return [...new Set(sessionData.map(entry => entry.toolName || entry.type))];
}
hasErrors(sessionData) {
return sessionData.some(entry => entry.context?.hasError);
}
identifyWorkflowPattern(sessionData) {
const tools = sessionData.map(entry => entry.toolName || entry.type);
if (tools.includes('Read') && tools.includes('Edit')) {
return 'read_edit_cycle';
}
if (tools.includes('Grep') && tools.includes('Edit')) {
return 'search_modify';
}
if (tools.includes('Bash') && tools.includes('Edit')) {
return 'test_driven';
}
if (tools.filter(t => t === 'Edit').length > 3) {
return 'iterative_development';
}
return 'general';
}
async handleSessionStop() {
if (this.sessionData.length === 0) return;
try {
console.log('Processing complete session before shutdown...');
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
const fullContext = this.analyzeSessionContext(this.sessionData);
fullContext.sessionComplete = true;
await this.processor.processSequentialThinking(this.sessionData, fullContext);
await this.archiveSession();
console.log(`Session processed: ${this.sessionData.length} entries`);
} catch (error) {
console.error('Error processing session on stop:', error);
}
}
async archiveSession() {
if (this.sessionData.length === 0) return;
try {
const archiveFileName = `session_${Date.now()}.json`;
const archivePath = join(this.sessionDir, 'archives', archiveFileName);
await fs.mkdir(join(this.sessionDir, 'archives'), { recursive: true });
const archiveData = {
sessionData: this.sessionData,
archived: new Date().toISOString(),
summary: this.analyzeSessionContext(this.sessionData),
};
await fs.writeFile(archivePath, JSON.stringify(archiveData, null, 2));
this.sessionData = [];
await this.saveCurrentSession();
await this.cleanupOldArchives();
} catch (error) {
console.error('Failed to archive session:', error);
}
}
async cleanupOldArchives() {
try {
const archivesDir = join(this.sessionDir, 'archives');
const files = await fs.readdir(archivesDir).catch(() => []);
const archiveFiles = files
.filter(file => file.startsWith('session_') && file.endsWith('.json'))
.map(file => ({
name: file,
path: join(archivesDir, file),
timestamp: parseInt(file.replace('session_', '').replace('.json', ''), 10),
}))
.sort((a, b) => b.timestamp - a.timestamp);
const maxArchives = 50;
if (archiveFiles.length > maxArchives) {
const filesToDelete = archiveFiles.slice(maxArchives);
for (const file of filesToDelete) {
await fs.unlink(file.path);
}
}
} catch (error) {
console.warn('Failed to cleanup old archives:', error);
}
}
extractProjectContext(sessionData) {
const workingDirectories = new Set();
const projects = [];
for (const entry of sessionData) {
if (entry.context?.workingDirectory) {
workingDirectories.add(entry.context.workingDirectory);
}
}
// Identify project types based on directory names and patterns
workingDirectories.forEach(dir => {
const segments = dir.split('/').filter(Boolean);
const projectName = segments[segments.length - 1];
projects.push({
directory: dir,
projectName,
isGitRepo: dir.includes('.git') || segments.some(s => s.startsWith('.')),
estimatedType: this.inferProjectType(dir),
});
});
return {
workingDirectories: Array.from(workingDirectories),
projectCount: workingDirectories.size,
dominantProject: projects.length > 0 ? projects[0] : null,
projects,
};
}
analyzeUserPrompts(sessionData) {
const prompts = sessionData.filter(entry => entry.type === 'user_prompt_submit');
const slashCommands = prompts.filter(p => p.context?.hasSlashCommand);
return {
totalPrompts: prompts.length,
slashCommandCount: slashCommands.length,
averagePromptLength: prompts.length > 0
? prompts.reduce((sum, p) => sum + (p.context?.promptLength || 0), 0) / prompts.length
: 0,
recentSlashCommands: slashCommands.slice(-3).map(p => p.prompt?.split(' ')[0]),
};
}
inferProjectType(directory) {
const dir = directory.toLowerCase();
if (dir.includes('node_modules') || dir.includes('package.json')) return 'nodejs';
if (dir.includes('python') || dir.includes('.py')) return 'python';
if (dir.includes('java') || dir.includes('.java')) return 'java';
if (dir.includes('react') || dir.includes('next')) return 'react';
if (dir.includes('vue')) return 'vue';
if (dir.includes('angular')) return 'angular';
if (dir.includes('rust') || dir.includes('.rs')) return 'rust';
if (dir.includes('go') || dir.includes('.go')) return 'go';
if (dir.includes('docker')) return 'docker';
if (dir.includes('k8s') || dir.includes('kubernetes')) return 'kubernetes';
return 'unknown';
}
async getSessionStats() {
return {
currentSessionEntries: this.sessionData.length,
sessionStarted: this.sessionData.length > 0 ? this.sessionData[0].timestamp : null,
lastActivity: this.sessionData.length > 0 ? this.sessionData[this.sessionData.length - 1].timestamp : null,
context: this.sessionData.length > 0 ? this.analyzeSessionContext(this.sessionData) : null,
};
}
async shutdown() {
if (this.initialized) {
await this.handleSessionStop();
console.log('HookManager shut down successfully');
}
}
}