@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
JavaScript
/**
* 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