UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

537 lines (458 loc) 14.9 kB
/** * Context Injector * Story 10.3 - Parallel Agent Execution * * Injects rich context into subagent prompts including: * - Project context * - Relevant files * - Memory/patterns * - Gotchas * - Recent decisions */ const fs = require('fs'); const path = require('path'); // Import dependencies with fallbacks let MemoryQuery, GotchasMemory, SessionMemory; try { MemoryQuery = require('../memory/memory-query'); } catch { MemoryQuery = null; } try { GotchasMemory = require('../memory/gotchas-memory'); } catch { GotchasMemory = null; } try { SessionMemory = require('../memory/session-memory'); } catch { SessionMemory = null; } class ContextInjector { constructor(config = {}) { // Token budget for context (prevents overwhelming the LLM) this.tokenBudget = config.tokenBudget || 4000; // Approximate characters per token this.charsPerToken = config.charsPerToken || 4; // Cache TTL (5 minutes) this.cacheTTL = config.cacheTTL || 5 * 60 * 1000; // Context cache this.cache = new Map(); // Dependencies this.memoryQuery = config.memoryQuery || (MemoryQuery ? new MemoryQuery() : null); this.gotchasMemory = config.gotchasMemory || (GotchasMemory ? new GotchasMemory() : null); this.sessionMemory = config.sessionMemory || (SessionMemory ? new SessionMemory() : null); // Root path this.rootPath = config.rootPath || process.cwd(); // Metrics this.metrics = { injections: 0, cacheHits: 0, avgContextSize: 0, avgInjectionTime: 0, }; } /** * Inject context for a task * @param {Object} task - Task to inject context for * @param {Object} baseContext - Base context * @returns {Promise<string>} - Formatted context string */ async inject(task, _baseContext = {}) { const startTime = Date.now(); const cacheKey = this.getCacheKey(task); // Build injection payload const injection = { // 1. Task description (required) task: { id: task.id, description: task.description, acceptanceCriteria: task.acceptanceCriteria || [], verification: task.verification, }, // 2. Project context (from cache or fresh) project: await this.getProjectContext(cacheKey), // 3. Relevant files (task-specific) files: await this.getRelevantFiles(task), // 4. Memory context memory: await this.getRelevantMemory(task), // 5. Gotchas gotchas: await this.getRelevantGotchas(task), // 6. Recent decisions decisions: await this.getRecentDecisions(), }; // Format for LLM consumption const formatted = this.formatForLLM(injection); // Ensure within token budget const trimmed = this.trimToTokenBudget(formatted, this.tokenBudget); // Update metrics const injectionTime = Date.now() - startTime; this.updateMetrics(trimmed, injectionTime); return trimmed; } /** * Get cache key for a task * @param {Object} task - Task * @returns {string} - Cache key */ getCacheKey(task) { return `${task.type || 'default'}-${task.service || 'core'}`; } /** * Get cached value if valid * @param {string} key - Cache key * @returns {any|null} - Cached value or null */ getCached(key) { const cached = this.cache.get(key); if (!cached) return null; if (Date.now() - cached.timestamp > this.cacheTTL) { this.cache.delete(key); return null; } this.metrics.cacheHits++; return cached.value; } /** * Set cache value * @param {string} key - Cache key * @param {any} value - Value to cache */ setCache(key, value) { this.cache.set(key, { value, timestamp: Date.now(), }); } /** * Get project context * @param {string} cacheKey - Cache key for type-specific context * @returns {Promise<Object>} - Project context */ async getProjectContext(cacheKey) { // Check cache const cached = this.getCached(`project-${cacheKey}`); if (cached) return cached; const context = { name: 'unknown', framework: null, patterns: [], conventions: [], }; try { // Try to read codebase map const codebaseMapPath = path.join(this.rootPath, '.aios', 'codebase-map.json'); if (fs.existsSync(codebaseMapPath)) { const codebaseMap = JSON.parse(fs.readFileSync(codebaseMapPath, 'utf8')); context.name = codebaseMap.name || codebaseMap.projectName || 'project'; context.framework = codebaseMap.framework || codebaseMap.mainFramework; context.patterns = codebaseMap.patterns || []; context.entryPoints = codebaseMap.entryPoints || []; } // Try to read package.json const packagePath = path.join(this.rootPath, 'package.json'); if (fs.existsSync(packagePath)) { const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); context.name = pkg.name || context.name; context.version = pkg.version; context.dependencies = Object.keys(pkg.dependencies || {}).slice(0, 10); } // Try to detect conventions context.conventions = await this.detectConventions(); } catch (_error) { // Silently fail - context is optional enhancement } // Cache the result this.setCache(`project-${cacheKey}`, context); return context; } /** * Detect project conventions * @returns {Promise<Array>} - Detected conventions */ async detectConventions() { const conventions = []; try { // Check for TypeScript if (fs.existsSync(path.join(this.rootPath, 'tsconfig.json'))) { conventions.push('TypeScript project'); } // Check for ESLint const eslintFiles = ['.eslintrc.js', '.eslintrc.json', '.eslintrc.yaml']; if (eslintFiles.some((f) => fs.existsSync(path.join(this.rootPath, f)))) { conventions.push('Uses ESLint'); } // Check for Prettier if (fs.existsSync(path.join(this.rootPath, '.prettierrc'))) { conventions.push('Uses Prettier'); } // Check for tests directory if (fs.existsSync(path.join(this.rootPath, 'tests'))) { conventions.push('Tests in /tests directory'); } else if (fs.existsSync(path.join(this.rootPath, '__tests__'))) { conventions.push('Tests in /__tests__ directory'); } } catch (_error) { // Ignore errors } return conventions; } /** * Get relevant files for a task * @param {Object} task - Task * @returns {Promise<Array>} - Relevant files */ async getRelevantFiles(task) { const files = []; // Add explicitly specified files if (task.files) { for (const file of task.files) { files.push({ path: file, purpose: 'Specified in task', exists: fs.existsSync(path.join(this.rootPath, file)), }); } } // Infer files from task description const inferredFiles = this.inferFilesFromDescription(task.description); for (const file of inferredFiles) { if (!files.some((f) => f.path === file)) { files.push({ path: file, purpose: 'Inferred from description', exists: fs.existsSync(path.join(this.rootPath, file)), }); } } return files.slice(0, 10); // Limit to 10 files } /** * Infer files from task description * @param {string} description - Task description * @returns {Array<string>} - Inferred file paths */ inferFilesFromDescription(description) { if (!description) return []; const files = []; // Match file paths in backticks const backtickMatches = description.match(/`([^`]+\.[a-zA-Z]+)`/g) || []; for (const match of backtickMatches) { files.push(match.replace(/`/g, '')); } // Match quoted paths const quotedMatches = description.match(/['"]([^'"]+\.[a-zA-Z]+)['"]/g) || []; for (const match of quotedMatches) { files.push(match.replace(/['"]/g, '')); } return [...new Set(files)]; } /** * Get relevant memory for a task * @param {Object} task - Task * @returns {Promise<Array>} - Relevant memory entries */ async getRelevantMemory(task) { if (!this.memoryQuery) return []; try { const query = task.description || task.id; const results = await this.memoryQuery.query(query, { limit: 5 }); return results.map((r) => ({ type: r.type, content: r.content || r.summary, relevance: r.score || r.relevance, })); } catch (_error) { return []; } } /** * Get relevant gotchas for a task * @param {Object} task - Task * @returns {Promise<Array>} - Relevant gotchas */ async getRelevantGotchas(task) { if (!this.gotchasMemory) return []; try { return await this.gotchasMemory.getContextForTask(task.description || task.id); } catch (_error) { return []; } } /** * Get recent decisions from session memory * @returns {Promise<Array>} - Recent decisions */ async getRecentDecisions() { if (!this.sessionMemory) return []; try { const decisions = await this.sessionMemory.getDecisions({ limit: 5 }); return decisions.map((d) => ({ decision: d.decision || d.content, reason: d.reason, timestamp: d.timestamp, })); } catch (_error) { return []; } } /** * Format injection payload for LLM consumption * @param {Object} injection - Injection payload * @returns {string} - Formatted markdown */ formatForLLM(injection) { let output = ''; // Task section (always included) output += '## Task Context\n\n'; output += '### Current Task\n'; output += `**ID:** ${injection.task.id}\n`; output += `**Description:** ${injection.task.description}\n\n`; // Acceptance Criteria if (injection.task.acceptanceCriteria && injection.task.acceptanceCriteria.length > 0) { output += '### Acceptance Criteria\n'; const criteria = Array.isArray(injection.task.acceptanceCriteria) ? injection.task.acceptanceCriteria : [injection.task.acceptanceCriteria]; criteria.forEach((ac, i) => { output += `${i + 1}. ${ac}\n`; }); output += '\n'; } // Relevant Files if (injection.files && injection.files.length > 0) { output += '### Relevant Files\n'; injection.files.forEach((f) => { const status = f.exists ? '✓' : '?'; output += `- [${status}] \`${f.path}\`: ${f.purpose}\n`; }); output += '\n'; } // Project Patterns if (injection.project.patterns && injection.project.patterns.length > 0) { output += '### Project Patterns\n'; injection.project.patterns.slice(0, 5).forEach((p) => { const name = typeof p === 'string' ? p : p.name; output += `- ${name}\n`; }); output += '\n'; } // Conventions if (injection.project.conventions && injection.project.conventions.length > 0) { output += '### Conventions\n'; injection.project.conventions.forEach((c) => { output += `- ${c}\n`; }); output += '\n'; } // Active Gotchas if (injection.gotchas && injection.gotchas.length > 0) { output += '### Active Gotchas (Avoid These)\n'; injection.gotchas.slice(0, 5).forEach((g) => { const title = g.title || g.pattern || 'Warning'; const workaround = g.workaround || g.description || 'Be careful'; output += `⚠️ **${title}**: ${workaround}\n\n`; }); } // Recent Decisions if (injection.decisions && injection.decisions.length > 0) { output += '### Recent Decisions\n'; injection.decisions.slice(0, 3).forEach((d) => { output += `- ${d.decision}`; if (d.reason) output += ` (${d.reason})`; output += '\n'; }); output += '\n'; } // Relevant Memory if (injection.memory && injection.memory.length > 0) { output += '### Relevant Context from Memory\n'; injection.memory.slice(0, 3).forEach((m) => { output += `- [${m.type}] ${m.content}\n`; }); output += '\n'; } return output; } /** * Trim content to fit token budget * @param {string} content - Content to trim * @param {number} tokenBudget - Maximum tokens * @returns {string} - Trimmed content */ trimToTokenBudget(content, tokenBudget) { const maxChars = tokenBudget * this.charsPerToken; if (content.length <= maxChars) { return content; } // Try to trim intelligently by sections const sections = content.split(/(?=###)/); let trimmed = ''; // Always include the task section if (sections[0]) { trimmed = sections[0]; } // Add other sections until budget is reached for (let i = 1; i < sections.length; i++) { if (trimmed.length + sections[i].length <= maxChars) { trimmed += sections[i]; } } // If still too long, hard truncate if (trimmed.length > maxChars) { trimmed = trimmed.substring(0, maxChars - 50) + '\n\n[Context truncated for token budget]\n'; } return trimmed; } /** * Update metrics * @param {string} context - Generated context * @param {number} time - Injection time */ updateMetrics(context, time) { this.metrics.injections++; this.metrics.avgContextSize = (this.metrics.avgContextSize * (this.metrics.injections - 1) + context.length) / this.metrics.injections; this.metrics.avgInjectionTime = (this.metrics.avgInjectionTime * (this.metrics.injections - 1) + time) / this.metrics.injections; } /** * Get metrics * @returns {Object} - Current metrics */ getMetrics() { return { ...this.metrics, avgContextSize: Math.round(this.metrics.avgContextSize), avgInjectionTime: Math.round(this.metrics.avgInjectionTime), cacheSize: this.cache.size, }; } /** * Clear cache */ clearCache() { this.cache.clear(); } /** * Format status for CLI * @returns {string} - Formatted status */ formatStatus() { const metrics = this.getMetrics(); let output = '💉 Context Injector Status\n'; output += '━'.repeat(40) + '\n\n'; output += '**Metrics:**\n'; output += ` Total Injections: ${metrics.injections}\n`; output += ` Cache Hits: ${metrics.cacheHits}\n`; output += ` Cache Size: ${metrics.cacheSize}\n`; output += ` Avg Context Size: ${metrics.avgContextSize} chars\n`; output += ` Avg Injection Time: ${metrics.avgInjectionTime}ms\n`; output += ` Token Budget: ${this.tokenBudget}\n`; return output; } } module.exports = ContextInjector; module.exports.ContextInjector = ContextInjector;