UNPKG

@jmkim85/dev-flow-mcp

Version:

MCP-based Dev Flow - AI-powered development workflow management with 13 essential tools for TDD and context management

366 lines 14.9 kB
/** * Unified Context Manager for Dev Flow MCP v2.1 * Combines functionality from ContextManager, ContextPreserver, and EnhancedContextManager */ import { promises as fs } from 'fs'; import { join } from 'path'; import YAML from 'yaml'; import { createHash } from 'crypto'; export class UnifiedContextManager { projectRoot; contextDir; maxFrames = 5; // Reduced from 10 maxFactsPerFrame = 10; // Reduced from 20 maxEvents = 15; // Reduced from 20 maxContextLength = 5000; // Target: 5K tokens (≈20K chars) maxTokens = 1250; // 5K tokens limit constructor(projectRoot) { this.projectRoot = projectRoot; this.contextDir = join(projectRoot, '.devflow', 'context'); } // Core frame management async createFrame(taskId, stage) { const state = await this.loadContextState(); const frame = { id: this.generateFrameId(), task_id: taskId, stage, summary: '', key_facts: [], decisions_made: [], open_questions: [], files_touched: [], created_at: new Date().toISOString(), parent_frame: state.active_frame_id || undefined, token_usage: 0, importance_score: 1.0 }; // Inherit only critical facts from parent if (state.active_frame_id) { const parentFrame = state.frames.find(f => f.id === state.active_frame_id); if (parentFrame) { frame.key_facts = this.selectCriticalFacts(parentFrame.key_facts); } } state.frames.push(frame); state.active_frame_id = frame.id; // Aggressive pruning for simplicity if (state.frames.length > this.maxFrames) { state.frames = state.frames.slice(-this.maxFrames); } await this.saveContextState(state); return frame; } async updateFrame(updates) { const state = await this.loadContextState(); if (!state.active_frame_id) { throw new Error('No active context frame'); } const frameIndex = state.frames.findIndex(f => f.id === state.active_frame_id); if (frameIndex === -1) { throw new Error('Active frame not found'); } state.frames[frameIndex] = { ...state.frames[frameIndex], ...updates, key_facts: this.limitFacts(updates.key_facts || state.frames[frameIndex].key_facts), id: state.frames[frameIndex].id, created_at: state.frames[frameIndex].created_at }; await this.saveContextState(state); } async addFact(fact, isGlobal = false) { const state = await this.loadContextState(); if (isGlobal) { if (!state.global_facts.includes(fact)) { state.global_facts.push(fact); // Keep only recent global facts if (state.global_facts.length > 10) { state.global_facts = state.global_facts.slice(-10); } } } else if (state.active_frame_id) { const frame = state.frames.find(f => f.id === state.active_frame_id); if (frame && !frame.key_facts.includes(fact)) { frame.key_facts.push(fact); frame.key_facts = this.limitFacts(frame.key_facts); } } await this.saveContextState(state); } async addEvent(event) { const state = await this.loadContextState(); state.recent_events.push(event); // Keep only recent events if (state.recent_events.length > this.maxEvents) { state.recent_events = state.recent_events.slice(-this.maxEvents); } await this.saveContextState(state); } // Unified context building (replaces buildContext and buildCompactContext) async buildContext(taskId) { const state = await this.loadContextState(); if (!state.active_frame_id) { return this.buildBasicContext(taskId); } const activeFrame = state.frames.find(f => f.id === state.active_frame_id); if (!activeFrame) { return this.buildBasicContext(taskId); } let context = `# Current Context\n\n`; context += `## Task: ${activeFrame.task_id} - Stage: ${activeFrame.stage}\n\n`; // Add metadata information if (activeFrame.token_usage !== undefined || activeFrame.importance_score !== undefined) { context += `### Context Metadata\n`; if (activeFrame.token_usage !== undefined) { context += `- Token Usage: ${activeFrame.token_usage}\n`; } if (activeFrame.importance_score !== undefined) { context += `- Importance Score: ${activeFrame.importance_score.toFixed(2)}\n`; } context += '\n'; } if (activeFrame.summary) { context += `### Summary\n${activeFrame.summary}\n\n`; } // Global facts (most important) - with compression if (state.global_facts.length > 0) { context += `### Global Facts\n`; const compressedGlobalFacts = this.compressFacts(state.global_facts, 5); compressedGlobalFacts.forEach(fact => { context += `- ${fact}\n`; }); context += '\n'; } // Key facts from current frame - with compression if (activeFrame.key_facts.length > 0) { context += `### Key Facts\n`; const compressedKeyFacts = this.compressFacts(activeFrame.key_facts, 6); compressedKeyFacts.forEach(fact => { context += `- ${fact}\n`; }); context += '\n'; } // Recent decisions if (activeFrame.decisions_made.length > 0) { context += `### Recent Decisions\n`; activeFrame.decisions_made.slice(-3).forEach(decision => { context += `- ${decision}\n`; }); context += '\n'; } // Open questions if (activeFrame.open_questions.length > 0) { context += `### Open Questions\n`; activeFrame.open_questions.slice(-3).forEach(question => { context += `- ${question}\n`; }); context += '\n'; } // Recent events (filtered for relevance) const relevantEvents = state.recent_events .filter(e => e.task_id === taskId) .slice(-3); if (relevantEvents.length > 0) { context += `### Recent Activity\n`; relevantEvents.forEach(event => { context += `- ${event.description}\n`; }); } return this.truncateContext(context); } async buildBasicContext(taskId) { try { const tasksPath = join(this.projectRoot, '.devflow', 'tasks.yaml'); const data = await fs.readFile(tasksPath, 'utf-8'); const parsed = YAML.parse(data); const tasks = parsed.tasks || []; const task = tasks.find((t) => t.id === taskId); if (!task) { return `# Context\n\nNo context available for task: ${taskId}`; } return `# Context\n\n## Task: ${task.title}\n\n**Description**: ${task.description}\n\n**Success Criteria**: ${task.success_criteria}`; } catch { return `# Context\n\nNo context available for task: ${taskId}`; } } async recordDecision(decision) { const state = await this.loadContextState(); if (!state.active_frame_id) return; const frame = state.frames.find(f => f.id === state.active_frame_id); if (!frame) return; frame.decisions_made.push(decision); // Keep only recent decisions if (frame.decisions_made.length > 5) { frame.decisions_made = frame.decisions_made.slice(-5); } await this.saveContextState(state); } async addQuestion(question) { const state = await this.loadContextState(); if (!state.active_frame_id) return; const frame = state.frames.find(f => f.id === state.active_frame_id); if (!frame) return; if (!frame.open_questions.includes(question)) { frame.open_questions.push(question); // Keep only recent questions if (frame.open_questions.length > 3) { frame.open_questions = frame.open_questions.slice(-3); } } await this.saveContextState(state); } async updateTokenUsage(tokens) { const state = await this.loadContextState(); if (!state.active_frame_id) return; const frame = state.frames.find(f => f.id === state.active_frame_id); if (!frame) return; frame.token_usage = (frame.token_usage || 0) + tokens; await this.saveContextState(state); } async updateImportanceScore(score) { const state = await this.loadContextState(); if (!state.active_frame_id) return; const frame = state.frames.find(f => f.id === state.active_frame_id); if (!frame) return; frame.importance_score = Math.max(0, Math.min(10, score)); // Clamp between 0-10 await this.saveContextState(state); } // Private helper methods async loadContextState() { try { const statePath = join(this.contextDir, 'context-state.yaml'); const data = await fs.readFile(statePath, 'utf-8'); return YAML.parse(data); } catch { return { frames: [], active_frame_id: null, global_facts: [], mistake_patterns: [], recent_events: [] }; } } async saveContextState(state) { await fs.mkdir(this.contextDir, { recursive: true }); const statePath = join(this.contextDir, 'context-state.yaml'); await fs.writeFile(statePath, YAML.stringify(state, null, 2)); } generateFrameId() { return createHash('sha256') .update(Date.now().toString() + Math.random().toString()) .digest('hex') .substring(0, 8); // Shorter IDs } selectCriticalFacts(facts) { // Only inherit facts with critical keywords const criticalKeywords = ['error', 'failed', 'must', 'required', 'decided']; return facts.filter(fact => criticalKeywords.some(keyword => fact.toLowerCase().includes(keyword))).slice(0, 3); // Max 3 inherited facts } calculateFactImportance(fact) { // Calculate importance score based on keywords and patterns let score = 1.0; // Critical keywords (high importance) const criticalKeywords = ['error', 'failed', 'bug', 'issue', 'problem', 'must', 'required', 'decided', 'breaking']; const mediumKeywords = ['should', 'important', 'note', 'remember', 'warning']; const lowKeywords = ['maybe', 'consider', 'might', 'could', 'possibly']; if (criticalKeywords.some(keyword => fact.toLowerCase().includes(keyword))) { score += 2.0; } else if (mediumKeywords.some(keyword => fact.toLowerCase().includes(keyword))) { score += 1.0; } else if (lowKeywords.some(keyword => fact.toLowerCase().includes(keyword))) { score -= 0.5; } // Length penalty (very long facts are less important for quick context) if (fact.length > 200) { score -= 0.5; } else if (fact.length > 100) { score -= 0.2; } // Code/technical content bonus if (fact.includes('()') || fact.includes('{}') || fact.includes('[]')) { score += 0.3; } return Math.max(0.1, score); } compressFacts(facts, maxFacts = 8) { if (facts.length <= maxFacts) { return facts; } // Calculate importance for each fact const factsWithImportance = facts.map((fact, index) => ({ fact, importance: this.calculateFactImportance(fact), age: facts.length - index // Newer facts have lower age })); // Sort by importance (descending) and age (ascending for tie-breaking) factsWithImportance.sort((a, b) => { if (Math.abs(a.importance - b.importance) < 0.1) { return a.age - b.age; // Prefer newer facts for same importance } return b.importance - a.importance; }); // Take top important facts const selectedFacts = factsWithImportance.slice(0, maxFacts - 1); // Add compression indicator if we removed facts const compressedCount = facts.length - selectedFacts.length; if (compressedCount > 0) { selectedFacts.push({ fact: `… [${compressedCount} older/less important facts compressed]`, importance: 0, age: 999 }); } // Sort back to maintain some chronological order for readability return selectedFacts .sort((a, b) => a.age - b.age) .map(item => item.fact); } limitFacts(facts) { return facts.slice(-this.maxFactsPerFrame); } truncateContext(context) { // Estimate token usage (rough approximation: 1 token ≈ 4 characters) const estimatedTokens = Math.ceil(context.length / 4); // Update token usage for active frame this.updateTokenUsage(estimatedTokens).catch(() => { // Silently handle errors to avoid breaking context building }); // Strict 5K token limit enforcement if (estimatedTokens <= this.maxTokens && context.length <= this.maxContextLength) { return context; } // Aggressive truncation to stay within 5K token limit const lines = context.split('\n'); let truncated = ''; let currentTokens = 0; const maxAllowedTokens = this.maxTokens - 50; // Reserve 50 tokens for truncation message for (const line of lines) { const lineTokens = Math.ceil(line.length / 4); if (currentTokens + lineTokens > maxAllowedTokens) { const remainingTokens = this.maxTokens - currentTokens; truncated += `\n\n*[Context truncated - ${estimatedTokens - currentTokens} tokens removed to stay within 5K limit]*`; break; } truncated += line + '\n'; currentTokens += lineTokens; } return truncated; } } //# sourceMappingURL=unified-context-manager.js.map