memory-scorer.js•28.9 kB
/**
* Memory Relevance Scoring Utility
* Implements intelligent algorithms to score memories by relevance to current project context
* Phase 2: Enhanced with conversation context awareness for dynamic memory loading
*/
/**
* Calculate time decay factor for memory relevance
* More recent memories get higher scores
*/
function calculateTimeDecay(memoryDate, decayRate = 0.1) {
try {
const now = new Date();
// Handle both Unix timestamps (seconds) and ISO strings
let memoryTime;
if (typeof memoryDate === 'string') {
// ISO string format
memoryTime = new Date(memoryDate);
} else if (typeof memoryDate === 'number') {
// Unix timestamp in seconds, convert to milliseconds
memoryTime = new Date(memoryDate * 1000);
} else {
return 0.5; // Invalid format
}
if (isNaN(memoryTime.getTime())) {
return 0.5; // Default score for invalid dates
}
// Calculate days since memory creation
const daysDiff = (now - memoryTime) / (1000 * 60 * 60 * 24);
// Exponential decay: score = e^(-decayRate * days)
// Recent memories (0-7 days): score 0.8-1.0
// Older memories (8-30 days): score 0.3-0.8
// Ancient memories (30+ days): score 0.0-0.3
const decayScore = Math.exp(-decayRate * daysDiff);
// Ensure score is between 0 and 1
return Math.max(0.01, Math.min(1.0, decayScore));
} catch (error) {
// Silently fail with default score to avoid noise
return 0.5;
}
}
/**
* Calculate tag relevance score
* Memories with tags matching project context get higher scores
*/
function calculateTagRelevance(memoryTags = [], projectContext) {
try {
if (!Array.isArray(memoryTags) || memoryTags.length === 0) {
return 0.3; // Default score for memories without tags
}
const contextTags = [
projectContext.name?.toLowerCase(),
projectContext.language?.toLowerCase(),
...(projectContext.frameworks || []).map(f => f.toLowerCase()),
...(projectContext.tools || []).map(t => t.toLowerCase())
].filter(Boolean);
if (contextTags.length === 0) {
return 0.5; // No context to match against
}
// Calculate tag overlap
const memoryTagsLower = memoryTags.map(tag => tag.toLowerCase());
const matchingTags = contextTags.filter(contextTag =>
memoryTagsLower.some(memoryTag =>
memoryTag.includes(contextTag) || contextTag.includes(memoryTag)
)
);
// Score based on percentage of matching tags
const overlapScore = matchingTags.length / contextTags.length;
// Bonus for exact project name matches
const exactProjectMatch = memoryTagsLower.includes(projectContext.name?.toLowerCase());
const projectBonus = exactProjectMatch ? 0.3 : 0;
// Bonus for exact language matches
const exactLanguageMatch = memoryTagsLower.includes(projectContext.language?.toLowerCase());
const languageBonus = exactLanguageMatch ? 0.2 : 0;
// Bonus for framework matches
const frameworkMatches = (projectContext.frameworks || []).filter(framework =>
memoryTagsLower.some(tag => tag.includes(framework.toLowerCase()))
);
const frameworkBonus = frameworkMatches.length * 0.1;
const totalScore = Math.min(1.0, overlapScore + projectBonus + languageBonus + frameworkBonus);
return Math.max(0.1, totalScore);
} catch (error) {
// Silently fail with default score to avoid noise
return 0.3;
}
}
/**
* Calculate content quality score to penalize generic/empty content
*/
function calculateContentQuality(memoryContent = '') {
try {
if (!memoryContent || typeof memoryContent !== 'string') {
return 0.1;
}
const content = memoryContent.trim();
// Check for generic session summary patterns
const genericPatterns = [
/## 🎯 Topics Discussed\s*-\s*implementation\s*-\s*\.\.\.?$/m,
/Topics Discussed.*implementation.*\.\.\..*$/s,
/Session Summary.*implementation.*\.\.\..*$/s,
/^# Session Summary.*Date.*Project.*Topics Discussed.*implementation.*\.\.\..*$/s
];
const isGeneric = genericPatterns.some(pattern => pattern.test(content));
if (isGeneric) {
return 0.05; // Heavily penalize generic content
}
// Check content length and substance
if (content.length < 50) {
return 0.2; // Short content gets low score
}
// Check for meaningful content indicators
const meaningfulIndicators = [
'decided', 'implemented', 'changed', 'fixed', 'created', 'updated',
'because', 'reason', 'approach', 'solution', 'result', 'impact',
'learned', 'discovered', 'found', 'issue', 'problem', 'challenge'
];
const meaningfulMatches = meaningfulIndicators.filter(indicator =>
content.toLowerCase().includes(indicator)
).length;
// Calculate information density
const words = content.split(/\s+/).filter(w => w.length > 2);
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
const diversityRatio = uniqueWords.size / Math.max(words.length, 1);
// Combine factors
const meaningfulnessScore = Math.min(0.4, meaningfulMatches * 0.08);
const diversityScore = Math.min(0.3, diversityRatio * 0.5);
const lengthScore = Math.min(0.3, content.length / 1000); // Longer content gets bonus
const qualityScore = meaningfulnessScore + diversityScore + lengthScore;
return Math.max(0.05, Math.min(1.0, qualityScore));
} catch (error) {
// Silently fail with default score to avoid noise
return 0.3;
}
}
/**
* Calculate content relevance using simple text analysis
* Memories with content matching project keywords get higher scores
*/
function calculateContentRelevance(memoryContent = '', projectContext) {
try {
if (!memoryContent || typeof memoryContent !== 'string') {
return 0.3;
}
const content = memoryContent.toLowerCase();
const keywords = [
projectContext.name?.toLowerCase(),
projectContext.language?.toLowerCase(),
...(projectContext.frameworks || []).map(f => f.toLowerCase()),
...(projectContext.tools || []).map(t => t.toLowerCase()),
// Add common technical keywords
'architecture', 'decision', 'implementation', 'bug', 'fix',
'feature', 'config', 'setup', 'deployment', 'performance'
].filter(Boolean);
if (keywords.length === 0) {
return 0.5;
}
// Count keyword occurrences
let totalMatches = 0;
let keywordScore = 0;
keywords.forEach(keyword => {
const occurrences = (content.match(new RegExp(keyword, 'g')) || []).length;
if (occurrences > 0) {
totalMatches++;
keywordScore += Math.log(1 + occurrences) * 0.1; // Logarithmic scoring
}
});
// Normalize score
const matchRatio = totalMatches / keywords.length;
const contentScore = Math.min(1.0, matchRatio + keywordScore);
return Math.max(0.1, contentScore);
} catch (error) {
// Silently fail with default score to avoid noise
return 0.3;
}
}
/**
* Calculate memory type bonus
* Certain memory types are more valuable for context injection
*/
function calculateTypeBonus(memoryType) {
const typeScores = {
'decision': 0.3, // Architectural decisions are highly valuable
'architecture': 0.3, // Architecture documentation is important
'reference': 0.2, // Reference materials are useful
'session': 0.15, // Session summaries provide good context
'insight': 0.2, // Insights are valuable for learning
'bug-fix': 0.15, // Bug fixes provide historical context
'feature': 0.1, // Feature descriptions are moderately useful
'note': 0.05, // General notes are less critical
'todo': 0.05, // TODOs are task-specific
'temporary': -0.1 // Temporary notes should be deprioritized
};
return typeScores[memoryType?.toLowerCase()] || 0;
}
/**
* Calculate recency bonus to prioritize very recent memories
* Provides explicit boost for memories created within specific time windows
*/
function calculateRecencyBonus(memoryDate) {
// Recency bonus tiers (days and corresponding bonus values)
const RECENCY_TIERS = [
{ days: 7, bonus: 0.15 }, // Strong boost for last week
{ days: 14, bonus: 0.10 }, // Moderate boost for last 2 weeks
{ days: 30, bonus: 0.05 } // Small boost for last month
];
try {
const now = new Date();
// Handle both Unix timestamps (seconds) and ISO strings
let memoryTime;
if (typeof memoryDate === 'string') {
// ISO string format
memoryTime = new Date(memoryDate);
} else if (typeof memoryDate === 'number') {
// Unix timestamp in seconds, convert to milliseconds
memoryTime = new Date(memoryDate * 1000);
} else {
return 0; // Invalid format
}
if (isNaN(memoryTime.getTime()) || memoryTime > now) {
return 0; // No bonus for invalid or future dates
}
const daysDiff = (now - memoryTime) / (1000 * 60 * 60 * 24);
// Find the appropriate tier for this memory's age
for (const tier of RECENCY_TIERS) {
if (daysDiff <= tier.days) {
return tier.bonus;
}
}
return 0; // No bonus for older memories
} catch (error) {
return 0;
}
}
/**
* Calculate conversation context relevance score (Phase 2)
* Matches memory content with current conversation topics and intent
*/
function calculateConversationRelevance(memory, conversationAnalysis) {
try {
if (!conversationAnalysis || !memory.content) {
return 0.3; // Default score when no conversation context
}
const memoryContent = memory.content.toLowerCase();
let relevanceScore = 0;
let factorCount = 0;
// Score based on topic matching
if (conversationAnalysis.topics && conversationAnalysis.topics.length > 0) {
conversationAnalysis.topics.forEach(topic => {
const topicMatches = (memoryContent.match(new RegExp(topic.name, 'gi')) || []).length;
if (topicMatches > 0) {
relevanceScore += topic.confidence * Math.min(topicMatches * 0.2, 0.8);
factorCount++;
}
});
}
// Score based on entity matching
if (conversationAnalysis.entities && conversationAnalysis.entities.length > 0) {
conversationAnalysis.entities.forEach(entity => {
const entityMatches = (memoryContent.match(new RegExp(entity.name, 'gi')) || []).length;
if (entityMatches > 0) {
relevanceScore += entity.confidence * 0.3;
factorCount++;
}
});
}
// Score based on intent alignment
if (conversationAnalysis.intent) {
const intentKeywords = {
'learning': ['learn', 'understand', 'explain', 'how', 'tutorial', 'guide'],
'problem-solving': ['fix', 'error', 'debug', 'issue', 'problem', 'solve'],
'development': ['build', 'create', 'implement', 'develop', 'code', 'feature'],
'optimization': ['optimize', 'improve', 'performance', 'faster', 'better'],
'review': ['review', 'check', 'analyze', 'audit', 'validate'],
'planning': ['plan', 'design', 'architecture', 'approach', 'strategy']
};
const intentWords = intentKeywords[conversationAnalysis.intent.name] || [];
let intentMatches = 0;
intentWords.forEach(word => {
if (memoryContent.includes(word)) {
intentMatches++;
}
});
if (intentMatches > 0) {
relevanceScore += conversationAnalysis.intent.confidence * (intentMatches / intentWords.length);
factorCount++;
}
}
// Score based on code context if present
if (conversationAnalysis.codeContext && conversationAnalysis.codeContext.isCodeRelated) {
const codeIndicators = ['code', 'function', 'class', 'method', 'variable', 'api', 'library'];
let codeMatches = 0;
codeIndicators.forEach(indicator => {
if (memoryContent.includes(indicator)) {
codeMatches++;
}
});
if (codeMatches > 0) {
relevanceScore += 0.4 * (codeMatches / codeIndicators.length);
factorCount++;
}
}
// Normalize score
const normalizedScore = factorCount > 0 ? relevanceScore / factorCount : 0.3;
return Math.max(0.1, Math.min(1.0, normalizedScore));
} catch (error) {
// Silently fail with default score to avoid noise
return 0.3;
}
}
/**
* Calculate final relevance score for a memory (Enhanced with quality scoring)
*/
function calculateRelevanceScore(memory, projectContext, options = {}) {
try {
const {
weights = {},
timeDecayRate = 0.1, // Default decay rate
includeConversationContext = false,
conversationAnalysis = null
} = options;
// Default weights including content quality factor
const defaultWeights = includeConversationContext ? {
timeDecay: 0.20, // Reduced weight for time
tagRelevance: 0.30, // Tag matching remains important
contentRelevance: 0.15, // Content matching reduced
contentQuality: 0.25, // New quality factor
conversationRelevance: 0.25, // Conversation context factor
typeBonus: 0.05 // Memory type provides minor adjustment
} : {
timeDecay: 0.25, // Reduced time weight
tagRelevance: 0.35, // Tag matching important
contentRelevance: 0.15, // Content matching
contentQuality: 0.25, // Quality factor prioritized
typeBonus: 0.05 // Type bonus reduced
};
const w = { ...defaultWeights, ...weights };
// Calculate individual scores
const timeScore = calculateTimeDecay(memory.created_at || memory.created_at_iso, timeDecayRate);
const tagScore = calculateTagRelevance(memory.tags, projectContext);
const contentScore = calculateContentRelevance(memory.content, projectContext);
const qualityScore = calculateContentQuality(memory.content);
const typeBonus = calculateTypeBonus(memory.memory_type);
const recencyBonus = calculateRecencyBonus(memory.created_at || memory.created_at_iso);
let finalScore = (
(timeScore * w.timeDecay) +
(tagScore * w.tagRelevance) +
(contentScore * w.contentRelevance) +
(qualityScore * w.contentQuality) +
typeBonus + // Type bonus is not weighted, acts as adjustment
recencyBonus // Recency bonus provides explicit boost for very recent memories
);
const breakdown = {
timeDecay: timeScore,
tagRelevance: tagScore,
contentRelevance: contentScore,
contentQuality: qualityScore,
typeBonus: typeBonus,
recencyBonus: recencyBonus
};
// Add conversation context scoring if enabled (Phase 2)
if (includeConversationContext && conversationAnalysis) {
const conversationScore = calculateConversationRelevance(memory, conversationAnalysis);
finalScore += (conversationScore * (w.conversationRelevance || 0));
breakdown.conversationRelevance = conversationScore;
}
// Apply quality penalty for very low quality content (multiplicative)
if (qualityScore < 0.2) {
finalScore *= 0.5; // Heavily penalize low quality content
}
// Ensure score is between 0 and 1
const normalizedScore = Math.max(0, Math.min(1, finalScore));
return {
finalScore: normalizedScore,
breakdown: breakdown,
weights: w,
hasConversationContext: includeConversationContext
};
} catch (error) {
// Silently fail with default score to avoid noise
return {
finalScore: 0.1,
breakdown: { error: error.message },
weights: {},
hasConversationContext: false
};
}
}
/**
* Score and sort memories by relevance
*/
function scoreMemoryRelevance(memories, projectContext, options = {}) {
try {
const { verbose = true } = options;
if (!Array.isArray(memories)) {
if (verbose) console.warn('[Memory Scorer] Invalid memories array');
return [];
}
if (verbose) {
console.log(`[Memory Scorer] Scoring ${memories.length} memories for project: ${projectContext.name}`);
}
// Score each memory
const scoredMemories = memories.map(memory => {
const scoreResult = calculateRelevanceScore(memory, projectContext, options);
return {
...memory,
relevanceScore: scoreResult.finalScore,
scoreBreakdown: scoreResult.breakdown,
hasConversationContext: scoreResult.hasConversationContext
};
});
// Sort by relevance score (highest first)
const sortedMemories = scoredMemories.sort((a, b) => b.relevanceScore - a.relevanceScore);
// Log scoring results for debugging
if (verbose) {
console.log('[Memory Scorer] Top scored memories:');
sortedMemories.slice(0, 3).forEach((memory, index) => {
console.log(` ${index + 1}. Score: ${memory.relevanceScore.toFixed(3)} - ${memory.content.substring(0, 60)}...`);
});
}
return sortedMemories;
} catch (error) {
if (verbose) console.error('[Memory Scorer] Error scoring memories:', error.message);
return memories || [];
}
}
/**
* Filter memories by minimum relevance threshold
*/
function filterByRelevance(memories, minScore = 0.3, options = {}) {
try {
const { verbose = true } = options;
const filtered = memories.filter(memory => memory.relevanceScore >= minScore);
if (verbose) {
console.log(`[Memory Scorer] Filtered ${filtered.length}/${memories.length} memories above threshold ${minScore}`);
}
return filtered;
} catch (error) {
if (verbose) console.warn('[Memory Scorer] Error filtering memories:', error.message);
return memories;
}
}
/**
* Analyze memory age distribution to detect staleness
* Returns statistics and recommended weight adjustments
*/
function analyzeMemoryAgeDistribution(memories, options = {}) {
try {
const { verbose = false } = options;
if (!Array.isArray(memories) || memories.length === 0) {
return {
avgAge: 0,
medianAge: 0,
p75Age: 0,
p90Age: 0,
recentCount: 0,
staleCount: 0,
isStale: false,
recommendedAdjustments: {}
};
}
const now = new Date();
// Calculate ages in days
const ages = memories.map(memory => {
// Handle both Unix timestamps (seconds) and ISO strings
let memoryTime;
if (memory.created_at_iso) {
memoryTime = new Date(memory.created_at_iso);
} else if (memory.created_at) {
// created_at is in seconds, convert to milliseconds
memoryTime = new Date(memory.created_at * 1000);
} else {
return 365; // Default to very old if no timestamp
}
if (isNaN(memoryTime.getTime())) return 365; // Default to very old
return (now - memoryTime) / (1000 * 60 * 60 * 24);
}).sort((a, b) => a - b);
// Calculate percentiles
const avgAge = ages.reduce((sum, age) => sum + age, 0) / ages.length;
const medianAge = ages[Math.floor(ages.length / 2)];
const p75Age = ages[Math.floor(ages.length * 0.75)];
const p90Age = ages[Math.floor(ages.length * 0.90)];
// Count recent vs stale
const recentCount = ages.filter(age => age <= 14).length; // Last 2 weeks
const staleCount = ages.filter(age => age > 30).length; // Older than 1 month
// Determine if memory set is stale
const isStale = medianAge > 30 || (recentCount / ages.length) < 0.2;
// Recommended adjustments based on staleness
const recommendedAdjustments = {};
if (isStale) {
// Memories are old - boost time decay weight, reduce tag relevance
recommendedAdjustments.timeDecay = 0.50; // Increase from default 0.25
recommendedAdjustments.tagRelevance = 0.20; // Decrease from default 0.35
recommendedAdjustments.recencyBonus = 0.25; // Increase bonus for any recent memories
recommendedAdjustments.reason = `Stale memory set detected (median: ${Math.round(medianAge)}d old, ${Math.round(recentCount/ages.length*100)}% recent)`;
} else if (avgAge < 14) {
// Memories are very recent - balanced approach
recommendedAdjustments.timeDecay = 0.30;
recommendedAdjustments.tagRelevance = 0.30;
recommendedAdjustments.reason = `Recent memory set (avg: ${Math.round(avgAge)}d old)`;
}
if (verbose) {
console.log('[Memory Age Analyzer]', {
avgAge: Math.round(avgAge),
medianAge: Math.round(medianAge),
p75Age: Math.round(p75Age),
recentPercent: Math.round(recentCount / ages.length * 100),
isStale,
adjustments: recommendedAdjustments.reason || 'No adjustments needed'
});
}
return {
avgAge,
medianAge,
p75Age,
p90Age,
recentCount,
staleCount,
totalCount: ages.length,
isStale,
recommendedAdjustments
};
} catch (error) {
if (verbose) console.error('[Memory Age Analyzer] Error:', error.message);
return {
avgAge: 0,
medianAge: 0,
p75Age: 0,
p90Age: 0,
recentCount: 0,
staleCount: 0,
isStale: false,
recommendedAdjustments: {}
};
}
}
/**
* Calculate adaptive git context weight based on memory age and git activity
* Prevents old git-related memories from dominating when recent development exists
*/
function calculateAdaptiveGitWeight(gitContext, memoryAgeAnalysis, configuredWeight = 1.2, options = {}) {
try {
const { verbose = false } = options;
// No git context or no recent commits - use configured weight
if (!gitContext || !gitContext.recentCommits || gitContext.recentCommits.length === 0) {
return { weight: configuredWeight, reason: 'No recent git activity' };
}
// Calculate days since most recent commit
const now = new Date();
const mostRecentCommit = new Date(gitContext.recentCommits[0].date);
const daysSinceLastCommit = (now - mostRecentCommit) / (1000 * 60 * 60 * 24);
// Scenario 1: Recent commits (< 7d) BUT stale memories (median > 30d)
// Problem: Git boost would amplify old git memories over potential recent work
if (daysSinceLastCommit <= 7 && memoryAgeAnalysis.medianAge > 30) {
const reducedWeight = Math.max(1.0, configuredWeight * 0.7); // Reduce by 30%
const reason = `Recent commits (${Math.round(daysSinceLastCommit)}d ago) but stale memories (median: ${Math.round(memoryAgeAnalysis.medianAge)}d) - reducing git boost`;
if (verbose) {
console.log(`[Adaptive Git Weight] ${reason}: ${configuredWeight.toFixed(1)} → ${reducedWeight.toFixed(1)}`);
}
return { weight: reducedWeight, reason, adjusted: true };
}
// Scenario 2: Both commits and memories are recent (< 14d)
// Safe to use configured weight, git context is relevant
if (daysSinceLastCommit <= 14 && memoryAgeAnalysis.avgAge <= 14) {
return {
weight: configuredWeight,
reason: `Recent commits and memories aligned (${Math.round(daysSinceLastCommit)}d commits, ${Math.round(memoryAgeAnalysis.avgAge)}d avg memory age)`,
adjusted: false
};
}
// Scenario 3: Old commits (> 14d) but recent memories exist
// Slightly reduce git weight to let recent non-git memories surface
if (daysSinceLastCommit > 14 && memoryAgeAnalysis.recentCount > 0) {
const reducedWeight = Math.max(1.0, configuredWeight * 0.85); // Reduce by 15%
const reason = `Older commits (${Math.round(daysSinceLastCommit)}d ago) with some recent memories - slightly reducing git boost`;
if (verbose) {
console.log(`[Adaptive Git Weight] ${reason}: ${configuredWeight.toFixed(1)} → ${reducedWeight.toFixed(1)}`);
}
return { weight: reducedWeight, reason, adjusted: true };
}
// Default: use configured weight
return { weight: configuredWeight, reason: 'Using configured weight', adjusted: false };
} catch (error) {
if (verbose) console.error('[Adaptive Git Weight] Error:', error.message);
return { weight: configuredWeight, reason: 'Error - using fallback', adjusted: false };
}
}
module.exports = {
scoreMemoryRelevance,
calculateRelevanceScore,
calculateTimeDecay,
calculateTagRelevance,
calculateContentRelevance,
calculateTypeBonus,
calculateRecencyBonus,
filterByRelevance,
analyzeMemoryAgeDistribution,
calculateAdaptiveGitWeight
};
// Direct execution support for testing
if (require.main === module) {
// Test with mock data
const mockProjectContext = {
name: 'mcp-memory-service',
language: 'JavaScript',
frameworks: ['Node.js'],
tools: ['npm']
};
const mockMemories = [
{
content: 'Decided to use SQLite-vec for better performance in MCP Memory Service',
tags: ['mcp-memory-service', 'decision', 'sqlite-vec'],
memory_type: 'decision',
created_at: '2025-08-19T10:00:00Z'
},
{
content: 'Fixed bug in JavaScript hook implementation for Claude Code integration',
tags: ['javascript', 'bug-fix', 'claude-code'],
memory_type: 'bug-fix',
created_at: '2025-08-18T15:30:00Z'
},
{
content: 'Random note about completely unrelated project',
tags: ['other-project', 'note'],
memory_type: 'note',
created_at: '2025-08-01T08:00:00Z'
}
];
console.log('\n=== MEMORY SCORING TEST ===');
const scored = scoreMemoryRelevance(mockMemories, mockProjectContext);
console.log('\n=== SCORED RESULTS ===');
scored.forEach((memory, index) => {
console.log(`${index + 1}. Score: ${memory.relevanceScore.toFixed(3)}`);
console.log(` Content: ${memory.content.substring(0, 80)}...`);
console.log(` Breakdown:`, memory.scoreBreakdown);
console.log('');
});
}