@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
411 lines (357 loc) • 10.8 kB
JavaScript
/**
* Skill Evaluation Engine v2.0
*
* Intelligent skill activation based on:
* - Keywords and patterns in prompts
* - File paths mentioned or being edited
* - Directory mappings
* - Intent detection
* - Content pattern matching
*
* Outputs a structured reminder with matched skills and reasons.
*/
const fs = require('fs');
const path = require('path');
// Configuration
const RULES_PATH = path.join(__dirname, 'skill-rules.json');
/**
* @typedef {Object} SkillMatch
* @property {string} name - Skill name
* @property {number} score - Confidence score
* @property {string[]} reasons - Why this skill was matched
* @property {number} priority - Skill priority
*/
/**
* Load skill rules from JSON file
* @returns {Object} Parsed skill rules
*/
function loadRules() {
try {
const content = fs.readFileSync(RULES_PATH, 'utf-8');
return JSON.parse(content);
} catch (error) {
console.error(`Failed to load skill rules: ${error.message}`);
process.exit(0); // Exit gracefully to not block the prompt
}
}
/**
* Extract file paths mentioned in the prompt
* @param {string} prompt - User's prompt text
* @returns {string[]} Array of detected file paths
*/
function extractFilePaths(prompt) {
const paths = new Set();
// Match explicit paths with extensions (.tsx, .ts, .jsx, .js, .json, .gql, .yaml, .yml, .md, .sh)
const extensionPattern =
/(?:^|\s|["'`])([\w\-./]+\.(?:[tj]sx?|json|gql|ya?ml|md|sh))\b/gi;
let match;
while ((match = extensionPattern.exec(prompt)) !== null) {
paths.add(match[1]);
}
// Match paths starting with common directories
const dirPattern =
/(?:^|\s|["'`])((?:src|app|components|screens|hooks|utils|services|navigation|graphql|localization|\.claude|\.github|\.maestro)\/[\w\-./]+)/gi;
while ((match = dirPattern.exec(prompt)) !== null) {
paths.add(match[1]);
}
// Match quoted paths
const quotedPattern = /["'`]([\w\-./]+\/[\w\-./]+)["'`]/g;
while ((match = quotedPattern.exec(prompt)) !== null) {
paths.add(match[1]);
}
return Array.from(paths);
}
/**
* Check if a pattern matches the text
* @param {string} text - Text to search in
* @param {string} pattern - Regex pattern
* @param {string} flags - Regex flags
* @returns {boolean}
*/
function matchesPattern(text, pattern, flags = 'i') {
try {
const regex = new RegExp(pattern, flags);
return regex.test(text);
} catch {
return false;
}
}
/**
* Check if a glob pattern matches a file path
* @param {string} filePath - File path to check
* @param {string} globPattern - Glob pattern (simplified)
* @returns {boolean}
*/
function matchesGlob(filePath, globPattern) {
// Convert glob to regex (simplified)
const regexPattern = globPattern
.replace(/\./g, '\\.')
.replace(/\*\*\//g, '<<<DOUBLESTARSLASH>>>')
.replace(/\*\*/g, '<<<DOUBLESTAR>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<DOUBLESTARSLASH>>>/g, '(.*\\/)?')
.replace(/<<<DOUBLESTAR>>>/g, '.*')
.replace(/\?/g, '.');
try {
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(filePath);
} catch {
return false;
}
}
/**
* Check if file path matches any directory mapping
* @param {string} filePath - File path to check
* @param {Object} mappings - Directory to skill mappings
* @returns {string|null} Matched skill name or null
*/
function matchDirectoryMapping(filePath, mappings) {
for (const [dir, skillName] of Object.entries(mappings)) {
if (filePath === dir || filePath.startsWith(dir + '/')) {
return skillName;
}
}
return null;
}
/**
* Evaluate a single skill against the prompt and context
* @param {string} skillName - Name of the skill
* @param {Object} skill - Skill configuration
* @param {string} prompt - User's prompt
* @param {string} promptLower - Lowercase prompt
* @param {string[]} filePaths - Extracted file paths
* @param {Object} rules - Full rules object
* @returns {SkillMatch|null}
*/
function evaluateSkill(
skillName,
skill,
prompt,
promptLower,
filePaths,
rules
) {
const { triggers = {}, excludePatterns = [], priority = 5 } = skill;
const scoring = rules.scoring;
let score = 0;
const reasons = [];
// Check exclude patterns first
for (const excludePattern of excludePatterns) {
if (matchesPattern(promptLower, excludePattern)) {
return null;
}
}
// 1. Check keywords
if (triggers.keywords) {
for (const keyword of triggers.keywords) {
if (promptLower.includes(keyword.toLowerCase())) {
score += scoring.keyword;
reasons.push(`keyword "${keyword}"`);
}
}
}
// 2. Check keyword patterns (regex)
if (triggers.keywordPatterns) {
for (const pattern of triggers.keywordPatterns) {
if (matchesPattern(promptLower, pattern)) {
score += scoring.keywordPattern;
reasons.push(`pattern /${pattern}/`);
}
}
}
// 3. Check intent patterns
if (triggers.intentPatterns) {
for (const pattern of triggers.intentPatterns) {
if (matchesPattern(promptLower, pattern)) {
score += scoring.intentPattern;
reasons.push(`intent detected`);
break;
}
}
}
// 4. Check context patterns
if (triggers.contextPatterns) {
for (const pattern of triggers.contextPatterns) {
if (promptLower.includes(pattern.toLowerCase())) {
score += scoring.contextPattern;
reasons.push(`context "${pattern}"`);
}
}
}
// 5. Check file paths against path patterns
if (triggers.pathPatterns && filePaths.length > 0) {
for (const filePath of filePaths) {
for (const pattern of triggers.pathPatterns) {
if (matchesGlob(filePath, pattern)) {
score += scoring.pathPattern;
reasons.push(`path "${filePath}"`);
break;
}
}
}
}
// 6. Check directory mappings
if (rules.directoryMappings && filePaths.length > 0) {
for (const filePath of filePaths) {
const mappedSkill = matchDirectoryMapping(
filePath,
rules.directoryMappings
);
if (mappedSkill === skillName) {
score += scoring.directoryMatch;
reasons.push(`directory mapping`);
break;
}
}
}
// 7. Check content patterns in prompt (for code snippets)
if (triggers.contentPatterns) {
for (const pattern of triggers.contentPatterns) {
if (matchesPattern(prompt, pattern)) {
score += scoring.contentPattern;
reasons.push(`code pattern detected`);
break;
}
}
}
if (score > 0) {
return {
name: skillName,
score,
reasons: [...new Set(reasons)],
priority,
};
}
return null;
}
/**
* Get related skills that should also be suggested
* @param {SkillMatch[]} matches - Current matches
* @param {Object} skills - All skill definitions
* @returns {string[]} Additional skill names to suggest
*/
function getRelatedSkills(matches, skills) {
const matchedNames = new Set(matches.map((m) => m.name));
const related = new Set();
for (const match of matches) {
const skill = skills[match.name];
if (skill?.relatedSkills) {
for (const relatedName of skill.relatedSkills) {
if (!matchedNames.has(relatedName)) {
related.add(relatedName);
}
}
}
}
return Array.from(related);
}
/**
* Format confidence level based on score
* @param {number} score - Confidence score
* @param {number} minScore - Minimum threshold
* @returns {string} Confidence label
*/
function formatConfidence(score, minScore) {
if (score >= minScore * 3) return 'HIGH';
if (score >= minScore * 2) return 'MEDIUM';
return 'LOW';
}
/**
* Main evaluation function
* @param {string} prompt - User's prompt
* @returns {string} Formatted output
*/
function evaluate(prompt) {
const rules = loadRules();
const { config, skills } = rules;
const promptLower = prompt.toLowerCase();
const filePaths = extractFilePaths(prompt);
// Evaluate all skills
const matches = [];
for (const [name, skill] of Object.entries(skills)) {
const match = evaluateSkill(
name,
skill,
prompt,
promptLower,
filePaths,
rules
);
if (match && match.score >= config.minConfidenceScore) {
matches.push(match);
}
}
if (matches.length === 0) {
return '';
}
// Sort by score (descending), then by priority (descending)
matches.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.priority - a.priority;
});
// Limit to max skills
const topMatches = matches.slice(0, config.maxSkillsToShow);
// Check for related skills
const relatedSkills = getRelatedSkills(topMatches, skills);
// Format output
let output = '<user-prompt-submit-hook>\n';
output += 'SKILL ACTIVATION REQUIRED\n\n';
if (filePaths.length > 0) {
output += `Detected file paths: ${filePaths.join(', ')}\n\n`;
}
output += 'Matched skills (ranked by relevance):\n';
for (let i = 0; i < topMatches.length; i++) {
const match = topMatches[i];
const confidence = formatConfidence(match.score, config.minConfidenceScore);
output += `${i + 1}. ${match.name} (${confidence} confidence)\n`;
if (config.showMatchReasons && match.reasons.length > 0) {
output += ` Matched: ${match.reasons.slice(0, 3).join(', ')}\n`;
}
}
if (relatedSkills.length > 0) {
output += `\nRelated skills to consider: ${relatedSkills.join(', ')}\n`;
}
output += '\nBefore implementing, you MUST:\n';
output += '1. EVALUATE: State YES/NO for each skill with brief reasoning\n';
output += '2. ACTIVATE: Invoke the Skill tool for each YES skill\n';
output += '3. IMPLEMENT: Only proceed after skill activation\n';
output += '\nExample evaluation:\n';
output += `- ${topMatches[0].name}: YES - [your reasoning]\n`;
if (topMatches.length > 1) {
output += `- ${topMatches[1].name}: NO - [your reasoning]\n`;
}
output += '\nDO NOT skip this step. Invoke relevant skills NOW.\n';
output += '</user-prompt-submit-hook>';
return output;
}
// Main execution
function main() {
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
let prompt = '';
try {
const data = JSON.parse(input);
prompt = data.prompt || '';
} catch {
prompt = input;
}
if (!prompt.trim()) {
process.exit(0);
}
try {
const output = evaluate(prompt);
if (output) {
console.log(output);
}
} catch (error) {
console.error(`Skill evaluation failed: ${error.message}`);
}
process.exit(0);
});
}
main();