diff --git a/.aios-core/core/memory/reflection-engine.js b/.aios-core/core/memory/reflection-engine.js new file mode 100644 index 000000000..f05eaf1c8 --- /dev/null +++ b/.aios-core/core/memory/reflection-engine.js @@ -0,0 +1,538 @@ +#!/usr/bin/env node + +/** + * AIOX Agent Reflection Engine + * + * Story: 9.6 - Agent Reflection Engine + * Epic: Epic 9 - Persistent Memory Layer + * + * Enables agents to reflect on past executions, extract lessons, + * and autonomously improve their strategies over time. + * + * Features: + * - AC1: reflection-engine.js in .aios-core/core/memory/ + * - AC2: Persists in .aiox/reflections.json + * - AC3: Records execution reflections with outcome, duration, strategy used + * - AC4: Extracts recurring patterns from reflections (success/failure clusters) + * - AC5: Recommends strategies before similar tasks based on historical outcomes + * - AC6: Tracks performance trends per agent, task type, and strategy + * - AC7: Injects relevant reflections as context before task execution + * - AC8: Prunes stale reflections beyond retention window + * + * @author @dev (Dex) + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +const CONFIG = { + reflectionsPath: '.aiox/reflections.json', + maxReflections: 500, + retentionDays: 90, + minReflectionsForPattern: 3, + maxRecommendations: 5, + similarityThreshold: 0.3, + version: '1.0.0', + schemaVersion: 'aiox-reflections-v1', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const Outcome = { + SUCCESS: 'success', + PARTIAL: 'partial', + FAILURE: 'failure', + ABORTED: 'aborted', +}; + +const TaskType = { + IMPLEMENTATION: 'implementation', + DEBUGGING: 'debugging', + REFACTORING: 'refactoring', + TESTING: 'testing', + REVIEW: 'review', + ARCHITECTURE: 'architecture', + DEPLOYMENT: 'deployment', + RESEARCH: 'research', + GENERAL: 'general', +}; + +const Events = { + REFLECTION_RECORDED: 'reflection:recorded', + PATTERN_DETECTED: 'pattern:detected', + STRATEGY_RECOMMENDED: 'strategy:recommended', + REFLECTIONS_PRUNED: 'reflections:pruned', + TREND_SHIFT: 'trend:shift', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// REFLECTION ENGINE +// ═══════════════════════════════════════════════════════════════════════════════════ + +class ReflectionEngine extends EventEmitter { + constructor(options = {}) { + super(); + this.projectRoot = options.projectRoot || process.cwd(); + this.config = { ...CONFIG, ...options.config }; + this.reflections = []; + this.patterns = []; + this._loaded = false; + } + + /** + * Get the reflections file path + */ + _getFilePath() { + return path.join(this.projectRoot, this.config.reflectionsPath); + } + + /** + * Load reflections from disk + */ + async load() { + const filePath = this._getFilePath(); + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + + if (data.schemaVersion !== this.config.schemaVersion) { + this.reflections = []; + this.patterns = []; + this._loaded = true; + return; + } + + this.reflections = Array.isArray(data.reflections) ? data.reflections : []; + this.patterns = Array.isArray(data.patterns) ? data.patterns : []; + } + } catch { + this.reflections = []; + this.patterns = []; + } + this._loaded = true; + } + + /** + * Save reflections to disk + */ + async save() { + const filePath = this._getFilePath(); + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data = { + schemaVersion: this.config.schemaVersion, + version: this.config.version, + savedAt: new Date().toISOString(), + reflections: this.reflections, + patterns: this.patterns, + }; + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + } + + /** + * Record a new reflection after task execution + * + * @param {Object} reflection + * @param {string} reflection.taskType - Type of task (from TaskType enum) + * @param {string} reflection.agentId - Agent that executed the task + * @param {string} reflection.outcome - Outcome (from Outcome enum) + * @param {string} reflection.strategy - Strategy used (free text) + * @param {string} reflection.description - What was attempted + * @param {string[]} [reflection.tags] - Searchable tags + * @param {number} [reflection.durationMs] - Execution time in ms + * @param {string} [reflection.lesson] - Key lesson learned + * @param {string} [reflection.context] - Additional context + * @returns {Object} The recorded reflection with generated ID + */ + recordReflection(reflection) { + if (!reflection.taskType || !reflection.agentId || !reflection.outcome || !reflection.strategy) { + throw new Error('Required fields: taskType, agentId, outcome, strategy'); + } + + const entry = { + id: this._generateId(), + taskType: reflection.taskType, + agentId: reflection.agentId, + outcome: reflection.outcome, + strategy: reflection.strategy, + description: reflection.description || '', + tags: reflection.tags || [], + durationMs: reflection.durationMs ?? null, + lesson: reflection.lesson ?? null, + context: reflection.context ?? null, + createdAt: new Date().toISOString(), + }; + + this.reflections.push(entry); + + // Enforce max reflections + if (this.reflections.length > this.config.maxReflections) { + const removed = this.reflections.shift(); + this.emit(Events.REFLECTIONS_PRUNED, { count: 1, reason: 'max_limit', removed: [removed.id] }); + this._recomputePatterns(); + } + + // Check for new patterns + this._detectPatterns(entry); + + this.emit(Events.REFLECTION_RECORDED, entry); + return entry; + } + + /** + * Get strategy recommendations for a given task context + * + * @param {Object} context + * @param {string} context.taskType - Type of upcoming task + * @param {string} [context.agentId] - Agent that will execute + * @param {string[]} [context.tags] - Relevant tags + * @returns {Object[]} Ranked strategy recommendations + */ + getRecommendations(context) { + if (!context.taskType) { + return []; + } + + // Find relevant reflections + const relevant = this.reflections.filter((r) => { + if (r.taskType !== context.taskType) return false; + if (context.agentId && r.agentId !== context.agentId) return false; + return true; + }); + + if (relevant.length === 0) return []; + + // Boost by tag overlap + const scored = relevant.map((r) => { + let score = r.outcome === Outcome.SUCCESS ? 1.0 : r.outcome === Outcome.PARTIAL ? 0.5 : 0.0; + if (context.tags && r.tags) { + const overlap = context.tags.filter((t) => r.tags.includes(t)).length; + score += overlap * 0.2; + } + // Time decay: newer reflections are more relevant + const ageMs = Date.now() - new Date(r.createdAt).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const decay = Math.max(0.1, 1.0 - ageDays / this.config.retentionDays); + score *= decay; + return { reflection: r, score }; + }); + + // Group by strategy and aggregate scores + const strategyMap = new Map(); + for (const { reflection, score } of scored) { + const key = reflection.strategy; + if (!strategyMap.has(key)) { + strategyMap.set(key, { + strategy: key, + totalScore: 0, + count: 0, + successes: 0, + failures: 0, + lessons: [], + avgDurationMs: null, + durations: [], + }); + } + const entry = strategyMap.get(key); + entry.totalScore += score; + entry.count += 1; + if (reflection.outcome === Outcome.SUCCESS) entry.successes++; + if (reflection.outcome === Outcome.FAILURE) entry.failures++; + if (reflection.lesson) entry.lessons.push(reflection.lesson); + if (reflection.durationMs) entry.durations.push(reflection.durationMs); + } + + // Calculate averages and sort + const recommendations = Array.from(strategyMap.values()) + .map((s) => { + s.successRate = s.count > 0 ? s.successes / s.count : 0; + s.avgScore = s.count > 0 ? s.totalScore / s.count : 0; + if (s.durations.length > 0) { + s.avgDurationMs = Math.round(s.durations.reduce((a, b) => a + b, 0) / s.durations.length); + } + delete s.durations; + return s; + }) + .sort((a, b) => b.avgScore - a.avgScore) + .slice(0, this.config.maxRecommendations); + + if (recommendations.length > 0) { + this.emit(Events.STRATEGY_RECOMMENDED, { + taskType: context.taskType, + topStrategy: recommendations[0].strategy, + count: recommendations.length, + }); + } + + return recommendations; + } + + /** + * Inject reflection context before task execution + * + * @param {Object} context - Task context (taskType, agentId, tags) + * @returns {Object} Context with injected reflections + */ + injectContext(context) { + const recommendations = this.getRecommendations(context); + const relevantPatterns = this.patterns.filter( + (p) => p.taskType === context.taskType || (p.tags && context.tags && context.tags.some((t) => p.tags.includes(t))), + ); + + return { + ...context, + reflections: { + recommendations, + patterns: relevantPatterns, + totalReflections: this.reflections.filter((r) => r.taskType === context.taskType).length, + }, + }; + } + + /** + * Get performance trends for an agent or task type + * + * @param {Object} filter + * @param {string} [filter.agentId] + * @param {string} [filter.taskType] + * @param {number} [filter.windowDays=30] + * @returns {Object} Performance trend data + */ + getTrends(filter = {}) { + const windowDays = filter.windowDays || 30; + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + + const relevant = this.reflections.filter((r) => { + if (filter.agentId && r.agentId !== filter.agentId) return false; + if (filter.taskType && r.taskType !== filter.taskType) return false; + return new Date(r.createdAt).getTime() >= cutoff; + }); + + if (relevant.length === 0) { + return { total: 0, successRate: 0, avgDurationMs: null, trend: 'insufficient_data' }; + } + + const successes = relevant.filter((r) => r.outcome === Outcome.SUCCESS).length; + const durations = relevant.filter((r) => r.durationMs).map((r) => r.durationMs); + + // Split into halves for trend detection + const mid = Math.floor(relevant.length / 2); + const firstHalf = relevant.slice(0, mid); + const secondHalf = relevant.slice(mid); + + const firstRate = + firstHalf.length > 0 + ? firstHalf.filter((r) => r.outcome === Outcome.SUCCESS).length / firstHalf.length + : 0; + const secondRate = + secondHalf.length > 0 + ? secondHalf.filter((r) => r.outcome === Outcome.SUCCESS).length / secondHalf.length + : 0; + + let trend = 'stable'; + if (secondRate - firstRate > 0.15) trend = 'improving'; + else if (firstRate - secondRate > 0.15) trend = 'declining'; + + return { + total: relevant.length, + successes, + successRate: successes / relevant.length, + avgDurationMs: + durations.length > 0 + ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) + : null, + trend, + firstHalfRate: firstRate, + secondHalfRate: secondRate, + }; + } + + /** + * Prune reflections older than retention window + * @returns {number} Number of pruned reflections + */ + prune() { + const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; + const before = this.reflections.length; + const removed = []; + + this.reflections = this.reflections.filter((r) => { + const keep = new Date(r.createdAt).getTime() >= cutoff; + if (!keep) removed.push(r.id); + return keep; + }); + + const pruned = before - this.reflections.length; + if (pruned > 0) { + this._recomputePatterns(); + this.emit(Events.REFLECTIONS_PRUNED, { count: pruned, reason: 'retention_window', removed }); + } + return pruned; + } + + /** + * Get statistics summary + */ + getStats() { + const byOutcome = {}; + const byTaskType = {}; + const byAgent = {}; + + for (const r of this.reflections) { + byOutcome[r.outcome] = (byOutcome[r.outcome] || 0) + 1; + byTaskType[r.taskType] = (byTaskType[r.taskType] || 0) + 1; + byAgent[r.agentId] = (byAgent[r.agentId] || 0) + 1; + } + + return { + totalReflections: this.reflections.length, + totalPatterns: this.patterns.length, + byOutcome, + byTaskType, + byAgent, + }; + } + + /** + * List reflections with optional filtering + */ + listReflections(filter = {}) { + let results = [...this.reflections]; + + if (filter.taskType) results = results.filter((r) => r.taskType === filter.taskType); + if (filter.agentId) results = results.filter((r) => r.agentId === filter.agentId); + if (filter.outcome) results = results.filter((r) => r.outcome === filter.outcome); + if (filter.tag) results = results.filter((r) => r.tags && r.tags.includes(filter.tag)); + + if (filter.limit) results = results.slice(-filter.limit); + + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Detect patterns from accumulated reflections + * @private + */ + _detectPatterns(newEntry) { + // Find reflections with same taskType and strategy + const similar = this.reflections.filter( + (r) => r.id !== newEntry.id && r.taskType === newEntry.taskType && r.strategy === newEntry.strategy, + ); + + if (similar.length < this.config.minReflectionsForPattern - 1) return; + + const all = [...similar, newEntry]; + const successes = all.filter((r) => r.outcome === Outcome.SUCCESS).length; + const successRate = successes / all.length; + + // Collect all tags + const tagSet = new Set(); + for (const r of all) { + if (r.tags) r.tags.forEach((t) => tagSet.add(t)); + } + + // Check if pattern already exists + const existingIdx = this.patterns.findIndex( + (p) => p.taskType === newEntry.taskType && p.strategy === newEntry.strategy, + ); + + const pattern = { + taskType: newEntry.taskType, + strategy: newEntry.strategy, + sampleSize: all.length, + successRate, + tags: Array.from(tagSet), + confidence: Math.min(1.0, all.length / 10), + verdict: successRate >= 0.7 ? 'recommended' : successRate <= 0.3 ? 'avoid' : 'neutral', + updatedAt: new Date().toISOString(), + }; + + if (existingIdx >= 0) { + this.patterns[existingIdx] = pattern; + } else { + this.patterns.push(pattern); + this.emit(Events.PATTERN_DETECTED, pattern); + } + } + + + /** + * Recompute all patterns from the current set of reflections. + * Called after pruning to ensure patterns stay consistent with remaining data. + * @private + */ + _recomputePatterns() { + // Group reflections by taskType + strategy + const groups = new Map(); + for (const r of this.reflections) { + const key = `${r.taskType}::${r.strategy}`; + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key).push(r); + } + + const newPatterns = []; + for (const [, group] of groups) { + if (group.length < this.config.minReflectionsForPattern) continue; + + const successes = group.filter((r) => r.outcome === Outcome.SUCCESS).length; + const successRate = successes / group.length; + + const tagSet = new Set(); + for (const r of group) { + if (r.tags) r.tags.forEach((t) => tagSet.add(t)); + } + + newPatterns.push({ + taskType: group[0].taskType, + strategy: group[0].strategy, + sampleSize: group.length, + successRate, + tags: Array.from(tagSet), + confidence: Math.min(1.0, group.length / 10), + verdict: successRate >= 0.7 ? 'recommended' : successRate <= 0.3 ? 'avoid' : 'neutral', + updatedAt: new Date().toISOString(), + }); + } + + this.patterns = newPatterns; + } + + /** + * Generate a unique ID + * @private + */ + _generateId() { + return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = ReflectionEngine; +module.exports.ReflectionEngine = ReflectionEngine; +module.exports.Outcome = Outcome; +module.exports.TaskType = TaskType; +module.exports.Events = Events; +module.exports.CONFIG = CONFIG; diff --git a/.aiox-core/core/execution/context-injector.js b/.aiox-core/core/execution/context-injector.js index 8be0c1f90..2f46a10d8 100644 --- a/.aiox-core/core/execution/context-injector.js +++ b/.aiox-core/core/execution/context-injector.js @@ -21,8 +21,9 @@ try { MemoryQuery = null; } try { - GotchasMemory = require('../memory/gotchas-memory'); -} catch { + ({ GotchasMemory } = require('../memory/gotchas-memory')); +} catch (error) { + console.warn('[ContextInjector] Failed to load GotchasMemory:', error.message); GotchasMemory = null; } try { diff --git a/.aiox-core/core/execution/subagent-dispatcher.js b/.aiox-core/core/execution/subagent-dispatcher.js index dc5bc2678..b4cc62407 100644 --- a/.aiox-core/core/execution/subagent-dispatcher.js +++ b/.aiox-core/core/execution/subagent-dispatcher.js @@ -28,8 +28,9 @@ try { MemoryQuery = null; } try { - GotchasMemory = require('../memory/gotchas-memory'); -} catch { + ({ GotchasMemory } = require('../memory/gotchas-memory')); +} catch (error) { + console.warn('[SubagentDispatcher] Failed to load GotchasMemory:', error.message); GotchasMemory = null; } diff --git a/.aiox-core/core/ideation/ideation-engine.js b/.aiox-core/core/ideation/ideation-engine.js index 1baea2c5f..6153fe71b 100644 --- a/.aiox-core/core/ideation/ideation-engine.js +++ b/.aiox-core/core/ideation/ideation-engine.js @@ -13,8 +13,9 @@ const { execSync } = require('child_process'); // Import dependencies with fallbacks let GotchasMemory; try { - GotchasMemory = require('../memory/gotchas-memory'); -} catch { + ({ GotchasMemory } = require('../memory/gotchas-memory')); +} catch (error) { + console.warn('[IdeationEngine] Failed to load GotchasMemory:', error.message); GotchasMemory = null; } diff --git a/.aiox-core/core/memory/reflection-engine.js b/.aiox-core/core/memory/reflection-engine.js new file mode 100644 index 000000000..f05eaf1c8 --- /dev/null +++ b/.aiox-core/core/memory/reflection-engine.js @@ -0,0 +1,538 @@ +#!/usr/bin/env node + +/** + * AIOX Agent Reflection Engine + * + * Story: 9.6 - Agent Reflection Engine + * Epic: Epic 9 - Persistent Memory Layer + * + * Enables agents to reflect on past executions, extract lessons, + * and autonomously improve their strategies over time. + * + * Features: + * - AC1: reflection-engine.js in .aios-core/core/memory/ + * - AC2: Persists in .aiox/reflections.json + * - AC3: Records execution reflections with outcome, duration, strategy used + * - AC4: Extracts recurring patterns from reflections (success/failure clusters) + * - AC5: Recommends strategies before similar tasks based on historical outcomes + * - AC6: Tracks performance trends per agent, task type, and strategy + * - AC7: Injects relevant reflections as context before task execution + * - AC8: Prunes stale reflections beyond retention window + * + * @author @dev (Dex) + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +const CONFIG = { + reflectionsPath: '.aiox/reflections.json', + maxReflections: 500, + retentionDays: 90, + minReflectionsForPattern: 3, + maxRecommendations: 5, + similarityThreshold: 0.3, + version: '1.0.0', + schemaVersion: 'aiox-reflections-v1', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const Outcome = { + SUCCESS: 'success', + PARTIAL: 'partial', + FAILURE: 'failure', + ABORTED: 'aborted', +}; + +const TaskType = { + IMPLEMENTATION: 'implementation', + DEBUGGING: 'debugging', + REFACTORING: 'refactoring', + TESTING: 'testing', + REVIEW: 'review', + ARCHITECTURE: 'architecture', + DEPLOYMENT: 'deployment', + RESEARCH: 'research', + GENERAL: 'general', +}; + +const Events = { + REFLECTION_RECORDED: 'reflection:recorded', + PATTERN_DETECTED: 'pattern:detected', + STRATEGY_RECOMMENDED: 'strategy:recommended', + REFLECTIONS_PRUNED: 'reflections:pruned', + TREND_SHIFT: 'trend:shift', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// REFLECTION ENGINE +// ═══════════════════════════════════════════════════════════════════════════════════ + +class ReflectionEngine extends EventEmitter { + constructor(options = {}) { + super(); + this.projectRoot = options.projectRoot || process.cwd(); + this.config = { ...CONFIG, ...options.config }; + this.reflections = []; + this.patterns = []; + this._loaded = false; + } + + /** + * Get the reflections file path + */ + _getFilePath() { + return path.join(this.projectRoot, this.config.reflectionsPath); + } + + /** + * Load reflections from disk + */ + async load() { + const filePath = this._getFilePath(); + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + + if (data.schemaVersion !== this.config.schemaVersion) { + this.reflections = []; + this.patterns = []; + this._loaded = true; + return; + } + + this.reflections = Array.isArray(data.reflections) ? data.reflections : []; + this.patterns = Array.isArray(data.patterns) ? data.patterns : []; + } + } catch { + this.reflections = []; + this.patterns = []; + } + this._loaded = true; + } + + /** + * Save reflections to disk + */ + async save() { + const filePath = this._getFilePath(); + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data = { + schemaVersion: this.config.schemaVersion, + version: this.config.version, + savedAt: new Date().toISOString(), + reflections: this.reflections, + patterns: this.patterns, + }; + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + } + + /** + * Record a new reflection after task execution + * + * @param {Object} reflection + * @param {string} reflection.taskType - Type of task (from TaskType enum) + * @param {string} reflection.agentId - Agent that executed the task + * @param {string} reflection.outcome - Outcome (from Outcome enum) + * @param {string} reflection.strategy - Strategy used (free text) + * @param {string} reflection.description - What was attempted + * @param {string[]} [reflection.tags] - Searchable tags + * @param {number} [reflection.durationMs] - Execution time in ms + * @param {string} [reflection.lesson] - Key lesson learned + * @param {string} [reflection.context] - Additional context + * @returns {Object} The recorded reflection with generated ID + */ + recordReflection(reflection) { + if (!reflection.taskType || !reflection.agentId || !reflection.outcome || !reflection.strategy) { + throw new Error('Required fields: taskType, agentId, outcome, strategy'); + } + + const entry = { + id: this._generateId(), + taskType: reflection.taskType, + agentId: reflection.agentId, + outcome: reflection.outcome, + strategy: reflection.strategy, + description: reflection.description || '', + tags: reflection.tags || [], + durationMs: reflection.durationMs ?? null, + lesson: reflection.lesson ?? null, + context: reflection.context ?? null, + createdAt: new Date().toISOString(), + }; + + this.reflections.push(entry); + + // Enforce max reflections + if (this.reflections.length > this.config.maxReflections) { + const removed = this.reflections.shift(); + this.emit(Events.REFLECTIONS_PRUNED, { count: 1, reason: 'max_limit', removed: [removed.id] }); + this._recomputePatterns(); + } + + // Check for new patterns + this._detectPatterns(entry); + + this.emit(Events.REFLECTION_RECORDED, entry); + return entry; + } + + /** + * Get strategy recommendations for a given task context + * + * @param {Object} context + * @param {string} context.taskType - Type of upcoming task + * @param {string} [context.agentId] - Agent that will execute + * @param {string[]} [context.tags] - Relevant tags + * @returns {Object[]} Ranked strategy recommendations + */ + getRecommendations(context) { + if (!context.taskType) { + return []; + } + + // Find relevant reflections + const relevant = this.reflections.filter((r) => { + if (r.taskType !== context.taskType) return false; + if (context.agentId && r.agentId !== context.agentId) return false; + return true; + }); + + if (relevant.length === 0) return []; + + // Boost by tag overlap + const scored = relevant.map((r) => { + let score = r.outcome === Outcome.SUCCESS ? 1.0 : r.outcome === Outcome.PARTIAL ? 0.5 : 0.0; + if (context.tags && r.tags) { + const overlap = context.tags.filter((t) => r.tags.includes(t)).length; + score += overlap * 0.2; + } + // Time decay: newer reflections are more relevant + const ageMs = Date.now() - new Date(r.createdAt).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const decay = Math.max(0.1, 1.0 - ageDays / this.config.retentionDays); + score *= decay; + return { reflection: r, score }; + }); + + // Group by strategy and aggregate scores + const strategyMap = new Map(); + for (const { reflection, score } of scored) { + const key = reflection.strategy; + if (!strategyMap.has(key)) { + strategyMap.set(key, { + strategy: key, + totalScore: 0, + count: 0, + successes: 0, + failures: 0, + lessons: [], + avgDurationMs: null, + durations: [], + }); + } + const entry = strategyMap.get(key); + entry.totalScore += score; + entry.count += 1; + if (reflection.outcome === Outcome.SUCCESS) entry.successes++; + if (reflection.outcome === Outcome.FAILURE) entry.failures++; + if (reflection.lesson) entry.lessons.push(reflection.lesson); + if (reflection.durationMs) entry.durations.push(reflection.durationMs); + } + + // Calculate averages and sort + const recommendations = Array.from(strategyMap.values()) + .map((s) => { + s.successRate = s.count > 0 ? s.successes / s.count : 0; + s.avgScore = s.count > 0 ? s.totalScore / s.count : 0; + if (s.durations.length > 0) { + s.avgDurationMs = Math.round(s.durations.reduce((a, b) => a + b, 0) / s.durations.length); + } + delete s.durations; + return s; + }) + .sort((a, b) => b.avgScore - a.avgScore) + .slice(0, this.config.maxRecommendations); + + if (recommendations.length > 0) { + this.emit(Events.STRATEGY_RECOMMENDED, { + taskType: context.taskType, + topStrategy: recommendations[0].strategy, + count: recommendations.length, + }); + } + + return recommendations; + } + + /** + * Inject reflection context before task execution + * + * @param {Object} context - Task context (taskType, agentId, tags) + * @returns {Object} Context with injected reflections + */ + injectContext(context) { + const recommendations = this.getRecommendations(context); + const relevantPatterns = this.patterns.filter( + (p) => p.taskType === context.taskType || (p.tags && context.tags && context.tags.some((t) => p.tags.includes(t))), + ); + + return { + ...context, + reflections: { + recommendations, + patterns: relevantPatterns, + totalReflections: this.reflections.filter((r) => r.taskType === context.taskType).length, + }, + }; + } + + /** + * Get performance trends for an agent or task type + * + * @param {Object} filter + * @param {string} [filter.agentId] + * @param {string} [filter.taskType] + * @param {number} [filter.windowDays=30] + * @returns {Object} Performance trend data + */ + getTrends(filter = {}) { + const windowDays = filter.windowDays || 30; + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + + const relevant = this.reflections.filter((r) => { + if (filter.agentId && r.agentId !== filter.agentId) return false; + if (filter.taskType && r.taskType !== filter.taskType) return false; + return new Date(r.createdAt).getTime() >= cutoff; + }); + + if (relevant.length === 0) { + return { total: 0, successRate: 0, avgDurationMs: null, trend: 'insufficient_data' }; + } + + const successes = relevant.filter((r) => r.outcome === Outcome.SUCCESS).length; + const durations = relevant.filter((r) => r.durationMs).map((r) => r.durationMs); + + // Split into halves for trend detection + const mid = Math.floor(relevant.length / 2); + const firstHalf = relevant.slice(0, mid); + const secondHalf = relevant.slice(mid); + + const firstRate = + firstHalf.length > 0 + ? firstHalf.filter((r) => r.outcome === Outcome.SUCCESS).length / firstHalf.length + : 0; + const secondRate = + secondHalf.length > 0 + ? secondHalf.filter((r) => r.outcome === Outcome.SUCCESS).length / secondHalf.length + : 0; + + let trend = 'stable'; + if (secondRate - firstRate > 0.15) trend = 'improving'; + else if (firstRate - secondRate > 0.15) trend = 'declining'; + + return { + total: relevant.length, + successes, + successRate: successes / relevant.length, + avgDurationMs: + durations.length > 0 + ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) + : null, + trend, + firstHalfRate: firstRate, + secondHalfRate: secondRate, + }; + } + + /** + * Prune reflections older than retention window + * @returns {number} Number of pruned reflections + */ + prune() { + const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; + const before = this.reflections.length; + const removed = []; + + this.reflections = this.reflections.filter((r) => { + const keep = new Date(r.createdAt).getTime() >= cutoff; + if (!keep) removed.push(r.id); + return keep; + }); + + const pruned = before - this.reflections.length; + if (pruned > 0) { + this._recomputePatterns(); + this.emit(Events.REFLECTIONS_PRUNED, { count: pruned, reason: 'retention_window', removed }); + } + return pruned; + } + + /** + * Get statistics summary + */ + getStats() { + const byOutcome = {}; + const byTaskType = {}; + const byAgent = {}; + + for (const r of this.reflections) { + byOutcome[r.outcome] = (byOutcome[r.outcome] || 0) + 1; + byTaskType[r.taskType] = (byTaskType[r.taskType] || 0) + 1; + byAgent[r.agentId] = (byAgent[r.agentId] || 0) + 1; + } + + return { + totalReflections: this.reflections.length, + totalPatterns: this.patterns.length, + byOutcome, + byTaskType, + byAgent, + }; + } + + /** + * List reflections with optional filtering + */ + listReflections(filter = {}) { + let results = [...this.reflections]; + + if (filter.taskType) results = results.filter((r) => r.taskType === filter.taskType); + if (filter.agentId) results = results.filter((r) => r.agentId === filter.agentId); + if (filter.outcome) results = results.filter((r) => r.outcome === filter.outcome); + if (filter.tag) results = results.filter((r) => r.tags && r.tags.includes(filter.tag)); + + if (filter.limit) results = results.slice(-filter.limit); + + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Detect patterns from accumulated reflections + * @private + */ + _detectPatterns(newEntry) { + // Find reflections with same taskType and strategy + const similar = this.reflections.filter( + (r) => r.id !== newEntry.id && r.taskType === newEntry.taskType && r.strategy === newEntry.strategy, + ); + + if (similar.length < this.config.minReflectionsForPattern - 1) return; + + const all = [...similar, newEntry]; + const successes = all.filter((r) => r.outcome === Outcome.SUCCESS).length; + const successRate = successes / all.length; + + // Collect all tags + const tagSet = new Set(); + for (const r of all) { + if (r.tags) r.tags.forEach((t) => tagSet.add(t)); + } + + // Check if pattern already exists + const existingIdx = this.patterns.findIndex( + (p) => p.taskType === newEntry.taskType && p.strategy === newEntry.strategy, + ); + + const pattern = { + taskType: newEntry.taskType, + strategy: newEntry.strategy, + sampleSize: all.length, + successRate, + tags: Array.from(tagSet), + confidence: Math.min(1.0, all.length / 10), + verdict: successRate >= 0.7 ? 'recommended' : successRate <= 0.3 ? 'avoid' : 'neutral', + updatedAt: new Date().toISOString(), + }; + + if (existingIdx >= 0) { + this.patterns[existingIdx] = pattern; + } else { + this.patterns.push(pattern); + this.emit(Events.PATTERN_DETECTED, pattern); + } + } + + + /** + * Recompute all patterns from the current set of reflections. + * Called after pruning to ensure patterns stay consistent with remaining data. + * @private + */ + _recomputePatterns() { + // Group reflections by taskType + strategy + const groups = new Map(); + for (const r of this.reflections) { + const key = `${r.taskType}::${r.strategy}`; + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key).push(r); + } + + const newPatterns = []; + for (const [, group] of groups) { + if (group.length < this.config.minReflectionsForPattern) continue; + + const successes = group.filter((r) => r.outcome === Outcome.SUCCESS).length; + const successRate = successes / group.length; + + const tagSet = new Set(); + for (const r of group) { + if (r.tags) r.tags.forEach((t) => tagSet.add(t)); + } + + newPatterns.push({ + taskType: group[0].taskType, + strategy: group[0].strategy, + sampleSize: group.length, + successRate, + tags: Array.from(tagSet), + confidence: Math.min(1.0, group.length / 10), + verdict: successRate >= 0.7 ? 'recommended' : successRate <= 0.3 ? 'avoid' : 'neutral', + updatedAt: new Date().toISOString(), + }); + } + + this.patterns = newPatterns; + } + + /** + * Generate a unique ID + * @private + */ + _generateId() { + return `ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = ReflectionEngine; +module.exports.ReflectionEngine = ReflectionEngine; +module.exports.Outcome = Outcome; +module.exports.TaskType = TaskType; +module.exports.Events = Events; +module.exports.CONFIG = CONFIG; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 2516e4c29..559e6267e 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-10T17:08:07.160Z" +generated_at: "2026-03-11T02:24:06.096Z" generator: scripts/generate-install-manifest.js file_count: 1089 files: @@ -409,9 +409,9 @@ files: type: core size: 48948 - path: core/execution/context-injector.js - hash: sha256:a183255eb4831f701086f23f371f94a9ce10c46ea18c8369ec0fd756777f042f + hash: sha256:b53f19422d4df0470852ed2a6015408d08dd9182998819667c96bd1c0be33c61 type: core - size: 14860 + size: 14956 - path: core/execution/parallel-executor.js hash: sha256:46870e5c8ff8db3ee0386e477d427cc98eeb008f630818b093a9524b410590ae type: core @@ -433,9 +433,9 @@ files: type: core size: 51556 - path: core/execution/subagent-dispatcher.js - hash: sha256:7affbc04de9be2bc53427670009a885f0b35e1cc183f82c2e044abf9611344b6 + hash: sha256:7b9d3f4bdca4bceb6e533fd7a20b29f529325193e65dda3021daf534a6eb18ee type: core - size: 25738 + size: 25837 - path: core/execution/wave-executor.js hash: sha256:4e2324edb37ae0729062b5ac029f2891e050e7efd3a48d0f4a1dc4f227a6716b type: core @@ -689,9 +689,9 @@ files: type: core size: 7755 - path: core/ideation/ideation-engine.js - hash: sha256:d9108fa47ed7a9131703739befb214b97d5b8e546faf1b49d8ae9d083756c589 + hash: sha256:14ac8d59fdb804341eb9ef7a1e11289d906bfc47a51a89bce7c09fab22420075 type: core - size: 22865 + size: 22960 - path: core/ids/circuit-breaker.js hash: sha256:1b35331ba71a6ce17869bab255e087fc540291243f9884fc21ed89f7efc122a4 type: core @@ -2583,19 +2583,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2603,11 +2603,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2615,7 +2615,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2623,7 +2623,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3367,11 +3367,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3383,11 +3383,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3395,23 +3395,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3507,43 +3507,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3691,7 +3691,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3743,7 +3743,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3807,7 +3807,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3915,7 +3915,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3923,11 +3923,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3975,7 +3975,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3987,67 +3987,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/memory/reflection-engine.test.js b/tests/core/memory/reflection-engine.test.js new file mode 100644 index 000000000..eb1883822 --- /dev/null +++ b/tests/core/memory/reflection-engine.test.js @@ -0,0 +1,633 @@ +/** + * Tests for Agent Reflection Engine + * + * Story: 9.6 - Agent Reflection Engine + * Epic: Epic 9 - Persistent Memory Layer + */ + +const fs = require('fs'); +const path = require('path'); +const ReflectionEngine = require('../../../.aios-core/core/memory/reflection-engine'); +const { Outcome, TaskType, Events, CONFIG } = ReflectionEngine; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TEST SETUP +// ═══════════════════════════════════════════════════════════════════════════════════ + +const TEST_DIR = path.join(__dirname, '__test-reflections__'); + +function createEngine(overrides = {}) { + return new ReflectionEngine({ + projectRoot: TEST_DIR, + config: { + reflectionsPath: '.aiox/reflections.json', + ...overrides, + }, + }); +} + +function makeReflection(overrides = {}) { + return { + taskType: TaskType.IMPLEMENTATION, + agentId: 'dev', + outcome: Outcome.SUCCESS, + strategy: 'test-first', + description: 'Implemented feature with TDD', + tags: ['nodejs', 'testing'], + durationMs: 5000, + lesson: 'TDD catches edge cases early', + ...overrides, + }; +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTRUCTOR +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - constructor', () => { + test('initializes with defaults', () => { + const engine = createEngine(); + expect(engine.reflections).toEqual([]); + expect(engine.patterns).toEqual([]); + expect(engine._loaded).toBe(false); + }); + + test('accepts custom config', () => { + const engine = createEngine({ maxReflections: 100 }); + expect(engine.config.maxReflections).toBe(100); + }); + + test('is an EventEmitter', () => { + const engine = createEngine(); + expect(typeof engine.on).toBe('function'); + expect(typeof engine.emit).toBe('function'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// LOAD / SAVE +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - load/save', () => { + test('loads from empty state', async () => { + const engine = createEngine(); + await engine.load(); + expect(engine._loaded).toBe(true); + expect(engine.reflections).toEqual([]); + }); + + test('save creates directory and file', async () => { + const engine = createEngine(); + await engine.load(); + engine.recordReflection(makeReflection()); + await engine.save(); + + const filePath = path.join(TEST_DIR, '.aiox/reflections.json'); + expect(fs.existsSync(filePath)).toBe(true); + + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + expect(data.schemaVersion).toBe(CONFIG.schemaVersion); + expect(data.reflections).toHaveLength(1); + }); + + test('round-trip: save then load', async () => { + const engine1 = createEngine(); + await engine1.load(); + engine1.recordReflection(makeReflection()); + engine1.recordReflection(makeReflection({ outcome: Outcome.FAILURE })); + await engine1.save(); + + const engine2 = createEngine(); + await engine2.load(); + expect(engine2.reflections).toHaveLength(2); + }); + + test('handles corrupted file gracefully', async () => { + const dir = path.join(TEST_DIR, '.aiox'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'reflections.json'), 'NOT JSON!!!'); + + const engine = createEngine(); + await engine.load(); + expect(engine._loaded).toBe(true); + expect(engine.reflections).toEqual([]); + }); + + test('resets on schema version mismatch', async () => { + const dir = path.join(TEST_DIR, '.aiox'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'reflections.json'), + JSON.stringify({ + schemaVersion: 'old-schema-v0', + reflections: [{ id: 'old' }], + patterns: [{ id: 'old-pattern' }], + }), + ); + + const engine = createEngine(); + await engine.load(); + expect(engine.reflections).toEqual([]); + expect(engine.patterns).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// RECORD REFLECTION +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - recordReflection', () => { + test('records a reflection with all fields', () => { + const engine = createEngine(); + const r = engine.recordReflection(makeReflection()); + + expect(r.id).toMatch(/^ref_/); + expect(r.taskType).toBe(TaskType.IMPLEMENTATION); + expect(r.agentId).toBe('dev'); + expect(r.outcome).toBe(Outcome.SUCCESS); + expect(r.strategy).toBe('test-first'); + expect(r.tags).toEqual(['nodejs', 'testing']); + expect(r.durationMs).toBe(5000); + expect(r.lesson).toBe('TDD catches edge cases early'); + expect(r.createdAt).toBeDefined(); + }); + + test('records minimal reflection', () => { + const engine = createEngine(); + const r = engine.recordReflection({ + taskType: TaskType.DEBUGGING, + agentId: 'qa', + outcome: Outcome.FAILURE, + strategy: 'log-analysis', + }); + + expect(r.description).toBe(''); + expect(r.tags).toEqual([]); + expect(r.durationMs).toBeNull(); + expect(r.lesson).toBeNull(); + }); + + test('throws on missing required fields', () => { + const engine = createEngine(); + expect(() => engine.recordReflection({ taskType: TaskType.TESTING })).toThrow('Required fields'); + expect(() => engine.recordReflection({})).toThrow('Required fields'); + }); + + test('emits REFLECTION_RECORDED event', () => { + const engine = createEngine(); + const handler = jest.fn(); + engine.on(Events.REFLECTION_RECORDED, handler); + + engine.recordReflection(makeReflection()); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].id).toMatch(/^ref_/); + }); + + test('enforces maxReflections limit', () => { + const engine = createEngine({ maxReflections: 3 }); + const pruneHandler = jest.fn(); + engine.on(Events.REFLECTIONS_PRUNED, pruneHandler); + + engine.recordReflection(makeReflection({ strategy: 's1' })); + engine.recordReflection(makeReflection({ strategy: 's2' })); + engine.recordReflection(makeReflection({ strategy: 's3' })); + expect(engine.reflections).toHaveLength(3); + + engine.recordReflection(makeReflection({ strategy: 's4' })); + expect(engine.reflections).toHaveLength(3); + expect(engine.reflections[0].strategy).toBe('s2'); // s1 was removed + expect(pruneHandler).toHaveBeenCalledWith( + expect.objectContaining({ count: 1, reason: 'max_limit' }), + ); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// RECOMMENDATIONS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - getRecommendations', () => { + test('returns empty for unknown task type', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection()); + const recs = engine.getRecommendations({ taskType: TaskType.DEPLOYMENT }); + expect(recs).toEqual([]); + }); + + test('returns empty when no taskType provided', () => { + const engine = createEngine(); + expect(engine.getRecommendations({})).toEqual([]); + }); + + test('ranks successful strategies higher', () => { + const engine = createEngine(); + + // 3 successes with TDD + for (let i = 0; i < 3; i++) { + engine.recordReflection(makeReflection({ strategy: 'tdd', outcome: Outcome.SUCCESS })); + } + // 3 failures with hack-first + for (let i = 0; i < 3; i++) { + engine.recordReflection(makeReflection({ strategy: 'hack-first', outcome: Outcome.FAILURE })); + } + + const recs = engine.getRecommendations({ taskType: TaskType.IMPLEMENTATION }); + expect(recs.length).toBeGreaterThanOrEqual(2); + expect(recs[0].strategy).toBe('tdd'); + expect(recs[0].successRate).toBe(1.0); + expect(recs[1].strategy).toBe('hack-first'); + expect(recs[1].successRate).toBe(0.0); + }); + + test('filters by agentId when provided', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ agentId: 'dev', strategy: 'strategy-a' })); + engine.recordReflection(makeReflection({ agentId: 'qa', strategy: 'strategy-b' })); + + const recs = engine.getRecommendations({ + taskType: TaskType.IMPLEMENTATION, + agentId: 'dev', + }); + expect(recs).toHaveLength(1); + expect(recs[0].strategy).toBe('strategy-a'); + }); + + test('boosts score with tag overlap', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ strategy: 'with-tags', tags: ['react', 'frontend'] })); + engine.recordReflection( + makeReflection({ strategy: 'no-tags', tags: ['backend', 'database'] }), + ); + + const recs = engine.getRecommendations({ + taskType: TaskType.IMPLEMENTATION, + tags: ['react', 'frontend'], + }); + + // with-tags should be ranked higher due to tag overlap + expect(recs[0].strategy).toBe('with-tags'); + }); + + test('collects lessons in recommendations', () => { + const engine = createEngine(); + engine.recordReflection( + makeReflection({ strategy: 'tdd', lesson: 'Write tests first' }), + ); + engine.recordReflection( + makeReflection({ strategy: 'tdd', lesson: 'Mock external deps' }), + ); + + const recs = engine.getRecommendations({ taskType: TaskType.IMPLEMENTATION }); + expect(recs[0].lessons).toContain('Write tests first'); + expect(recs[0].lessons).toContain('Mock external deps'); + }); + + test('calculates average duration', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ strategy: 'fast', durationMs: 1000 })); + engine.recordReflection(makeReflection({ strategy: 'fast', durationMs: 3000 })); + + const recs = engine.getRecommendations({ taskType: TaskType.IMPLEMENTATION }); + expect(recs[0].avgDurationMs).toBe(2000); + }); + + test('emits STRATEGY_RECOMMENDED event', () => { + const engine = createEngine(); + const handler = jest.fn(); + engine.on(Events.STRATEGY_RECOMMENDED, handler); + + engine.recordReflection(makeReflection()); + engine.getRecommendations({ taskType: TaskType.IMPLEMENTATION }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + taskType: TaskType.IMPLEMENTATION, + topStrategy: 'test-first', + }), + ); + }); + + test('respects maxRecommendations limit', () => { + const engine = createEngine({ maxRecommendations: 2 }); + for (let i = 0; i < 5; i++) { + engine.recordReflection(makeReflection({ strategy: `strategy-${i}` })); + } + + const recs = engine.getRecommendations({ taskType: TaskType.IMPLEMENTATION }); + expect(recs.length).toBeLessThanOrEqual(2); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONTEXT INJECTION +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - injectContext', () => { + test('injects recommendations and patterns', () => { + const engine = createEngine({ minReflectionsForPattern: 2 }); + engine.recordReflection(makeReflection({ strategy: 'tdd' })); + engine.recordReflection(makeReflection({ strategy: 'tdd' })); + + const ctx = engine.injectContext({ + taskType: TaskType.IMPLEMENTATION, + agentId: 'dev', + }); + + expect(ctx.reflections).toBeDefined(); + expect(ctx.reflections.recommendations.length).toBeGreaterThan(0); + expect(ctx.reflections.totalReflections).toBe(2); + }); + + test('preserves original context properties', () => { + const engine = createEngine(); + const ctx = engine.injectContext({ + taskType: TaskType.TESTING, + agentId: 'qa', + customField: 'preserved', + }); + + expect(ctx.customField).toBe('preserved'); + expect(ctx.agentId).toBe('qa'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// PATTERN DETECTION +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - pattern detection', () => { + test('detects pattern after minReflectionsForPattern', () => { + const engine = createEngine({ minReflectionsForPattern: 3 }); + const handler = jest.fn(); + engine.on(Events.PATTERN_DETECTED, handler); + + engine.recordReflection(makeReflection({ strategy: 'tdd' })); + engine.recordReflection(makeReflection({ strategy: 'tdd' })); + expect(handler).not.toHaveBeenCalled(); + + engine.recordReflection(makeReflection({ strategy: 'tdd' })); + expect(handler).toHaveBeenCalledTimes(1); + + const pattern = handler.mock.calls[0][0]; + expect(pattern.taskType).toBe(TaskType.IMPLEMENTATION); + expect(pattern.strategy).toBe('tdd'); + expect(pattern.sampleSize).toBe(3); + expect(pattern.verdict).toBe('recommended'); + }); + + test('marks failing patterns as avoid', () => { + const engine = createEngine({ minReflectionsForPattern: 3 }); + + for (let i = 0; i < 3; i++) { + engine.recordReflection( + makeReflection({ strategy: 'cowboy-coding', outcome: Outcome.FAILURE }), + ); + } + + expect(engine.patterns).toHaveLength(1); + expect(engine.patterns[0].verdict).toBe('avoid'); + expect(engine.patterns[0].successRate).toBe(0); + }); + + test('updates existing pattern on new data', () => { + const engine = createEngine({ minReflectionsForPattern: 3 }); + + for (let i = 0; i < 3; i++) { + engine.recordReflection(makeReflection({ strategy: 'incremental' })); + } + expect(engine.patterns).toHaveLength(1); + expect(engine.patterns[0].sampleSize).toBe(3); + + engine.recordReflection(makeReflection({ strategy: 'incremental' })); + expect(engine.patterns).toHaveLength(1); + expect(engine.patterns[0].sampleSize).toBe(4); + }); + + test('collects tags from all reflections into pattern', () => { + const engine = createEngine({ minReflectionsForPattern: 3 }); + + engine.recordReflection(makeReflection({ strategy: 's1', tags: ['a'] })); + engine.recordReflection(makeReflection({ strategy: 's1', tags: ['b'] })); + engine.recordReflection(makeReflection({ strategy: 's1', tags: ['a', 'c'] })); + + const tags = engine.patterns[0].tags; + expect(tags).toContain('a'); + expect(tags).toContain('b'); + expect(tags).toContain('c'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TRENDS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - getTrends', () => { + test('returns insufficient_data for empty', () => { + const engine = createEngine(); + const trends = engine.getTrends(); + expect(trends.trend).toBe('insufficient_data'); + expect(trends.total).toBe(0); + }); + + test('calculates success rate', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ outcome: Outcome.SUCCESS })); + engine.recordReflection(makeReflection({ outcome: Outcome.SUCCESS })); + engine.recordReflection(makeReflection({ outcome: Outcome.FAILURE })); + + const trends = engine.getTrends(); + expect(trends.total).toBe(3); + expect(trends.successes).toBe(2); + expect(trends.successRate).toBeCloseTo(0.667, 2); + }); + + test('detects improving trend', () => { + const engine = createEngine(); + // First half: failures + for (let i = 0; i < 4; i++) { + engine.recordReflection(makeReflection({ outcome: Outcome.FAILURE })); + } + // Second half: successes + for (let i = 0; i < 4; i++) { + engine.recordReflection(makeReflection({ outcome: Outcome.SUCCESS })); + } + + const trends = engine.getTrends(); + expect(trends.trend).toBe('improving'); + }); + + test('detects declining trend', () => { + const engine = createEngine(); + // First half: successes + for (let i = 0; i < 4; i++) { + engine.recordReflection(makeReflection({ outcome: Outcome.SUCCESS })); + } + // Second half: failures + for (let i = 0; i < 4; i++) { + engine.recordReflection(makeReflection({ outcome: Outcome.FAILURE })); + } + + const trends = engine.getTrends(); + expect(trends.trend).toBe('declining'); + }); + + test('filters by agentId', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ agentId: 'dev' })); + engine.recordReflection(makeReflection({ agentId: 'qa' })); + + const trends = engine.getTrends({ agentId: 'dev' }); + expect(trends.total).toBe(1); + }); + + test('filters by taskType', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ taskType: TaskType.DEBUGGING })); + engine.recordReflection(makeReflection({ taskType: TaskType.TESTING })); + + const trends = engine.getTrends({ taskType: TaskType.DEBUGGING }); + expect(trends.total).toBe(1); + }); + + test('calculates average duration', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ durationMs: 2000 })); + engine.recordReflection(makeReflection({ durationMs: 4000 })); + + const trends = engine.getTrends(); + expect(trends.avgDurationMs).toBe(3000); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// PRUNE +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - prune', () => { + test('removes old reflections beyond retention window', () => { + const engine = createEngine({ retentionDays: 30 }); + + // Add a reflection with old date + engine.reflections.push({ + id: 'old_1', + taskType: TaskType.GENERAL, + agentId: 'dev', + outcome: Outcome.SUCCESS, + strategy: 'old-strategy', + createdAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(), + }); + + // Add a recent one + engine.recordReflection(makeReflection()); + + expect(engine.reflections).toHaveLength(2); + const pruned = engine.prune(); + expect(pruned).toBe(1); + expect(engine.reflections).toHaveLength(1); + }); + + test('emits REFLECTIONS_PRUNED event', () => { + const engine = createEngine({ retentionDays: 1 }); + const handler = jest.fn(); + engine.on(Events.REFLECTIONS_PRUNED, handler); + + engine.reflections.push({ + id: 'old_2', + taskType: TaskType.GENERAL, + agentId: 'dev', + outcome: Outcome.SUCCESS, + strategy: 'x', + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + }); + + engine.prune(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ count: 1, reason: 'retention_window' }), + ); + }); + + test('returns 0 when nothing to prune', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection()); + expect(engine.prune()).toBe(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// STATS & LIST +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('ReflectionEngine - getStats', () => { + test('returns complete statistics', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ outcome: Outcome.SUCCESS, agentId: 'dev' })); + engine.recordReflection(makeReflection({ outcome: Outcome.FAILURE, agentId: 'qa' })); + engine.recordReflection( + makeReflection({ outcome: Outcome.SUCCESS, taskType: TaskType.TESTING, agentId: 'qa' }), + ); + + const stats = engine.getStats(); + expect(stats.totalReflections).toBe(3); + expect(stats.byOutcome[Outcome.SUCCESS]).toBe(2); + expect(stats.byOutcome[Outcome.FAILURE]).toBe(1); + expect(stats.byAgent['dev']).toBe(1); + expect(stats.byAgent['qa']).toBe(2); + expect(stats.byTaskType[TaskType.IMPLEMENTATION]).toBe(2); + expect(stats.byTaskType[TaskType.TESTING]).toBe(1); + }); +}); + +describe('ReflectionEngine - listReflections', () => { + test('lists all reflections', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection()); + engine.recordReflection(makeReflection()); + expect(engine.listReflections()).toHaveLength(2); + }); + + test('filters by taskType', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ taskType: TaskType.DEBUGGING })); + engine.recordReflection(makeReflection({ taskType: TaskType.TESTING })); + + expect(engine.listReflections({ taskType: TaskType.DEBUGGING })).toHaveLength(1); + }); + + test('filters by outcome', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ outcome: Outcome.SUCCESS })); + engine.recordReflection(makeReflection({ outcome: Outcome.FAILURE })); + + expect(engine.listReflections({ outcome: Outcome.FAILURE })).toHaveLength(1); + }); + + test('filters by tag', () => { + const engine = createEngine(); + engine.recordReflection(makeReflection({ tags: ['react'] })); + engine.recordReflection(makeReflection({ tags: ['vue'] })); + + expect(engine.listReflections({ tag: 'react' })).toHaveLength(1); + }); + + test('applies limit', () => { + const engine = createEngine(); + for (let i = 0; i < 10; i++) { + engine.recordReflection(makeReflection()); + } + + expect(engine.listReflections({ limit: 3 })).toHaveLength(3); + }); +});