diff --git a/.aios-core/core/memory/reflection-engine.js b/.aios-core/core/memory/reflection-engine.js new file mode 100644 index 0000000000..385d38c064 --- /dev/null +++ b/.aios-core/core/memory/reflection-engine.js @@ -0,0 +1,592 @@ +#!/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 { + /** + * @param {Object} options + * @param {string} [options.projectRoot] - Project root directory + * @param {Object} [options.config] - Override default config + */ + constructor(options = {}) { + super(); + this.projectRoot = options.projectRoot ?? process.cwd(); + this.config = { ...CONFIG, ...options.config }; + this.reflections = []; + this.patterns = []; + this._loaded = false; + this._saving = false; + } + + /** + * Get the reflections file path + * @returns {string} Absolute path to reflections.json + * @private + */ + _getFilePath() { + return path.join(this.projectRoot, this.config.reflectionsPath); + } + + /** + * Ensure persisted state has been hydrated before operating. + * Lazy-loads on first access to prevent empty-buffer overwrites. + * @returns {Promise} + * @private + */ + async _ensureLoaded() { + if (!this._loaded) { + await this.load(); + } + } + + /** + * Load reflections from disk. + * Logs a warning on schema mismatch or parse errors instead of failing silently. + * @returns {Promise} + */ + 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) { + console.warn( + `[ReflectionEngine] Schema mismatch: found '${data.schemaVersion}', expected '${this.config.schemaVersion}'. Resetting reflections.`, + ); + 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 (err) { + console.warn('[ReflectionEngine] Failed to load reflections:', err.message); + this.reflections = []; + this.patterns = []; + } + this._loaded = true; + } + + /** + * Save reflections to disk. + * Guards against concurrent writes and uses atomic write (tmp + rename). + * @returns {Promise} + */ + async save() { + if (this._saving) return; + this._saving = true; + + const filePath = this._getFilePath(); + const dir = path.dirname(filePath); + + try { + 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, + }; + + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8'); + fs.renameSync(tmpPath, filePath); + } catch (err) { + console.error('[ReflectionEngine] Failed to save reflections:', err.message); + } finally { + this._saving = false; + } + } + + /** + * 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 (Array.isArray(context.tags) && Array.isArray(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. + * Guards pattern.tags with Array.isArray to prevent runtime errors + * from malformed persisted data. + * + * @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 || + (Array.isArray(context.tags) && + context.tags.some((t) => Array.isArray(p.tags) && 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. + * Recomputes patterns after pruning to keep them consistent. + * @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 + * @returns {Object} Stats with totals by outcome, taskType, and agent + */ + 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 + * @param {Object} [filter] + * @param {string} [filter.taskType] - Filter by task type + * @param {string} [filter.agentId] - Filter by agent + * @param {string} [filter.outcome] - Filter by outcome + * @param {string} [filter.tag] - Filter by tag + * @param {number} [filter.limit] - Limit results + * @returns {Object[]} Filtered reflections + */ + 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) => Array.isArray(r.tags) && r.tags.includes(filter.tag)); + + if (filter.limit) results = results.slice(-filter.limit); + + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // INTERNAL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Detect patterns from accumulated reflections + * @param {Object} newEntry - The new reflection to check against + * @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 (Array.isArray(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 (Array.isArray(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 + * @returns {string} + * @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 8be0c1f909..2f46a10d8e 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 dc5bc26780..b4cc62407a 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 1baea2c5f7..6153fe71b0 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 0000000000..f05eaf1c81 --- /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/data/entity-registry.yaml b/.aiox-core/data/entity-registry.yaml index ec457d528a..9f12338c0f 100644 --- a/.aiox-core/data/entity-registry.yaml +++ b/.aiox-core/data/entity-registry.yaml @@ -1,7 +1,7 @@ metadata: version: 1.0.0 - lastUpdated: '2026-03-11T00:48:55.982Z' - entityCount: 745 + lastUpdated: '2026-03-17T15:38:11.021Z' + entityCount: 746 checksumAlgorithm: sha256 resolutionRate: 100 entities: @@ -8729,7 +8729,9 @@ entities: - injector usedBy: [] dependencies: + - memory-query - gotchas-memory + - session-memory externalDeps: [] plannedDeps: - memory-query @@ -8739,8 +8741,8 @@ entities: score: 0.4 constraints: [] extensionPoints: [] - checksum: sha256:a183255eb4831f701086f23f371f94a9ce10c46ea18c8369ec0fd756777f042f - lastVerified: '2026-03-11T00:48:55.878Z' + checksum: sha256:b53f19422d4df0470852ed2a6015408d08dd9182998819667c96bd1c0be33c61 + lastVerified: '2026-03-17T15:38:10.868Z' parallel-executor: path: .aiox-core/core/execution/parallel-executor.js layer: L1 @@ -8851,6 +8853,8 @@ entities: - dispatcher usedBy: [] dependencies: + - ai-providers + - memory-query - gotchas-memory externalDeps: [] plannedDeps: @@ -8861,8 +8865,8 @@ entities: score: 0.4 constraints: [] extensionPoints: [] - checksum: sha256:7affbc04de9be2bc53427670009a885f0b35e1cc183f82c2e044abf9611344b6 - lastVerified: '2026-03-11T00:48:55.878Z' + checksum: sha256:7b9d3f4bdca4bceb6e533fd7a20b29f529325193e65dda3021daf534a6eb18ee + lastVerified: '2026-03-17T15:38:10.869Z' wave-executor: path: .aiox-core/core/execution/wave-executor.js layer: L1 @@ -9030,8 +9034,8 @@ entities: score: 0.4 constraints: [] extensionPoints: [] - checksum: sha256:d9108fa47ed7a9131703739befb214b97d5b8e546faf1b49d8ae9d083756c589 - lastVerified: '2026-03-11T00:48:55.879Z' + checksum: sha256:14ac8d59fdb804341eb9ef7a1e11289d906bfc47a51a89bce7c09fab22420075 + lastVerified: '2026-03-17T15:38:10.870Z' circuit-breaker: path: .aiox-core/core/ids/circuit-breaker.js layer: L1 @@ -12778,6 +12782,22 @@ entities: extensionPoints: [] checksum: sha256:dd025894f8f0d3bd22a147dbc0debef8b83e96f3c59483653404b3cd5a01d5aa lastVerified: '2026-03-11T00:48:55.903Z' + reflection-engine: + path: .aiox-core/core/memory/reflection-engine.js + layer: L1 + type: module + purpose: reflection.description || '', + keywords: + - reflection + - engine + usedBy: [] + dependencies: [] + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:e219009b9322b996ef0ab0ef25d70f8ddd26717d0e24e88129988fe63f8514ca + lastVerified: '2026-03-17T15:38:11.019Z' agents: aiox-master: path: .aiox-core/development/agents/aiox-master.md diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 05816fa4fd..f718e780d0 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T15:04:09.395Z" +generated_at: "2026-03-17T15:40:02.776Z" generator: scripts/generate-install-manifest.js -file_count: 1090 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -409,9 +409,9 @@ files: type: core size: 48976 - 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 @@ -792,6 +792,10 @@ files: hash: sha256:0063eff42caf0dda759c0390ac323e7b102f5507f27b8beb7b0acc2fbec407c4 type: core size: 33058 + - path: core/memory/reflection-engine.js + hash: sha256:e219009b9322b996ef0ab0ef25d70f8ddd26717d0e24e88129988fe63f8514ca + type: core + size: 18745 - path: core/migration/migration-config.yaml hash: sha256:c251213b99ce86c9b15311d51a0b464b7fc25179455c4c6c107cae76cf49d1ab type: core @@ -1221,9 +1225,9 @@ files: type: data size: 9575 - path: data/entity-registry.yaml - hash: sha256:cc1bf74d3ef4e90b7a396d5b77259e540b2f9bd4a5b4b1da4977fe49ae83525d + hash: sha256:4238c9d7fa10886d601ab9510ba39b0efbd2ab24206848bc5d4eb1166429189c type: data - size: 521869 + size: 522442 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data diff --git a/tests/core/memory/reflection-engine.test.js b/tests/core/memory/reflection-engine.test.js new file mode 100644 index 0000000000..eb18838226 --- /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); + }); +});