Files
gh-bandofai-puerto-plugins-…/hooks/puerto-prompt-analyzer.js
2025-11-29 17:59:51 +08:00

791 lines
22 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Puerto Prompt Analyzer Hook for Claude Code (v2.0)
*
* Analyzes user prompts before Claude processes them, providing:
* - Task type classification (research, implementation, mixed)
* - Instruction quality validation
* - Intelligent plugin recommendations from Puerto marketplace
* - Project context awareness
* - Caching and performance optimization
*
* Part of the essentials plugin
* @see plugins/essentials/hooks/README.md for documentation
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// ============================================================================
// CONFIGURATION
// ============================================================================
const RESEARCH_KEYWORDS = [
'explain', 'analyze', 'research', 'investigate', 'understand',
'review', 'compare', 'summarize', 'describe', 'document',
'learn', 'study', 'explore', 'examine', 'evaluate'
];
const IMPLEMENTATION_KEYWORDS = [
'implement', 'create', 'build', 'fix', 'refactor', 'add',
'modify', 'write', 'develop', 'code', 'make', 'update',
'change', 'remove', 'delete', 'optimize', 'improve', 'deploy'
];
const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'be', 'been',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'should',
'could', 'may', 'might', 'can', 'this', 'that', 'these', 'those'
]);
const VALIDATION_PATTERNS = {
tooVague: {
patterns: [/^(fix it|make it better|improve this|do it|help)$/i],
suggestion: 'Be more specific about what you want to fix or improve'
},
missingContext: {
patterns: [/\b(this|that|it)\b/i],
minLength: 30,
suggestion: 'Provide clear context - what specifically does "this" or "that" refer to?'
},
overlyBroad: {
patterns: [/^(build an? app|create a (website|system)|make (something|a thing))$/i],
suggestion: 'Break down your request into specific features or components'
}
};
// Scoring weights (tuned for better results)
const WEIGHTS = {
keywordMatch: 3,
nameMatch: 5,
descriptionOverlap: 2,
categoryMatch: 4,
taskTypeAlign: 5,
projectTypeMatch: 3
};
const MIN_SCORE_THRESHOLD = 8;
const MAX_RECOMMENDATIONS = 3;
const CACHE_TTL = 60000; // 60 seconds
const SESSION_MEMORY_TTL = 3600000; // 1 hour
// ============================================================================
// CACHING & SESSION MANAGEMENT
// ============================================================================
const CACHE = {
marketplace: null,
marketplacePath: null,
timestamp: 0,
installedPlugins: null,
installedTimestamp: 0
};
const SESSION_MEMORY = new Map(); // session_id -> { shown: Set, timestamp }
function cleanupOldSessions() {
const now = Date.now();
for (const [sessionId, data] of SESSION_MEMORY.entries()) {
if (now - data.timestamp > SESSION_MEMORY_TTL) {
SESSION_MEMORY.delete(sessionId);
}
}
}
function getSessionMemory(sessionId) {
if (!SESSION_MEMORY.has(sessionId)) {
SESSION_MEMORY.set(sessionId, {
shown: new Set(),
timestamp: Date.now()
});
}
return SESSION_MEMORY.get(sessionId);
}
function markAsShown(sessionId, pluginName) {
const memory = getSessionMemory(sessionId);
memory.shown.add(pluginName);
memory.timestamp = Date.now();
}
function wasRecentlyShown(sessionId, pluginName) {
const memory = SESSION_MEMORY.get(sessionId);
return memory && memory.shown.has(pluginName);
}
// ============================================================================
// MAIN ENTRY POINT
// ============================================================================
function main() {
const perfStart = Date.now();
try {
// Cleanup old sessions periodically
if (Math.random() < 0.1) { // 10% chance
cleanupOldSessions();
}
// Read hook input from stdin
const input = fs.readFileSync(0, 'utf-8');
const hookInput = JSON.parse(input);
// Analyze and generate output
const result = analyzeInstruction(hookInput);
// Output JSON to stdout
console.log(JSON.stringify(result, null, 2));
// Performance logging
const elapsed = Date.now() - perfStart;
if (elapsed > 500) {
console.error(`[puerto-prompt-analyzer] SLOW execution: ${elapsed}ms`);
}
} catch (error) {
// Fail open - log error but allow prompt to proceed
console.error('[puerto-prompt-analyzer] Fatal error:', error.message);
console.log(JSON.stringify(allowPrompt()));
process.exit(0);
}
}
// ============================================================================
// CORE ANALYSIS LOGIC
// ============================================================================
function analyzeInstruction(hookInput) {
try {
const { prompt, cwd, session_id } = hookInput;
// Skip if empty or command
if (!prompt || !prompt.trim()) {
return allowPrompt();
}
if (prompt.trim().startsWith('/')) {
return allowPrompt();
}
// Load configuration
const config = loadConfiguration();
// Classify task type
const taskType = classifyTaskType(prompt);
// Detect project context
const projectContext = detectProjectContext(cwd);
// Validate instruction quality
const validation = validateInstruction(prompt);
// Load and score plugins
const recommendations = getPluginRecommendations(
prompt,
taskType,
cwd,
session_id,
projectContext,
config
);
// Mark shown plugins
recommendations.forEach(p => markAsShown(session_id, p.name));
// Generate markdown output
const additionalContext = formatRecommendations(
taskType,
recommendations,
validation,
projectContext
);
return {
decision: undefined,
reason: 'Analysis complete',
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext
}
};
} catch (error) {
console.error('[puerto-prompt-analyzer] Error during analysis:', error.message);
return allowPrompt();
}
}
// ============================================================================
// CONFIGURATION MANAGEMENT
// ============================================================================
function loadConfiguration() {
const configPath = path.join(os.homedir(), '.claude', 'puerto-prompt-analyzer.json');
const defaults = {
minScore: MIN_SCORE_THRESHOLD,
maxRecommendations: MAX_RECOMMENDATIONS,
cacheMinutes: 1,
blacklist: [],
favoriteCategories: [],
showScores: false
};
try {
if (fs.existsSync(configPath)) {
const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
return { ...defaults, ...userConfig };
}
} catch (error) {
console.error('[puerto-prompt-analyzer] Error loading config:', error.message);
}
return defaults;
}
// ============================================================================
// PROJECT CONTEXT DETECTION
// ============================================================================
function detectProjectContext(cwd) {
if (!cwd) return { type: 'unknown', files: [] };
const context = {
type: 'unknown',
languages: [],
frameworks: [],
files: []
};
try {
// Check for package.json (JavaScript/Node.js)
if (fs.existsSync(path.join(cwd, 'package.json'))) {
context.type = 'javascript';
context.languages.push('javascript', 'nodejs');
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
// Detect frameworks
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['react']) context.frameworks.push('react');
if (deps['vue']) context.frameworks.push('vue');
if (deps['next']) context.frameworks.push('nextjs');
if (deps['express']) context.frameworks.push('express');
}
// Check for Python
if (fs.existsSync(path.join(cwd, 'requirements.txt')) ||
fs.existsSync(path.join(cwd, 'pyproject.toml'))) {
context.type = 'python';
context.languages.push('python');
}
// Check for Rust
if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) {
context.type = 'rust';
context.languages.push('rust');
}
// Check for Go
if (fs.existsSync(path.join(cwd, 'go.mod'))) {
context.type = 'go';
context.languages.push('go', 'golang');
}
// Check for Ruby
if (fs.existsSync(path.join(cwd, 'Gemfile'))) {
context.type = 'ruby';
context.languages.push('ruby');
}
// Check for Java/Kotlin
if (fs.existsSync(path.join(cwd, 'pom.xml')) ||
fs.existsSync(path.join(cwd, 'build.gradle'))) {
context.type = 'java';
context.languages.push('java');
}
} catch (error) {
console.error('[puerto-prompt-analyzer] Error detecting project:', error.message);
}
return context;
}
// ============================================================================
// TEXT PROCESSING UTILITIES
// ============================================================================
function stem(word) {
// Simple stemmer - remove common suffixes
return word
.replace(/ies$/, 'y')
.replace(/ing$/, '')
.replace(/ed$/, '')
.replace(/s$/, '')
.toLowerCase();
}
function tokenize(text) {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, ' ') // Keep hyphens
.split(/\s+/)
.map(stem)
.filter(w => w.length > 2 && !STOP_WORDS.has(w));
}
// ============================================================================
// TASK CLASSIFICATION
// ============================================================================
function classifyTaskType(prompt) {
const tokens = tokenize(prompt);
const lower = prompt.toLowerCase();
// Count keyword matches (using stemmed tokens)
const researchScore = RESEARCH_KEYWORDS.filter(kw =>
tokens.includes(stem(kw)) || lower.includes(kw)
).length;
const implScore = IMPLEMENTATION_KEYWORDS.filter(kw =>
tokens.includes(stem(kw)) || lower.includes(kw)
).length;
// Enhanced classification
if (implScore > researchScore * 1.5) {
return 'Implementation';
}
if (researchScore > implScore * 1.5) {
return 'Research';
}
if (researchScore > 0 && implScore > 0) {
return 'Mixed';
}
return 'General';
}
// ============================================================================
// INSTRUCTION VALIDATION
// ============================================================================
function validateInstruction(prompt) {
const suggestions = [];
// Check for vague instructions
if (VALIDATION_PATTERNS.tooVague.patterns.some(p => p.test(prompt))) {
suggestions.push(VALIDATION_PATTERNS.tooVague.suggestion);
}
// Check for missing context (only if prompt is very short)
const minLength = VALIDATION_PATTERNS.missingContext.minLength;
if (prompt.length < minLength &&
VALIDATION_PATTERNS.missingContext.patterns.some(p => p.test(prompt))) {
suggestions.push(VALIDATION_PATTERNS.missingContext.suggestion);
}
// Check for overly broad requests
if (VALIDATION_PATTERNS.overlyBroad.patterns.some(p => p.test(prompt))) {
suggestions.push(VALIDATION_PATTERNS.overlyBroad.suggestion);
}
return { suggestions };
}
// ============================================================================
// INSTALLED PLUGINS DETECTION
// ============================================================================
function getInstalledPlugins() {
const now = Date.now();
// Use cache
if (CACHE.installedPlugins && (now - CACHE.installedTimestamp) < CACHE_TTL) {
return CACHE.installedPlugins;
}
const installed = new Set();
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
if (settings.enabledPlugins) {
Object.keys(settings.enabledPlugins).forEach(pluginId => {
// Extract plugin name from "plugin@marketplace" format
const pluginName = pluginId.split('@')[0];
installed.add(pluginName);
});
}
}
} catch (error) {
console.error('[puerto-prompt-analyzer] Error reading installed plugins:', error.message);
}
CACHE.installedPlugins = installed;
CACHE.installedTimestamp = now;
return installed;
}
// ============================================================================
// PLUGIN RECOMMENDATIONS
// ============================================================================
function getPluginRecommendations(prompt, taskType, cwd, sessionId, projectContext, config) {
try {
// Find and load marketplace.json (with caching)
const marketplacePath = findMarketplaceJson(cwd);
if (!marketplacePath) {
console.error('[puerto-prompt-analyzer] marketplace.json not found');
return [];
}
const marketplace = getMarketplaceData(marketplacePath);
if (!marketplace || !marketplace.plugins || !Array.isArray(marketplace.plugins)) {
console.error('[puerto-prompt-analyzer] Invalid marketplace format');
return [];
}
const installedPlugins = getInstalledPlugins();
// Score all plugins
const scored = marketplace.plugins
.map(plugin => ({
...plugin,
score: scorePlugin(plugin, prompt, taskType, sessionId, projectContext, installedPlugins, config)
}))
.filter(p => p.score >= config.minScore) // Quality threshold
.sort((a, b) => b.score - a.score);
// Apply diversity (don't show too many similar plugins)
const diverse = diversifyRecommendations(scored, config.maxRecommendations);
// Add recommendation reasons
return diverse.map(plugin => ({
...plugin,
reason: generateReason(plugin, taskType, prompt, projectContext)
}));
} catch (error) {
console.error('[puerto-prompt-analyzer] Error loading marketplace:', error.message);
return [];
}
}
function getMarketplaceData(marketplacePath) {
const now = Date.now();
// Check cache
if (CACHE.marketplace &&
CACHE.marketplacePath === marketplacePath &&
(now - CACHE.timestamp) < CACHE_TTL) {
return CACHE.marketplace;
}
// Load and cache
try {
CACHE.marketplace = JSON.parse(fs.readFileSync(marketplacePath, 'utf-8'));
CACHE.marketplacePath = marketplacePath;
CACHE.timestamp = now;
return CACHE.marketplace;
} catch (error) {
console.error('[puerto-prompt-analyzer] Error parsing marketplace:', error.message);
return null;
}
}
function findMarketplaceJson(startDir) {
if (!startDir) {
return null;
}
let currentDir = startDir;
// Search up to 5 levels up
for (let i = 0; i < 5; i++) {
const marketplacePath = path.join(currentDir, '.claude-plugin', 'marketplace.json');
if (fs.existsSync(marketplacePath)) {
return marketplacePath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break; // Reached root
}
currentDir = parentDir;
}
return null;
}
function scorePlugin(plugin, prompt, taskType, sessionId, projectContext, installedPlugins, config) {
const promptTokens = new Set(tokenize(prompt));
const lower = prompt.toLowerCase();
let score = 0;
// Skip essentials plugin (it's already installed by definition)
if (plugin.name === 'essentials') {
return -1;
}
// Skip blacklisted plugins
if (config.blacklist.includes(plugin.name)) {
return -1;
}
// Skip installed plugins
if (installedPlugins.has(plugin.name)) {
return -1;
}
// Reduce score for recently shown plugins
if (wasRecentlyShown(sessionId, plugin.name)) {
score -= 5; // Penalty for repetition
}
// 1. Tokenized description overlap (better than simple word matching)
if (plugin.description) {
const descTokens = tokenize(plugin.description);
const overlap = descTokens.filter(t => promptTokens.has(t)).length;
score += overlap * WEIGHTS.descriptionOverlap;
}
// 2. Name match (strong signal)
if (plugin.name) {
const nameWords = plugin.name.split('-');
const nameMatches = nameWords.filter(w =>
promptTokens.has(stem(w)) || lower.includes(w)
).length;
score += nameMatches * WEIGHTS.nameMatch;
}
// 3. Keywords match
if (plugin.keywords && Array.isArray(plugin.keywords)) {
const keywordMatches = plugin.keywords.filter(kw =>
promptTokens.has(stem(kw)) || lower.includes(kw.toLowerCase())
).length;
score += keywordMatches * WEIGHTS.keywordMatch;
}
// 4. Task type alignment
if (taskType === 'Implementation') {
if (plugin.description && /agent|specialist|builder|creator|developer/.test(plugin.description.toLowerCase())) {
score += WEIGHTS.taskTypeAlign;
}
}
if (taskType === 'Research') {
if (plugin.description && /skill|knowledge|guide|reference|documentation/.test(plugin.description.toLowerCase())) {
score += WEIGHTS.taskTypeAlign;
}
}
// 5. Project context matching
if (projectContext.type !== 'unknown') {
const contextTerms = [
...projectContext.languages,
...projectContext.frameworks,
projectContext.type
];
contextTerms.forEach(term => {
if (plugin.keywords && plugin.keywords.some(kw => kw.toLowerCase().includes(term))) {
score += WEIGHTS.projectTypeMatch;
}
if (plugin.description && plugin.description.toLowerCase().includes(term)) {
score += WEIGHTS.projectTypeMatch * 0.5;
}
});
}
// 6. Category boost for favorites
if (config.favoriteCategories.length > 0 && plugin.category) {
if (config.favoriteCategories.includes(plugin.category)) {
score += 3;
}
}
return Math.max(0, score); // Never negative
}
function diversifyRecommendations(plugins, maxCount) {
const selected = [];
const usedCategories = new Set();
const usedKeywords = new Set();
// First pass: pick best from different categories
for (const plugin of plugins) {
if (selected.length >= maxCount) break;
const category = plugin.category || 'general';
const primaryKeyword = (plugin.keywords && plugin.keywords[0]) || '';
// Prefer different categories and keywords
if (!usedCategories.has(category) || selected.length === 0) {
selected.push(plugin);
usedCategories.add(category);
if (primaryKeyword) usedKeywords.add(primaryKeyword);
}
}
// Second pass: fill remaining slots if needed
if (selected.length < maxCount) {
for (const plugin of plugins) {
if (selected.length >= maxCount) break;
if (!selected.includes(plugin)) {
selected.push(plugin);
}
}
}
return selected;
}
function generateReason(plugin, taskType, prompt, projectContext) {
const reasons = [];
// Check for strong keyword matches
if (plugin.keywords && Array.isArray(plugin.keywords)) {
const matches = plugin.keywords.filter(kw =>
prompt.toLowerCase().includes(kw.toLowerCase())
);
if (matches.length > 0) {
reasons.push(`Matches keywords: ${matches.slice(0, 2).join(', ')}`);
}
}
// Check for name matches
const nameWords = plugin.name.split('-');
const nameMatches = nameWords.filter(w =>
prompt.toLowerCase().includes(w)
);
if (nameMatches.length > 0) {
reasons.push(`Related to ${nameMatches.join(', ')}`);
}
// Project context match
if (projectContext.type !== 'unknown') {
const contextTerms = [...projectContext.languages, ...projectContext.frameworks];
const matches = contextTerms.filter(term =>
(plugin.description && plugin.description.toLowerCase().includes(term)) ||
(plugin.keywords && plugin.keywords.some(kw => kw.toLowerCase().includes(term)))
);
if (matches.length > 0) {
reasons.push(`Fits your ${projectContext.type} project`);
}
}
// Task type alignment
if (taskType === 'Implementation') {
reasons.push('Provides specialized implementation tools');
} else if (taskType === 'Research') {
reasons.push('Offers expert knowledge and guidance');
}
// Default reason if nothing specific
if (reasons.length === 0) {
reasons.push('Relevant to your task based on description');
}
return reasons[0]; // Return the most specific reason
}
// ============================================================================
// OUTPUT FORMATTING
// ============================================================================
function formatRecommendations(taskType, plugins, validation, projectContext) {
let md = '\n\n---\n\n## 🔍 Puerto Prompt Analysis\n\n';
// Task type
md += `**Task Type:** ${taskType}`;
// Project context if detected
if (projectContext.type !== 'unknown') {
md += ` | **Project:** ${projectContext.type}`;
if (projectContext.frameworks.length > 0) {
md += ` (${projectContext.frameworks.slice(0, 2).join(', ')})`;
}
}
md += '\n';
// Validation suggestions
if (validation.suggestions.length > 0) {
md += `\n**💡 Suggestions:**\n`;
validation.suggestions.forEach(s => {
md += `- ${s}\n`;
});
}
// Plugin recommendations
if (plugins.length === 0) {
md += '\n*No specific plugin recommendations found.*\n';
md += '\n---\n\n';
return md;
}
md += `\n**📦 Recommended Plugins:**\n\n`;
plugins.forEach((plugin, idx) => {
md += `### ${idx + 1}. \`${plugin.name}\`\n`;
md += `**Description:** ${plugin.description}\n`;
md += `**Why:** ${plugin.reason}\n`;
// Show score if configured
const config = loadConfiguration();
if (config.showScores) {
md += `**Score:** ${Math.round(plugin.score)}\n`;
}
md += `**Install:** \`/plugin install ${plugin.name}\`\n\n`;
});
md += '---\n\n';
return md;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function allowPrompt() {
return {
decision: undefined,
reason: 'Proceeding normally',
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: ''
}
};
}
// ============================================================================
// RUN
// ============================================================================
if (require.main === module) {
main();
}
// Export for testing
module.exports = {
classifyTaskType,
validateInstruction,
scorePlugin,
analyzeInstruction,
tokenize,
stem,
detectProjectContext
};