diff --git a/.aiox-core/core/memory/decision-memory.js b/.aiox-core/core/memory/decision-memory.js new file mode 100644 index 0000000000..20646506b3 --- /dev/null +++ b/.aiox-core/core/memory/decision-memory.js @@ -0,0 +1,638 @@ +#!/usr/bin/env node + +/** + * AIOX Decision Memory + * + * Story: 9.5 - Decision Memory + * Epic: Epic 9 - Persistent Memory Layer + * + * Cross-session decision tracking system. Records agent decisions, + * their outcomes, and confidence levels to enable learning from + * past experience. Implements Phase 2 of the Agent Immortality + * Protocol (#482) — Persistence layer. + * + * Features: + * - AC1: decision-memory.js in .aiox-core/core/memory/ + * - AC2: Persists in .aiox/decisions.json + * - AC3: Records decision context, rationale, and outcome + * - AC4: Categories: architecture, delegation, tooling, recovery, workflow + * - AC5: Command *decision {description} records manually + * - AC6: Command *decisions lists recent decisions with outcomes + * - AC7: Injects relevant past decisions before similar tasks + * - AC8: Confidence scoring with decay over time + * - AC9: Pattern detection across decisions (recurring success/failure) + * + * @version 1.0.0 + */ + +const fs = require('fs'); +const path = require('path'); +const EventEmitter = require('events'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONFIGURATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +const CONFIG = { + decisionsJsonPath: '.aiox/decisions.json', + + // Confidence decay: decisions lose relevance over time + confidenceDecayDays: 30, + minConfidence: 0.1, + + // Pattern detection + patternThreshold: 3, // Same decision pattern 3x = recognized pattern + maxDecisions: 500, // Cap stored decisions + + // Context injection + maxInjectedDecisions: 5, // Max decisions injected per task + similarityThreshold: 0.3, // Minimum keyword overlap for relevance + + version: '1.0.0', + schemaVersion: 'aiox-decision-memory-v1', +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// ENUMS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const DecisionCategory = { + ARCHITECTURE: 'architecture', + DELEGATION: 'delegation', + TOOLING: 'tooling', + RECOVERY: 'recovery', + WORKFLOW: 'workflow', + TESTING: 'testing', + DEPLOYMENT: 'deployment', + GENERAL: 'general', +}; + +const Outcome = { + SUCCESS: 'success', + PARTIAL: 'partial', + FAILURE: 'failure', + PENDING: 'pending', +}; + +const Events = { + DECISION_RECORDED: 'decision:recorded', + OUTCOME_UPDATED: 'outcome:updated', + PATTERN_DETECTED: 'pattern:detected', + DECISIONS_INJECTED: 'decisions:injected', +}; + +const STOP_WORDS = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', + 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', + 'would', 'could', 'should', 'may', 'might', 'can', 'shall', + 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', + 'as', 'into', 'through', 'during', 'before', 'after', 'and', + 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either', + 'neither', 'each', 'every', 'all', 'any', 'few', 'more', + 'most', 'other', 'some', 'such', 'no', 'only', 'own', 'same', + 'than', 'too', 'very', 'just', 'because', 'que', 'para', + 'com', 'por', 'uma', 'como', 'mais', 'dos', 'das', 'nos', +]); + +const CATEGORY_KEYWORDS = { + [DecisionCategory.ARCHITECTURE]: [ + 'architecture', 'design', 'pattern', 'module', 'refactor', + 'structure', 'layer', 'abstraction', 'interface', 'separation', + ], + [DecisionCategory.DELEGATION]: [ + 'delegate', 'assign', 'agent', 'handoff', 'route', + 'dispatch', 'spawn', 'subagent', 'orchestrat', + ], + [DecisionCategory.TOOLING]: [ + 'tool', 'cli', 'command', 'script', 'build', + 'lint', 'format', 'bundle', 'compile', + ], + [DecisionCategory.RECOVERY]: [ + 'recover', 'retry', 'fallback', 'circuit', 'heal', + 'restart', 'rollback', 'backup', 'restore', + ], + [DecisionCategory.WORKFLOW]: [ + 'workflow', 'pipeline', 'ci', 'automate', 'process', + 'merge', 'branch', 'review', 'approve', + ], + [DecisionCategory.TESTING]: [ + 'test', 'spec', 'coverage', 'assert', 'mock', + 'fixture', 'snapshot', 'jest', 'unit', 'integration', + ], + [DecisionCategory.DEPLOYMENT]: [ + 'deploy', 'release', 'publish', 'ship', 'staging', + 'production', 'rollout', 'canary', 'blue-green', + ], +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// DECISION MEMORY +// ═══════════════════════════════════════════════════════════════════════════════════ + +class DecisionMemory 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.decisions = []; + this.patterns = []; + this._loaded = false; + this._saving = false; + } + + /** + * Load decisions from disk + * @returns {Promise} + */ + async load() { + const filePath = path.resolve(this.projectRoot, this.config.decisionsJsonPath); + + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw); + + if (data.schemaVersion === this.config.schemaVersion) { + this.decisions = data.decisions ?? []; + this.patterns = data.patterns ?? []; + } + } + } catch { + // Corrupted file — start fresh + this.decisions = []; + this.patterns = []; + } + + this._loaded = true; + } + + /** + * 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(); + } + } + + /** + * Save decisions to disk. + * Caps in-memory decisions at maxDecisions, guards against concurrent writes, + * and uses atomic write (tmp + rename) to prevent corruption. + * @returns {Promise} + */ + async save() { + if (this._saving) return; + this._saving = true; + + const filePath = path.resolve(this.projectRoot, this.config.decisionsJsonPath); + const dir = path.dirname(filePath); + + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + this.decisions = this.decisions.slice(-this.config.maxDecisions); + + const data = { + schemaVersion: this.config.schemaVersion, + version: this.config.version, + updatedAt: new Date().toISOString(), + stats: this.getStats(), + decisions: this.decisions, + patterns: this.patterns, + }; + + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + fs.renameSync(tmpPath, filePath); + } catch (err) { + this.emit('save:error', { error: err.message, path: filePath }); + } finally { + this._saving = false; + } + } + + /** + * Record a new decision (AC3, AC5) + * @param {Object} decision + * @param {string} decision.description - What was decided + * @param {string} [decision.rationale] - Why this decision was made + * @param {string[]} [decision.alternatives] - Other options considered + * @param {string} [decision.category] - Decision category + * @param {string} [decision.taskContext] - Related task/story + * @param {string} [decision.agentId] - Agent that made the decision + * @returns {Object} The recorded decision + */ + recordDecision({ + description, + rationale = '', + alternatives = [], + category = null, + taskContext = '', + agentId = 'unknown', + }) { + if (!description) { + throw new Error('Decision description is required'); + } + + const decision = { + id: this._generateId(), + description, + rationale, + alternatives, + category: category ?? this._detectCategory(description), + taskContext, + agentId, + outcome: Outcome.PENDING, + confidence: 1.0, + keywords: this._extractKeywords(description), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + outcomeNotes: '', + }; + + this.decisions.push(decision); + this._detectPatterns(decision); + this.emit(Events.DECISION_RECORDED, decision); + + return decision; + } + + /** + * Update the outcome of a decision (AC3) + * @param {string} decisionId - Decision ID + * @param {string} outcome - 'success' | 'partial' | 'failure' + * @param {string} [notes] - Outcome notes + * @returns {Object|null} Updated decision + */ + updateOutcome(decisionId, outcome, notes = '') { + const decision = this.decisions.find(d => d.id === decisionId); + if (!decision) return null; + + const allowedOutcomes = [Outcome.SUCCESS, Outcome.PARTIAL, Outcome.FAILURE]; + if (!allowedOutcomes.includes(outcome)) { + throw new Error(`Invalid outcome: ${outcome}. Use: ${allowedOutcomes.join(', ')}`); + } + + decision.outcome = outcome; + decision.outcomeNotes = notes; + decision.updatedAt = new Date().toISOString(); + + // Adjust confidence based on outcome + if (outcome === Outcome.SUCCESS) { + decision.confidence = Math.min(1.0, decision.confidence + 0.1); + } else if (outcome === Outcome.FAILURE) { + decision.confidence = Math.max(this.config.minConfidence, decision.confidence - 0.3); + } + + // Recompute patterns related to this decision when outcomes change + this._recomputeRelatedPatterns(decision); + + this.emit(Events.OUTCOME_UPDATED, decision); + return decision; + } + + /** + * Get relevant past decisions for a task context (AC7) + * @param {string} taskDescription - Current task description + * @param {Object} [options] + * @param {number} [options.limit] - Max results + * @param {string} [options.category] - Filter by category + * @param {boolean} [options.successOnly] - Only successful decisions + * @returns {Object[]} Relevant decisions sorted by relevance + */ + getRelevantDecisions(taskDescription, options = {}) { + const limit = options.limit ?? this.config.maxInjectedDecisions; + const taskKeywords = this._extractKeywords(taskDescription); + + let candidates = this.decisions.filter(d => d.outcome !== Outcome.PENDING); + + if (options.category) { + candidates = candidates.filter(d => d.category === options.category); + } + + if (options.successOnly) { + candidates = candidates.filter(d => d.outcome === Outcome.SUCCESS); + } + + // Score by keyword similarity + confidence with time decay. + // Gate on raw similarity first (config threshold), then rank by composite score. + const scored = candidates.map(d => { + const similarity = this._keywordSimilarity(taskKeywords, d.keywords); + const decayed = this._applyTimeDecay(d.confidence, d.createdAt); + const outcomeBonus = d.outcome === Outcome.SUCCESS ? 0.2 : + d.outcome === Outcome.FAILURE ? 0.1 : 0; // Failures are also valuable to learn from + + return { + decision: d, + similarity, + score: (similarity * 0.6) + (decayed * 0.25) + (outcomeBonus * 0.15), + }; + }); + + return scored + .filter(s => s.similarity >= this.config.similarityThreshold) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(s => ({ + ...s.decision, + relevanceScore: Math.round(s.score * 100) / 100, + })); + } + + /** + * Inject relevant decisions as context for a task (AC7). + * Escapes persisted fields to prevent markdown/prompt injection. + * @param {string} taskDescription - Task description + * @returns {string} Formatted context block + */ + injectDecisionContext(taskDescription) { + const relevant = this.getRelevantDecisions(taskDescription); + + if (relevant.length === 0) return ''; + + const lines = [ + '## Relevant Past Decisions', + '', + ]; + + for (const d of relevant) { + const outcomeIcon = d.outcome === Outcome.SUCCESS ? '[OK]' : + d.outcome === Outcome.FAILURE ? '[FAIL]' : '[WARN]'; + + const safeDesc = this._escapeMarkdown(d.description); + const safeRationale = d.rationale ? this._escapeMarkdown(d.rationale) : ''; + const safeNotes = d.outcomeNotes ? this._escapeMarkdown(d.outcomeNotes) : ''; + + lines.push(`### ${outcomeIcon} ${safeDesc}`); + if (safeRationale) lines.push(`**Rationale:** ${safeRationale}`); + if (safeNotes) lines.push(`**Outcome:** ${safeNotes}`); + lines.push(`**Category:** ${d.category} | **Confidence:** ${Math.round(this._applyTimeDecay(d.confidence, d.createdAt) * 100)}%`); + lines.push(''); + } + + this.emit(Events.DECISIONS_INJECTED, { task: taskDescription, count: relevant.length }); + return lines.join('\n'); + } + + /** + * Get recognized patterns (AC9) + * @returns {Object[]} Detected patterns + */ + getPatterns() { + return [...this.patterns]; + } + + /** + * Get statistics + * @returns {Object} Stats + */ + getStats() { + const total = this.decisions.length; + const byOutcome = {}; + const byCategory = {}; + + for (const d of this.decisions) { + byOutcome[d.outcome] = (byOutcome[d.outcome] ?? 0) + 1; + byCategory[d.category] = (byCategory[d.category] ?? 0) + 1; + } + + const successRate = total > 0 + ? (byOutcome[Outcome.SUCCESS] ?? 0) / Math.max(1, total - (byOutcome[Outcome.PENDING] ?? 0)) + : 0; + + return { + total, + byOutcome, + byCategory, + patterns: this.patterns.length, + successRate: Math.round(successRate * 100), + }; + } + + /** + * List recent decisions (AC6) + * @param {Object} [options] + * @param {number} [options.limit] - Max results + * @param {string} [options.category] - Filter by category + * @returns {Object[]} Recent decisions + */ + listDecisions(options = {}) { + const limit = options.limit ?? 20; + let results = [...this.decisions]; + + if (options.category) { + results = results.filter(d => d.category === options.category); + } + + return results.slice(-limit).reverse(); + } + + // ═════════════════════════════════════════════════════════════════════════════ + // PRIVATE METHODS + // ═════════════════════════════════════════════════════════════════════════════ + + /** + * Auto-detect category from description + * @param {string} text + * @returns {string} Category + * @private + */ + _detectCategory(text) { + const lower = text.toLowerCase(); + let bestCategory = DecisionCategory.GENERAL; + let bestScore = 0; + + for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) { + const score = keywords.reduce((count, kw) => + count + (lower.includes(kw) ? 1 : 0), 0); + + if (score > bestScore) { + bestScore = score; + bestCategory = category; + } + } + + return bestCategory; + } + + /** + * Extract keywords from text + * @param {string} text + * @returns {string[]} Keywords + * @private + */ + _extractKeywords(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 2 && !STOP_WORDS.has(w)) + .slice(0, 20); + } + + /** + * Calculate keyword similarity between two keyword sets (Jaccard index) + * @param {string[]} keywords1 + * @param {string[]} keywords2 + * @returns {number} Similarity score 0-1 + * @private + */ + _keywordSimilarity(keywords1, keywords2) { + if (keywords1.length === 0 || keywords2.length === 0) return 0; + + const set1 = new Set(keywords1); + const set2 = new Set(keywords2); + const intersection = [...set1].filter(k => set2.has(k)).length; + const union = new Set([...set1, ...set2]).size; + + return union > 0 ? intersection / union : 0; + } + + /** + * Apply time-based confidence decay. + * Returns Math.max(minConfidence, confidence * decayFactor) so the + * final decayed value never drops below the configured floor. + * @param {number} confidence - Original confidence + * @param {string} createdAt - ISO date string + * @returns {number} Decayed confidence + * @private + */ + _applyTimeDecay(confidence, createdAt) { + const ageMs = Date.now() - new Date(createdAt).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const decayFactor = 1 - (ageDays / this.config.confidenceDecayDays) * 0.5; + const decayedConfidence = confidence * decayFactor; + + return Math.max(this.config.minConfidence, decayedConfidence); + } + + /** + * Escape markdown control sequences from persisted fields to prevent + * injected headings, code fences, or instruction text in context blocks. + * @param {string} text - Raw text to sanitize + * @returns {string} Sanitized text safe for markdown embedding + * @private + */ + _escapeMarkdown(text) { + return text + .replace(/```/g, '\\`\\`\\`') + .replace(/^(#{1,6})\s/gm, '\\$1 ') + .replace(/^>/gm, '\\>') + .replace(/\n/g, ' '); + } + + /** + * Detect recurring patterns in decisions (AC9). + * Uses a neutral recommendation when no resolved outcomes exist yet. + * @param {Object} newDecision - The new decision to check against + * @private + */ + _detectPatterns(newDecision) { + const similar = this.decisions.filter(d => + d.id !== newDecision.id && + d.category === newDecision.category && + this._keywordSimilarity(d.keywords, newDecision.keywords) > this.config.similarityThreshold, + ); + + if (similar.length >= this.config.patternThreshold - 1) { + const outcomes = similar.map(d => d.outcome).filter(o => o !== Outcome.PENDING); + const successCount = outcomes.filter(o => o === Outcome.SUCCESS).length; + const failureCount = outcomes.filter(o => o === Outcome.FAILURE).length; + + let recommendation; + if (outcomes.length === 0) { + recommendation = 'Recurring pattern detected. No outcome data yet to evaluate.'; + } else if (successCount > failureCount) { + recommendation = 'This approach has historically worked well. Consider reusing.'; + } else { + recommendation = 'This approach has historically underperformed. Consider alternatives.'; + } + + const pattern = { + id: `pattern-${this.patterns.length + 1}`, + category: newDecision.category, + description: `Recurring ${newDecision.category} decision: "${newDecision.description}"`, + occurrences: similar.length + 1, + successRate: outcomes.length > 0 ? successCount / outcomes.length : 0, + recommendation, + detectedAt: new Date().toISOString(), + relatedDecisionIds: [...similar.map(d => d.id), newDecision.id], + }; + + // Avoid duplicate patterns + const exists = this.patterns.some(p => + p.category === pattern.category && + this._keywordSimilarity( + this._extractKeywords(p.description), + this._extractKeywords(pattern.description), + ) > 0.6, + ); + + if (!exists) { + this.patterns.push(pattern); + this.emit(Events.PATTERN_DETECTED, pattern); + } + } + } + + /** + * Recompute recommendation for patterns related to a decision whose outcome changed. + * Prevents stale neutral/negative recommendations from persisting after outcomes resolve. + * @param {Object} decision - The decision that was updated + * @private + */ + _recomputeRelatedPatterns(decision) { + for (const pattern of this.patterns) { + if (!pattern.relatedDecisionIds.includes(decision.id)) continue; + + const related = this.decisions.filter(d => pattern.relatedDecisionIds.includes(d.id)); + const outcomes = related.map(d => d.outcome).filter(o => o !== Outcome.PENDING); + const successCount = outcomes.filter(o => o === Outcome.SUCCESS).length; + const failureCount = outcomes.filter(o => o === Outcome.FAILURE).length; + + if (outcomes.length === 0) { + pattern.recommendation = 'Recurring pattern detected. No outcome data yet to evaluate.'; + } else if (successCount > failureCount) { + pattern.recommendation = 'This approach has historically worked well. Consider reusing.'; + } else { + pattern.recommendation = 'This approach has historically underperformed. Consider alternatives.'; + } + + pattern.successRate = outcomes.length > 0 ? successCount / outcomes.length : 0; + } + } + + /** + * Generate unique decision ID + * @returns {string} + * @private + */ + _generateId() { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `dec-${timestamp}-${random}`; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// EXPORTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +module.exports = { + DecisionMemory, + DecisionCategory, + Outcome, + Events, + CONFIG, +}; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 05816fa4fd..2ee253948e 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:36:14.895Z" generator: scripts/generate-install-manifest.js -file_count: 1090 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -788,6 +788,10 @@ files: hash: sha256:895ec75f6a303edf4cffa0ab7adbb8a4876f62626cc0d7178420efd5758f21a9 type: core size: 8850 + - path: core/memory/decision-memory.js + hash: sha256:8a6d5b1bcfc45dda57b0a8740175ce08802b50c22c0a4bfd3de9cf1efef85475 + type: core + size: 22491 - path: core/memory/gotchas-memory.js hash: sha256:0063eff42caf0dda759c0390ac323e7b102f5507f27b8beb7b0acc2fbec407c4 type: core diff --git a/tests/core/execution/rate-limit-manager.test.js b/tests/core/execution/rate-limit-manager.test.js new file mode 100644 index 0000000000..78c6691350 --- /dev/null +++ b/tests/core/execution/rate-limit-manager.test.js @@ -0,0 +1,319 @@ +/** + * Testes unitários para RateLimitManager + * + * Cobre executeWithRetry, calculateDelay, isRateLimitError, + * preemptiveThrottle, metrics, events e withRateLimit wrapper. + * + * @see .aiox-core/core/execution/rate-limit-manager.js + * @issue #52 + */ + +'use strict'; + +const RateLimitManager = require('../../../.aiox-core/core/execution/rate-limit-manager'); +const { withRateLimit, getGlobalManager } = RateLimitManager; + +// ============================================================================ +// Constructor +// ============================================================================ + +describe('RateLimitManager — constructor', () => { + it('deve usar defaults quando sem config', () => { + const mgr = new RateLimitManager(); + expect(mgr.maxRetries).toBe(5); + expect(mgr.baseDelay).toBe(1000); + expect(mgr.maxDelay).toBe(30000); + expect(mgr.requestsPerMinute).toBe(50); + }); + + it('deve aceitar config custom', () => { + const mgr = new RateLimitManager({ + maxRetries: 3, + baseDelay: 500, + maxDelay: 10000, + requestsPerMinute: 20, + }); + expect(mgr.maxRetries).toBe(3); + expect(mgr.baseDelay).toBe(500); + expect(mgr.maxDelay).toBe(10000); + expect(mgr.requestsPerMinute).toBe(20); + }); + + it('deve inicializar metrics zerados', () => { + const mgr = new RateLimitManager(); + expect(mgr.metrics.rateLimitHits).toBe(0); + expect(mgr.metrics.totalRetries).toBe(0); + expect(mgr.metrics.totalRequests).toBe(0); + }); + + it('deve ser EventEmitter', () => { + const mgr = new RateLimitManager(); + expect(typeof mgr.on).toBe('function'); + expect(typeof mgr.emit).toBe('function'); + }); +}); + +// ============================================================================ +// isRateLimitError +// ============================================================================ + +describe('RateLimitManager — isRateLimitError', () => { + let mgr; + beforeEach(() => { mgr = new RateLimitManager(); }); + + it('deve detectar HTTP 429 via status', () => { + expect(mgr.isRateLimitError({ status: 429, message: '' })).toBe(true); + }); + + it('deve detectar HTTP 429 via statusCode', () => { + expect(mgr.isRateLimitError({ statusCode: 429, message: '' })).toBe(true); + }); + + it('deve detectar "rate limit" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('rate limit exceeded'))).toBe(true); + }); + + it('deve detectar "too many requests" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('too many requests'))).toBe(true); + }); + + it('deve detectar "throttl" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('request throttled'))).toBe(true); + }); + + it('deve detectar "quota exceeded" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('API quota exceeded'))).toBe(true); + }); + + it('deve detectar "overloaded" (Anthropic)', () => { + expect(mgr.isRateLimitError(new Error('API is overloaded'))).toBe(true); + }); + + it('deve detectar code RATE_LIMITED', () => { + const err = new Error('fail'); + err.code = 'RATE_LIMITED'; + expect(mgr.isRateLimitError(err)).toBe(true); + }); + + it('deve detectar code TOO_MANY_REQUESTS', () => { + const err = new Error('fail'); + err.code = 'TOO_MANY_REQUESTS'; + expect(mgr.isRateLimitError(err)).toBe(true); + }); + + it('deve retornar false para erros normais', () => { + expect(mgr.isRateLimitError(new Error('connection refused'))).toBe(false); + expect(mgr.isRateLimitError(new Error('timeout'))).toBe(false); + expect(mgr.isRateLimitError({ status: 500, message: 'server error' })).toBe(false); + }); +}); + +// ============================================================================ +// calculateDelay +// ============================================================================ + +describe('RateLimitManager — calculateDelay', () => { + let mgr; + beforeEach(() => { mgr = new RateLimitManager({ baseDelay: 1000, maxDelay: 30000 }); }); + + it('deve usar retryAfter do erro quando disponível', () => { + const err = new Error('rate limited'); + err.retryAfter = 5; + const delay = mgr.calculateDelay(1, err); + expect(delay).toBe(5000); + }); + + it('deve limitar retryAfter ao maxDelay', () => { + const err = new Error('rate limited'); + err.retryAfter = 60; + const delay = mgr.calculateDelay(1, err); + expect(delay).toBe(30000); + }); + + it('deve extrair retry-after da mensagem de erro', () => { + const err = new Error('Retry-After: 3'); + const delay = mgr.calculateDelay(1, err); + expect(delay).toBe(3000); + }); + + it('deve usar backoff exponencial sem retryAfter', () => { + const err = new Error('rate limited'); + // attempt 1: 1000 * 2^0 = 1000 + jitter(0-1000) ≈ 1000-2000 + const delay1 = mgr.calculateDelay(1, err); + expect(delay1).toBeGreaterThanOrEqual(1000); + expect(delay1).toBeLessThanOrEqual(2001); + + // attempt 3: 1000 * 2^2 = 4000 + jitter(0-1000) ≈ 4000-5000 + const delay3 = mgr.calculateDelay(3, err); + expect(delay3).toBeGreaterThanOrEqual(4000); + expect(delay3).toBeLessThanOrEqual(5001); + }); + + it('deve limitar ao maxDelay', () => { + const err = new Error('rate limited'); + // attempt 10: 1000 * 2^9 = 512000 → capped at 30000 + const delay = mgr.calculateDelay(10, err); + expect(delay).toBeLessThanOrEqual(30000); + }); +}); + +// ============================================================================ +// executeWithRetry +// ============================================================================ + +describe('RateLimitManager — executeWithRetry', () => { + let mgr; + beforeEach(() => { + mgr = new RateLimitManager({ maxRetries: 3, baseDelay: 1 }); + mgr.sleep = jest.fn().mockResolvedValue(undefined); // Skip delays + }); + + it('deve retornar resultado quando fn sucede na primeira tentativa', async () => { + const result = await mgr.executeWithRetry(() => Promise.resolve('ok')); + expect(result).toBe('ok'); + expect(mgr.metrics.totalRequests).toBe(1); + expect(mgr.metrics.rateLimitHits).toBe(0); + }); + + it('deve fazer retry em rate limit e suceder na segunda', async () => { + let attempt = 0; + const fn = () => { + attempt++; + if (attempt === 1) { + const err = new Error('rate limit'); + throw err; + } + return Promise.resolve('recovered'); + }; + + const result = await mgr.executeWithRetry(fn); + expect(result).toBe('recovered'); + expect(mgr.metrics.rateLimitHits).toBe(1); + expect(mgr.metrics.successAfterRetry).toBe(1); + expect(mgr.metrics.totalRetries).toBe(1); + }); + + it('deve lançar após maxRetries em rate limit', async () => { + const fn = () => { throw new Error('rate limit exceeded'); }; + + await expect(mgr.executeWithRetry(fn)).rejects.toThrow('Rate limit exceeded after 3 retries'); + expect(mgr.metrics.rateLimitHits).toBe(3); + }); + + it('deve lançar imediatamente para erros não-rate-limit', async () => { + const fn = () => { throw new Error('connection refused'); }; + + await expect(mgr.executeWithRetry(fn)).rejects.toThrow('connection refused'); + expect(mgr.metrics.rateLimitHits).toBe(0); + expect(mgr.metrics.totalRetries).toBe(0); + }); + + it('deve emitir eventos rate_limit_hit e waiting', async () => { + const events = []; + mgr.on('rate_limit_hit', (data) => events.push({ type: 'hit', ...data })); + mgr.on('waiting', (data) => events.push({ type: 'wait', ...data })); + + let attempt = 0; + const fn = () => { + attempt++; + if (attempt <= 2) throw new Error('rate limit'); + return Promise.resolve('ok'); + }; + + await mgr.executeWithRetry(fn); + expect(events.filter((e) => e.type === 'hit')).toHaveLength(2); + expect(events.filter((e) => e.type === 'wait')).toHaveLength(2); + }); +}); + +// ============================================================================ +// Metrics & Events +// ============================================================================ + +describe('RateLimitManager — metrics & events', () => { + let mgr; + beforeEach(() => { mgr = new RateLimitManager(); }); + + it('getMetrics deve calcular averageWaitTime e successRate', () => { + mgr.metrics.totalRequests = 10; + mgr.metrics.rateLimitHits = 2; + mgr.metrics.totalRetries = 3; + mgr.metrics.totalWaitTime = 9000; + + const m = mgr.getMetrics(); + expect(m.averageWaitTime).toBe(3000); + expect(m.successRate).toBe(80); + }); + + it('getMetrics deve retornar 100% successRate sem requests', () => { + const m = mgr.getMetrics(); + expect(m.successRate).toBe(100); + expect(m.averageWaitTime).toBe(0); + }); + + it('logEvent deve respeitar maxEventLog', () => { + for (let i = 0; i < 120; i++) { + mgr.logEvent('test', { i }); + } + expect(mgr.eventLog.length).toBe(100); + expect(mgr.eventLog[0].i).toBe(20); // Primeiros 20 descartados + }); + + it('getRecentEvents deve retornar últimos N eventos', () => { + for (let i = 0; i < 10; i++) { + mgr.logEvent('test', { i }); + } + const recent = mgr.getRecentEvents(3); + expect(recent).toHaveLength(3); + expect(recent[0].i).toBe(7); + }); + + it('resetMetrics deve zerar tudo', () => { + mgr.metrics.totalRequests = 10; + mgr.logEvent('test', {}); + + mgr.resetMetrics(); + expect(mgr.metrics.totalRequests).toBe(0); + expect(mgr.eventLog).toHaveLength(0); + }); + + it('formatStatus deve retornar string formatada', () => { + mgr.metrics.totalRequests = 5; + mgr.metrics.rateLimitHits = 1; + const status = mgr.formatStatus(); + + expect(status).toContain('Rate Limit Manager Status'); + expect(status).toContain('Total Requests: 5'); + expect(status).toContain('Rate Limit Hits: 1'); + }); +}); + +// ============================================================================ +// withRateLimit wrapper +// ============================================================================ + +describe('withRateLimit', () => { + it('deve wrappear função com retry automático', async () => { + const mgr = new RateLimitManager({ maxRetries: 2, baseDelay: 1 }); + mgr.sleep = jest.fn().mockResolvedValue(undefined); + + const original = jest.fn().mockResolvedValue('result'); + const wrapped = withRateLimit(original, mgr, { label: 'test' }); + + const result = await wrapped('arg1', 'arg2'); + expect(result).toBe('result'); + expect(original).toHaveBeenCalledWith('arg1', 'arg2'); + }); +}); + +// ============================================================================ +// getGlobalManager singleton +// ============================================================================ + +describe('getGlobalManager', () => { + it('deve retornar instância singleton', () => { + const mgr1 = getGlobalManager({ maxRetries: 10 }); + const mgr2 = getGlobalManager({ maxRetries: 99 }); + expect(mgr1).toBe(mgr2); // Mesmo objeto + }); +}); diff --git a/tests/core/execution/result-aggregator.test.js b/tests/core/execution/result-aggregator.test.js new file mode 100644 index 0000000000..9831589af1 --- /dev/null +++ b/tests/core/execution/result-aggregator.test.js @@ -0,0 +1,298 @@ +/** + * Testes unitarios para ResultAggregator + * + * Cobre aggregate, aggregateAll, detectFileConflicts, + * assessConflictSeverity, suggestResolution, extractFilesFromOutput, + * metrics, warnings e history. + * + * @see .aiox-core/core/execution/result-aggregator.js + * @issue #52 + */ + +'use strict'; + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const ResultAggregator = require('../../../.aiox-core/core/execution/result-aggregator'); + +// ============================================================================ +// Constructor +// ============================================================================ + +describe('ResultAggregator -- constructor', () => { + it('deve usar defaults quando sem config', () => { + const agg = new ResultAggregator(); + expect(agg.detectConflicts).toBe(true); + expect(agg.maxHistory).toBe(50); + }); + + it('deve aceitar config custom', () => { + const agg = new ResultAggregator({ + detectConflicts: false, + maxHistory: 10, + }); + expect(agg.detectConflicts).toBe(false); + expect(agg.maxHistory).toBe(10); + }); + + it('deve ser EventEmitter', () => { + const agg = new ResultAggregator(); + expect(typeof agg.on).toBe('function'); + expect(typeof agg.emit).toBe('function'); + }); +}); + +// ============================================================================ +// detectFileConflicts +// ============================================================================ + +describe('ResultAggregator -- detectFileConflicts', () => { + let agg; + beforeEach(() => { agg = new ResultAggregator(); }); + + it('deve detectar conflito quando dois tasks modificam mesmo arquivo', () => { + const tasks = [ + { taskId: 'task-1', filesModified: ['src/index.js', 'src/utils.js'] }, + { taskId: 'task-2', filesModified: ['src/index.js'] }, + ]; + const conflicts = agg.detectFileConflicts(tasks); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].file).toBe('src/index.js'); + expect(conflicts[0].tasks).toEqual(['task-1', 'task-2']); + expect(conflicts[0].type).toBe('concurrent_modification'); + }); + + it('deve retornar array vazio sem conflitos', () => { + const tasks = [ + { taskId: 'task-1', filesModified: ['src/a.js'] }, + { taskId: 'task-2', filesModified: ['src/b.js'] }, + ]; + expect(agg.detectFileConflicts(tasks)).toEqual([]); + }); + + it('deve lidar com tasks sem filesModified', () => { + const tasks = [ + { taskId: 'task-1' }, + { taskId: 'task-2', filesModified: [] }, + ]; + expect(agg.detectFileConflicts(tasks)).toEqual([]); + }); +}); + +// ============================================================================ +// assessConflictSeverity +// ============================================================================ + +describe('ResultAggregator -- assessConflictSeverity', () => { + let agg; + beforeEach(() => { agg = new ResultAggregator(); }); + + it('deve retornar critical para package.json', () => { + expect(agg.assessConflictSeverity('package.json')).toBe('critical'); + }); + + it('deve retornar critical para tsconfig.json', () => { + expect(agg.assessConflictSeverity('tsconfig.json')).toBe('critical'); + }); + + it('deve retornar critical para .env', () => { + expect(agg.assessConflictSeverity('.env')).toBe('critical'); + }); + + it('deve retornar critical para index.js', () => { + expect(agg.assessConflictSeverity('src/index.js')).toBe('critical'); + }); + + it('deve retornar high para arquivos de config', () => { + expect(agg.assessConflictSeverity('config/database.js')).toBe('high'); + }); + + it('deve retornar high para schemas', () => { + expect(agg.assessConflictSeverity('db/schema.sql')).toBe('high'); + }); + + it('deve retornar high para migrations', () => { + expect(agg.assessConflictSeverity('migrations/001.sql')).toBe('high'); + }); + + it('deve retornar medium para outros arquivos', () => { + expect(agg.assessConflictSeverity('src/utils/helper.js')).toBe('medium'); + }); +}); + +// ============================================================================ +// suggestResolution +// ============================================================================ + +describe('ResultAggregator -- suggestResolution', () => { + let agg; + beforeEach(() => { agg = new ResultAggregator(); }); + + it('deve sugerir merge manual para JSON', () => { + const res = agg.suggestResolution('package.json', 'a', 'b'); + expect(res).toContain('Merge JSON'); + expect(res).toContain('a'); + expect(res).toContain('b'); + }); + + it('deve sugerir merge automatico para testes', () => { + expect(agg.suggestResolution('test/foo.test.js', 'a', 'b')).toContain('automatically'); + expect(agg.suggestResolution('spec/bar.spec.js', 'a', 'b')).toContain('automatically'); + }); + + it('deve sugerir review para outros arquivos', () => { + expect(agg.suggestResolution('src/app.js', 'a', 'b')).toContain('Review'); + }); +}); + +// ============================================================================ +// extractFilesFromOutput +// ============================================================================ + +describe('ResultAggregator -- extractFilesFromOutput', () => { + let agg; + beforeEach(() => { agg = new ResultAggregator(); }); + + it('deve retornar array vazio para output nulo', () => { + expect(agg.extractFilesFromOutput(null)).toEqual([]); + expect(agg.extractFilesFromOutput(undefined)).toEqual([]); + expect(agg.extractFilesFromOutput('')).toEqual([]); + }); + + it('deve extrair arquivos de padroes "created/modified"', () => { + const output = 'created file `src/index.js`\nmodified "config/app.js"'; + const files = agg.extractFilesFromOutput(output); + expect(files).toContain('src/index.js'); + expect(files).toContain('config/app.js'); + }); + + it('deve extrair arquivos de padroes "file:" e "path:"', () => { + const output = 'file: src/utils.js\npath: "config.json"'; + const files = agg.extractFilesFromOutput(output); + expect(files).toContain('src/utils.js'); + }); +}); + +// ============================================================================ +// aggregate +// ============================================================================ + +describe('ResultAggregator -- aggregate', () => { + let agg; + beforeEach(() => { + agg = new ResultAggregator({ detectConflicts: true }); + }); + + it('deve agregar resultados de wave simples', async () => { + const waveResults = { + waveIndex: 1, + results: [ + { taskId: 'task-1', agentId: 'dev', success: true, duration: 100, output: 'done' }, + { taskId: 'task-2', agentId: 'qa', success: true, duration: 200, output: 'ok' }, + ], + }; + + const result = await agg.aggregate(waveResults); + + expect(result.waveIndex).toBe(1); + expect(result.tasks).toHaveLength(2); + expect(result.tasks[0].taskId).toBe('task-1'); + expect(result.tasks[0].success).toBe(true); + expect(result.conflicts).toEqual([]); + expect(result.metrics).toHaveProperty('totalTasks'); + }); + + it('deve detectar conflitos entre tasks', async () => { + const events = []; + agg.on('conflicts_detected', (data) => events.push(data)); + + const waveResults = { + waveIndex: 1, + results: [ + { taskId: 'task-1', success: true, filesModified: ['src/index.js'] }, + { taskId: 'task-2', success: true, filesModified: ['src/index.js', 'src/b.js'] }, + ], + }; + + const result = await agg.aggregate(waveResults); + + expect(result.conflicts).toHaveLength(1); + expect(events).toHaveLength(1); + }); + + it('deve adicionar ao history', async () => { + await agg.aggregate({ waveIndex: 0, results: [] }); + await agg.aggregate({ waveIndex: 1, results: [] }); + + expect(agg.history).toHaveLength(2); + }); + + it('deve emitir aggregation_complete', async () => { + const events = []; + agg.on('aggregation_complete', (data) => events.push(data)); + + await agg.aggregate({ waveIndex: 0, results: [] }); + expect(events).toHaveLength(1); + }); + + it('deve respeitar maxHistory', async () => { + agg.maxHistory = 3; + for (let i = 0; i < 5; i++) { + await agg.aggregate({ waveIndex: i, results: [] }); + } + expect(agg.history).toHaveLength(3); + }); + + it('deve pular deteccao de conflitos quando desabilitado', async () => { + agg.detectConflicts = false; + const waveResults = { + waveIndex: 0, + results: [ + { taskId: 'task-1', success: true, filesModified: ['a.js'] }, + { taskId: 'task-2', success: true, filesModified: ['a.js'] }, + ], + }; + + const result = await agg.aggregate(waveResults); + expect(result.conflicts).toEqual([]); + }); +}); + +// ============================================================================ +// aggregateAll +// ============================================================================ + +describe('ResultAggregator -- aggregateAll', () => { + let agg; + beforeEach(() => { agg = new ResultAggregator(); }); + + it('deve consolidar multiplas waves', async () => { + const waves = [ + { waveIndex: 0, results: [{ taskId: 'a', success: true }] }, + { waveIndex: 1, results: [{ taskId: 'b', success: true }] }, + ]; + + const result = await agg.aggregateAll(waves); + expect(result.waves).toHaveLength(2); + expect(result.allTasks).toHaveLength(2); + expect(result.overallMetrics).toHaveProperty('totalTasks'); + }); + + it('deve coletar conflitos de todas as waves', async () => { + const waves = [ + { + waveIndex: 0, + results: [ + { taskId: 'a', success: true, filesModified: ['x.js'] }, + { taskId: 'b', success: true, filesModified: ['x.js'] }, + ], + }, + ]; + + const result = await agg.aggregateAll(waves); + expect(result.allConflicts.length).toBeGreaterThan(0); + expect(result.allConflicts[0]).toHaveProperty('waveIndex'); + }); +}); diff --git a/tests/core/memory/decision-memory.test.js b/tests/core/memory/decision-memory.test.js new file mode 100644 index 0000000000..ca0b14a452 --- /dev/null +++ b/tests/core/memory/decision-memory.test.js @@ -0,0 +1,629 @@ +const path = require('path'); +const fs = require('fs'); +const { + DecisionMemory, + DecisionCategory, + Outcome, + Events, + CONFIG, +} = require('../../../.aiox-core/core/memory/decision-memory'); + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TEST HELPERS +// ═══════════════════════════════════════════════════════════════════════════════════ + +const TEST_ROOT = path.join(__dirname, '__fixtures__', 'decision-memory'); + +function createMemory(overrides = {}) { + return new DecisionMemory({ + projectRoot: TEST_ROOT, + config: { ...overrides }, + }); +} + +function cleanFixtures() { + const filePath = path.join(TEST_ROOT, CONFIG.decisionsJsonPath); + const tmpPath = `${filePath}.tmp`; + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════════════════════════ + +describe('DecisionMemory', () => { + beforeEach(() => { + cleanFixtures(); + }); + + afterAll(() => { + cleanFixtures(); + const dir = path.join(TEST_ROOT, '.aiox'); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); + if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Constructor & Loading + // ───────────────────────────────────────────────────────────────────────────── + + describe('constructor', () => { + it('should create with default config', () => { + const mem = createMemory(); + expect(mem.decisions).toEqual([]); + expect(mem.patterns).toEqual([]); + expect(mem._loaded).toBe(false); + }); + + it('should accept custom config overrides', () => { + const mem = createMemory({ maxDecisions: 100 }); + expect(mem.config.maxDecisions).toBe(100); + }); + + it('should initialize _saving flag to false', () => { + const mem = createMemory(); + expect(mem._saving).toBe(false); + }); + }); + + describe('load', () => { + it('should load from empty state', async () => { + const mem = createMemory(); + await mem.load(); + expect(mem._loaded).toBe(true); + expect(mem.decisions).toEqual([]); + }); + + it('should load persisted decisions', async () => { + const mem = createMemory(); + mem.recordDecision({ description: 'Use microservices architecture' }); + await mem.save(); + + const mem2 = createMemory(); + await mem2.load(); + expect(mem2.decisions).toHaveLength(1); + expect(mem2.decisions[0].description).toBe('Use microservices architecture'); + }); + + it('should handle corrupted file gracefully', async () => { + const filePath = path.join(TEST_ROOT, CONFIG.decisionsJsonPath); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, '{invalid json!!!', 'utf-8'); + + const mem = createMemory(); + await mem.load(); + expect(mem.decisions).toEqual([]); + }); + + it('should ignore data with wrong schema version', async () => { + const filePath = path.join(TEST_ROOT, CONFIG.decisionsJsonPath); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ + schemaVersion: 'old-version', + decisions: [{ id: 'old', description: 'old' }], + }), 'utf-8'); + + const mem = createMemory(); + await mem.load(); + expect(mem.decisions).toEqual([]); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Recording Decisions + // ───────────────────────────────────────────────────────────────────────────── + + describe('recordDecision', () => { + it('should record a basic decision', () => { + const mem = createMemory(); + const decision = mem.recordDecision({ + description: 'Delegate story creation to @sm agent', + }); + + expect(decision.id).toMatch(/^dec-/); + expect(decision.description).toBe('Delegate story creation to @sm agent'); + expect(decision.outcome).toBe(Outcome.PENDING); + expect(decision.confidence).toBe(1.0); + expect(decision.createdAt).toBeDefined(); + }); + + it('should auto-detect category from description', () => { + const mem = createMemory(); + + const arch = mem.recordDecision({ description: 'Refactor module architecture to use layered pattern' }); + expect(arch.category).toBe(DecisionCategory.ARCHITECTURE); + + const deleg = mem.recordDecision({ description: 'Delegate task to subagent for orchestration' }); + expect(deleg.category).toBe(DecisionCategory.DELEGATION); + + const test = mem.recordDecision({ description: 'Add jest unit test coverage for utils' }); + expect(test.category).toBe(DecisionCategory.TESTING); + }); + + it('should use provided category over auto-detect', () => { + const mem = createMemory(); + const d = mem.recordDecision({ + description: 'Use TypeScript', + category: DecisionCategory.TOOLING, + }); + expect(d.category).toBe(DecisionCategory.TOOLING); + }); + + it('should extract keywords from description', () => { + const mem = createMemory(); + const d = mem.recordDecision({ + description: 'Use circuit breaker pattern for API resilience', + }); + expect(d.keywords).toContain('circuit'); + expect(d.keywords).toContain('breaker'); + expect(d.keywords).toContain('pattern'); + expect(d.keywords).not.toContain('for'); // stop word + }); + + it('should throw on empty description', () => { + const mem = createMemory(); + expect(() => mem.recordDecision({ description: '' })).toThrow('description is required'); + }); + + it('should emit DECISION_RECORDED event', () => { + const mem = createMemory(); + const handler = jest.fn(); + mem.on(Events.DECISION_RECORDED, handler); + + mem.recordDecision({ description: 'Test decision' }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].description).toBe('Test decision'); + }); + + it('should record rationale and alternatives', () => { + const mem = createMemory(); + const d = mem.recordDecision({ + description: 'Use PostgreSQL over MongoDB', + rationale: 'Relational data model fits better', + alternatives: ['MongoDB', 'DynamoDB', 'SQLite'], + }); + + expect(d.rationale).toBe('Relational data model fits better'); + expect(d.alternatives).toEqual(['MongoDB', 'DynamoDB', 'SQLite']); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Outcome Updates + // ───────────────────────────────────────────────────────────────────────────── + + describe('updateOutcome', () => { + it('should update outcome and notes', () => { + const mem = createMemory(); + const d = mem.recordDecision({ description: 'Use caching layer' }); + + const updated = mem.updateOutcome(d.id, Outcome.SUCCESS, 'Reduced latency by 40%'); + expect(updated.outcome).toBe(Outcome.SUCCESS); + expect(updated.outcomeNotes).toBe('Reduced latency by 40%'); + }); + + it('should maintain or increase confidence on success', () => { + const mem = createMemory(); + const d = mem.recordDecision({ description: 'Enable compression' }); + const initial = d.confidence; + + mem.updateOutcome(d.id, Outcome.SUCCESS); + expect(d.confidence).toBeGreaterThanOrEqual(initial); + }); + + it('should decrease confidence on failure', () => { + const mem = createMemory(); + const d = mem.recordDecision({ description: 'Deploy on Friday' }); + const initial = d.confidence; + + mem.updateOutcome(d.id, Outcome.FAILURE); + expect(d.confidence).toBeLessThan(initial); + }); + + it('should not go below minimum confidence', () => { + const mem = createMemory({ minConfidence: 0.1 }); + + // Record multiple decisions and fail each one + const decisions = []; + for (let i = 0; i < 10; i++) { + const d = mem.recordDecision({ description: `Bad idea ${i}` }); + mem.updateOutcome(d.id, Outcome.FAILURE); + decisions.push(d); + } + + for (const d of decisions) { + expect(d.confidence).toBeGreaterThanOrEqual(0.1); + } + }); + + it('should return null for unknown decision ID', () => { + const mem = createMemory(); + expect(mem.updateOutcome('nonexistent', Outcome.SUCCESS)).toBeNull(); + }); + + it('should throw on invalid outcome', () => { + const mem = createMemory(); + const d = mem.recordDecision({ description: 'test' }); + expect(() => mem.updateOutcome(d.id, 'invalid')).toThrow('Invalid outcome'); + }); + + it('should throw when setting outcome to PENDING', () => { + const mem = createMemory(); + const d = mem.recordDecision({ description: 'test pending rejection' }); + expect(() => mem.updateOutcome(d.id, Outcome.PENDING)).toThrow('Invalid outcome'); + }); + + it('should emit OUTCOME_UPDATED event', () => { + const mem = createMemory(); + const handler = jest.fn(); + mem.on(Events.OUTCOME_UPDATED, handler); + + const d = mem.recordDecision({ description: 'test' }); + mem.updateOutcome(d.id, Outcome.SUCCESS); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Relevance & Context Injection + // ───────────────────────────────────────────────────────────────────────────── + + describe('getRelevantDecisions', () => { + it('should find relevant decisions by keyword similarity', () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + + const d1 = mem.recordDecision({ description: 'Use circuit breaker for API calls' }); + mem.updateOutcome(d1.id, Outcome.SUCCESS, 'Prevented cascade failures'); + + const d2 = mem.recordDecision({ description: 'Add database connection pooling' }); + mem.updateOutcome(d2.id, Outcome.SUCCESS); + + const relevant = mem.getRelevantDecisions('circuit breaker pattern for external API'); + expect(relevant.length).toBeGreaterThanOrEqual(1); + expect(relevant[0].description).toContain('circuit breaker'); + }); + + it('should exclude pending decisions', () => { + const mem = createMemory(); + mem.recordDecision({ description: 'Pending decision about testing' }); + + const relevant = mem.getRelevantDecisions('testing strategy'); + expect(relevant).toHaveLength(0); + }); + + it('should filter by category', () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d1 = mem.recordDecision({ description: 'Architecture decision about modules', category: DecisionCategory.ARCHITECTURE }); + mem.updateOutcome(d1.id, Outcome.SUCCESS); + const d2 = mem.recordDecision({ description: 'Testing decision about modules', category: DecisionCategory.TESTING }); + mem.updateOutcome(d2.id, Outcome.SUCCESS); + + const relevant = mem.getRelevantDecisions('modules', { category: DecisionCategory.ARCHITECTURE }); + expect(relevant.every(d => d.category === DecisionCategory.ARCHITECTURE)).toBe(true); + }); + + it('should filter successOnly when requested', () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d1 = mem.recordDecision({ description: 'Good deploy strategy' }); + mem.updateOutcome(d1.id, Outcome.SUCCESS); + const d2 = mem.recordDecision({ description: 'Bad deploy strategy' }); + mem.updateOutcome(d2.id, Outcome.FAILURE); + + const relevant = mem.getRelevantDecisions('deploy strategy', { successOnly: true }); + expect(relevant.every(d => d.outcome === Outcome.SUCCESS)).toBe(true); + }); + + it('should gate on raw keyword similarity, not blended score', () => { + const mem = createMemory({ similarityThreshold: 0.5 }); + + // Decision with unrelated keywords but resolved outcome + const d1 = mem.recordDecision({ description: 'database indexing optimization tuning' }); + mem.updateOutcome(d1.id, Outcome.SUCCESS); + + // Query with completely different keywords + const relevant = mem.getRelevantDecisions('authentication login security tokens'); + expect(relevant).toHaveLength(0); + }); + }); + + describe('injectDecisionContext', () => { + it('should return empty string when no relevant decisions', () => { + const mem = createMemory(); + expect(mem.injectDecisionContext('something unrelated')).toBe(''); + }); + + it('should format relevant decisions as markdown', () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d = mem.recordDecision({ + description: 'Use retry with exponential backoff', + rationale: 'Prevents thundering herd', + }); + mem.updateOutcome(d.id, Outcome.SUCCESS, 'Worked perfectly'); + + const context = mem.injectDecisionContext('retry strategy for API calls'); + expect(context).toContain('Relevant Past Decisions'); + expect(context).toContain('exponential backoff'); + expect(context).toContain('[OK]'); + }); + + it('should escape markdown control characters in injected fields', () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const d = mem.recordDecision({ + description: 'Use caching for ```code``` blocks', + rationale: '# Heading injection attempt', + }); + mem.updateOutcome(d.id, Outcome.SUCCESS, '> Blockquote injection'); + + const context = mem.injectDecisionContext('caching code blocks strategy'); + // Code fences and headings should be escaped + expect(context).not.toContain('```code```'); + expect(context).not.toMatch(/^# Heading/m); + }); + + it('should emit DECISIONS_INJECTED event', () => { + const mem = createMemory({ similarityThreshold: 0.1 }); + const handler = jest.fn(); + mem.on(Events.DECISIONS_INJECTED, handler); + + const d = mem.recordDecision({ description: 'caching strategy for data' }); + mem.updateOutcome(d.id, Outcome.SUCCESS); + mem.injectDecisionContext('data caching approach'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Pattern Detection + // ───────────────────────────────────────────────────────────────────────────── + + describe('pattern detection', () => { + it('should detect pattern after threshold occurrences', () => { + const mem = createMemory({ patternThreshold: 3, similarityThreshold: 0.1 }); + const handler = jest.fn(); + mem.on(Events.PATTERN_DETECTED, handler); + + mem.recordDecision({ description: 'Use circuit breaker for service A' }); + mem.recordDecision({ description: 'Use circuit breaker for service B' }); + mem.recordDecision({ description: 'Use circuit breaker for service C' }); + + expect(handler).toHaveBeenCalled(); + expect(mem.getPatterns().length).toBeGreaterThanOrEqual(1); + }); + + it('should use neutral recommendation when no outcomes exist', () => { + const mem = createMemory({ patternThreshold: 3, similarityThreshold: 0.1 }); + + mem.recordDecision({ description: 'Use circuit breaker for service A' }); + mem.recordDecision({ description: 'Use circuit breaker for service B' }); + mem.recordDecision({ description: 'Use circuit breaker for service C' }); + + const patterns = mem.getPatterns(); + expect(patterns.length).toBeGreaterThanOrEqual(1); + expect(patterns[0].recommendation).toContain('No outcome data yet'); + }); + + it('should not duplicate patterns', () => { + const mem = createMemory({ patternThreshold: 3 }); + + for (let i = 0; i < 6; i++) { + mem.recordDecision({ description: `Use retry pattern for service ${i}` }); + } + + const patterns = mem.getPatterns(); + // Should not have multiple identical patterns + const unique = new Set(patterns.map(p => p.category)); + expect(patterns.length).toBeLessThanOrEqual(unique.size + 1); + }); + + it('should recompute pattern recommendation when outcomes change', () => { + const mem = createMemory({ patternThreshold: 3, similarityThreshold: 0.1 }); + + const d1 = mem.recordDecision({ description: 'Use circuit breaker for service A' }); + const d2 = mem.recordDecision({ description: 'Use circuit breaker for service B' }); + mem.recordDecision({ description: 'Use circuit breaker for service C' }); + + // Initially neutral + const patternsBefore = mem.getPatterns(); + expect(patternsBefore[0].recommendation).toContain('No outcome data yet'); + + // Mark outcomes + mem.updateOutcome(d1.id, Outcome.SUCCESS); + mem.updateOutcome(d2.id, Outcome.SUCCESS); + + const patternsAfter = mem.getPatterns(); + expect(patternsAfter[0].recommendation).toContain('worked well'); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Stats & Listing + // ───────────────────────────────────────────────────────────────────────────── + + describe('getStats', () => { + it('should return correct statistics', () => { + const mem = createMemory(); + const d1 = mem.recordDecision({ description: 'Decision 1' }); + const d2 = mem.recordDecision({ description: 'Decision 2' }); + const d3 = mem.recordDecision({ description: 'Decision 3' }); + + mem.updateOutcome(d1.id, Outcome.SUCCESS); + mem.updateOutcome(d2.id, Outcome.FAILURE); + + const stats = mem.getStats(); + expect(stats.total).toBe(3); + expect(stats.byOutcome[Outcome.SUCCESS]).toBe(1); + expect(stats.byOutcome[Outcome.FAILURE]).toBe(1); + expect(stats.byOutcome[Outcome.PENDING]).toBe(1); + expect(stats.successRate).toBe(50); + }); + }); + + describe('listDecisions', () => { + it('should list decisions in reverse chronological order', () => { + const mem = createMemory(); + mem.recordDecision({ description: 'First' }); + mem.recordDecision({ description: 'Second' }); + mem.recordDecision({ description: 'Third' }); + + const list = mem.listDecisions(); + expect(list[0].description).toBe('Third'); + expect(list[2].description).toBe('First'); + }); + + it('should respect limit', () => { + const mem = createMemory(); + for (let i = 0; i < 10; i++) { + mem.recordDecision({ description: `Decision ${i}` }); + } + + const list = mem.listDecisions({ limit: 3 }); + expect(list).toHaveLength(3); + }); + + it('should filter by category', () => { + const mem = createMemory(); + mem.recordDecision({ description: 'Architecture choice', category: DecisionCategory.ARCHITECTURE }); + mem.recordDecision({ description: 'Testing choice', category: DecisionCategory.TESTING }); + + const list = mem.listDecisions({ category: DecisionCategory.ARCHITECTURE }); + expect(list).toHaveLength(1); + expect(list[0].category).toBe(DecisionCategory.ARCHITECTURE); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Persistence + // ───────────────────────────────────────────────────────────────────────────── + + describe('save & load roundtrip', () => { + it('should persist and restore full state', async () => { + const mem = createMemory(); + const d = mem.recordDecision({ + description: 'Use event-driven architecture', + rationale: 'Decouples components', + alternatives: ['REST', 'gRPC'], + agentId: 'cto', + }); + mem.updateOutcome(d.id, Outcome.SUCCESS, 'Clean separation achieved'); + await mem.save(); + + const mem2 = createMemory(); + await mem2.load(); + + expect(mem2.decisions).toHaveLength(1); + expect(mem2.decisions[0].description).toBe('Use event-driven architecture'); + expect(mem2.decisions[0].outcome).toBe(Outcome.SUCCESS); + expect(mem2.decisions[0].rationale).toBe('Decouples components'); + expect(mem2.decisions[0].alternatives).toEqual(['REST', 'gRPC']); + }); + + it('should cap decisions at maxDecisions on save', async () => { + const mem = createMemory({ maxDecisions: 5 }); + + for (let i = 0; i < 10; i++) { + mem.recordDecision({ description: `Decision ${i}` }); + } + + await mem.save(); + + const mem2 = createMemory({ maxDecisions: 5 }); + await mem2.load(); + expect(mem2.decisions.length).toBeLessThanOrEqual(5); + }); + + it('should also cap in-memory decisions after save', async () => { + const mem = createMemory({ maxDecisions: 5 }); + + for (let i = 0; i < 10; i++) { + mem.recordDecision({ description: `Decision ${i}` }); + } + + expect(mem.decisions.length).toBe(10); + await mem.save(); + expect(mem.decisions.length).toBeLessThanOrEqual(5); + }); + + it('should emit save:error on write failure', async () => { + const mem = createMemory(); + mem.recordDecision({ description: 'test save error' }); + + const handler = jest.fn(); + mem.on('save:error', handler); + + // Override projectRoot to an invalid path to trigger write error + mem.projectRoot = '/nonexistent/invalid/path'; + await mem.save(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].error).toBeDefined(); + }); + + it('should skip concurrent saves', async () => { + const mem = createMemory(); + mem.recordDecision({ description: 'concurrent save test' }); + + // Simulate concurrent save by setting _saving flag + mem._saving = true; + await mem.save(); // Should return immediately + mem._saving = false; + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Time Decay + // ───────────────────────────────────────────────────────────────────────────── + + describe('confidence decay', () => { + it('should decay confidence over time', () => { + const mem = createMemory({ confidenceDecayDays: 30 }); + const oldDate = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(); // 20 days ago + const decayed = mem._applyTimeDecay(1.0, oldDate); + + expect(decayed).toBeLessThan(1.0); + expect(decayed).toBeGreaterThan(0); + }); + + it('should not decay recent decisions', () => { + const mem = createMemory({ confidenceDecayDays: 30 }); + const recent = new Date().toISOString(); + const decayed = mem._applyTimeDecay(1.0, recent); + + expect(decayed).toBeCloseTo(1.0, 1); + }); + + it('should never return below minConfidence', () => { + const mem = createMemory({ confidenceDecayDays: 1, minConfidence: 0.1 }); + const veryOld = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const decayed = mem._applyTimeDecay(0.4, veryOld); + + expect(decayed).toBeGreaterThanOrEqual(0.1); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Lazy Loading Guard + // ───────────────────────────────────────────────────────────────────────────── + + describe('_ensureLoaded', () => { + it('should lazy-load on first call', async () => { + const mem = createMemory(); + expect(mem._loaded).toBe(false); + await mem._ensureLoaded(); + expect(mem._loaded).toBe(true); + }); + + it('should not reload if already loaded', async () => { + const mem = createMemory(); + await mem.load(); + const loadSpy = jest.spyOn(mem, 'load'); + await mem._ensureLoaded(); + expect(loadSpy).not.toHaveBeenCalled(); + loadSpy.mockRestore(); + }); + }); +});