From d04805005ef2797b7ffe8ad786ecd43ca8c10de7 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 6 Mar 2026 23:05:51 -0300 Subject: [PATCH 1/7] feat(memory): add decision-memory module for cross-session agent learning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 9.5 of Epic 9 (Persistent Memory Layer). Implements Phase 2 of the Agent Immortality Protocol (#482) — Persistence layer. Features: - Record decisions with context, rationale, and alternatives - Track outcomes (success/partial/failure) with confidence scoring - Auto-detect categories from description keywords - Find relevant past decisions for context injection (AC7) - Pattern detection across recurring decisions (AC9) - Time-based confidence decay for relevance scoring - Persistence to .aiox/decisions.json 37 unit tests covering all features. --- .aiox-core/core/memory/decision-memory.js | 549 ++++++++++++++++++++++ .aiox-core/install-manifest.yaml | 108 +++-- tests/core/memory/decision-memory.test.js | 493 +++++++++++++++++++ 3 files changed, 1098 insertions(+), 52 deletions(-) create mode 100644 .aiox-core/core/memory/decision-memory.js create mode 100644 tests/core/memory/decision-memory.test.js diff --git a/.aiox-core/core/memory/decision-memory.js b/.aiox-core/core/memory/decision-memory.js new file mode 100644 index 000000000..bc6fd3c4a --- /dev/null +++ b/.aiox-core/core/memory/decision-memory.js @@ -0,0 +1,549 @@ +#!/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 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', 'deploy', 'release', + '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; + } + + /** + * 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; + } + + /** + * Save decisions to disk + * @returns {Promise} + */ + async save() { + const filePath = path.resolve(this.projectRoot, this.config.decisionsJsonPath); + const dir = path.dirname(filePath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data = { + schemaVersion: this.config.schemaVersion, + version: this.config.version, + updatedAt: new Date().toISOString(), + stats: this.getStats(), + decisions: this.decisions.slice(-this.config.maxDecisions), + patterns: this.patterns, + }; + + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } + + /** + * 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; + + if (!Object.values(Outcome).includes(outcome)) { + throw new Error(`Invalid outcome: ${outcome}. Use: ${Object.values(Outcome).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); + } + + 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 + 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, + score: (similarity * 0.6) + (decayed * 0.25) + (outcomeBonus * 0.15), + }; + }); + + return scored + .filter(s => s.score >= 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) + * @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 ? '✅' : + d.outcome === Outcome.FAILURE ? '❌' : '⚠️'; + + lines.push(`### ${outcomeIcon} ${d.description}`); + if (d.rationale) lines.push(`**Rationale:** ${d.rationale}`); + if (d.outcomeNotes) lines.push(`**Outcome:** ${d.outcomeNotes}`); + 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) { + const stopWords = 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', + ]); + + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 2 && !stopWords.has(w)) + .slice(0, 20); + } + + /** + * Calculate keyword similarity between two keyword sets + * @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 + * @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 = Math.max( + this.config.minConfidence, + 1 - (ageDays / this.config.confidenceDecayDays) * 0.5, + ); + + return confidence * decayFactor; + } + + /** + * Detect recurring patterns in decisions (AC9) + * @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) > 0.4, + ); + + 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; + + 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: successCount > failureCount + ? 'This approach has historically worked well. Consider reusing.' + : 'This approach has historically underperformed. Consider alternatives.', + 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); + } + } + } + + /** + * 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 2516e4c29..87e59724b 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-10T17:08:07.160Z" +generated_at: "2026-03-11T02:23:43.232Z" generator: scripts/generate-install-manifest.js -file_count: 1089 +file_count: 1090 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:7c9189410dffa771db9866e40f1ccb4b8c3bac5d57b4a20f2ef99235ae71ad42 + type: core + size: 18911 - path: core/memory/gotchas-memory.js hash: sha256:0063eff42caf0dda759c0390ac323e7b102f5507f27b8beb7b0acc2fbec407c4 type: core @@ -2583,19 +2587,19 @@ files: - path: development/templates/service-template/__tests__/index.test.ts.hbs hash: sha256:4617c189e75ab362d4ef2cabcc3ccce3480f914fd915af550469c17d1b68a4fe type: template - size: 9810 + size: 9573 - path: development/templates/service-template/client.ts.hbs hash: sha256:f342c60695fe611192002bdb8c04b3a0dbce6345b7fa39834ea1898f71689198 type: template - size: 12213 + size: 11810 - path: development/templates/service-template/errors.ts.hbs hash: sha256:e0be40d8be19b71b26e35778eadffb20198e7ca88e9d140db9da1bfe12de01ec type: template - size: 5395 + size: 5213 - path: development/templates/service-template/index.ts.hbs hash: sha256:d44012d54b76ab98356c7163d257ca939f7fed122f10fecf896fe1e7e206d10a type: template - size: 3206 + size: 3086 - path: development/templates/service-template/jest.config.js hash: sha256:1681bfd7fbc0d330d3487d3427515847c4d57ef300833f573af59e0ad69ed159 type: template @@ -2603,11 +2607,11 @@ files: - path: development/templates/service-template/package.json.hbs hash: sha256:d89d35f56992ee95c2ceddf17fa1d455c18007a4d24af914ba83cf4abc38bca9 type: template - size: 2314 + size: 2227 - path: development/templates/service-template/README.md.hbs hash: sha256:2c3dd4c2bf6df56b9b6db439977be7e1cc35820438c0e023140eccf6ccd227a0 type: template - size: 3584 + size: 3426 - path: development/templates/service-template/tsconfig.json hash: sha256:8b465fcbdd45c4d6821ba99aea62f2bd7998b1bca8de80486a1525e77d43c9a1 type: template @@ -2615,7 +2619,7 @@ files: - path: development/templates/service-template/types.ts.hbs hash: sha256:3e52e0195003be8cd1225a3f27f4d040686c8b8c7762f71b41055f04cd1b841b type: template - size: 2661 + size: 2516 - path: development/templates/squad-template/agents/example-agent.yaml hash: sha256:824a1b349965e5d4ae85458c231b78260dc65497da75dada25b271f2cabbbe67 type: agent @@ -2623,7 +2627,7 @@ files: - path: development/templates/squad-template/LICENSE hash: sha256:ff7017aa403270cf2c440f5ccb4240d0b08e54d8bf8a0424d34166e8f3e10138 type: template - size: 1092 + size: 1071 - path: development/templates/squad-template/package.json hash: sha256:8f68627a0d74e49f94ae382d0c2b56ecb5889d00f3095966c742fb5afaf363db type: template @@ -3367,11 +3371,11 @@ files: - path: infrastructure/templates/aiox-sync.yaml.template hash: sha256:0040ad8a9e25716a28631b102c9448b72fd72e84f992c3926eb97e9e514744bb type: template - size: 8567 + size: 8385 - path: infrastructure/templates/coderabbit.yaml.template hash: sha256:91a4a76bbc40767a4072fb6a87c480902bb800cfb0a11e9fc1b3183d8f7f3a80 type: template - size: 8321 + size: 8042 - path: infrastructure/templates/core-config/core-config-brownfield.tmpl.yaml hash: sha256:9bdb0c0e09c765c991f9f142921f7f8e2c0d0ada717f41254161465dc0622d02 type: template @@ -3383,11 +3387,11 @@ files: - path: infrastructure/templates/github-workflows/ci.yml.template hash: sha256:acbfa2a8a84141fd6a6b205eac74719772f01c221c0afe22ce951356f06a605d type: template - size: 5089 + size: 4920 - path: infrastructure/templates/github-workflows/pr-automation.yml.template hash: sha256:c236077b4567965a917e48df9a91cc42153ff97b00a9021c41a7e28179be9d0f type: template - size: 10939 + size: 10609 - path: infrastructure/templates/github-workflows/README.md hash: sha256:6b7b5cb32c28b3e562c81a96e2573ea61849b138c93ccac6e93c3adac26cadb5 type: template @@ -3395,23 +3399,23 @@ files: - path: infrastructure/templates/github-workflows/release.yml.template hash: sha256:b771145e61a254a88dc6cca07869e4ece8229ce18be87132f59489cdf9a66ec6 type: template - size: 6791 + size: 6595 - path: infrastructure/templates/gitignore/gitignore-aiox-base.tmpl hash: sha256:9088975ee2bf4d88e23db6ac3ea5d27cccdc72b03db44450300e2f872b02e935 type: template - size: 851 + size: 788 - path: infrastructure/templates/gitignore/gitignore-brownfield-merge.tmpl hash: sha256:ce4291a3cf5677050c9dafa320809e6b0ca5db7e7f7da0382d2396e32016a989 type: template - size: 506 + size: 488 - path: infrastructure/templates/gitignore/gitignore-node.tmpl hash: sha256:5179f78de7483274f5d7182569229088c71934db1fd37a63a40b3c6b815c9c8e type: template - size: 1036 + size: 951 - path: infrastructure/templates/gitignore/gitignore-python.tmpl hash: sha256:d7aac0b1e6e340b774a372a9102b4379722588449ca82ac468cf77804bbc1e55 type: template - size: 1725 + size: 1580 - path: infrastructure/templates/project-docs/coding-standards-tmpl.md hash: sha256:377acf85463df8ac9923fc59d7cfeba68a82f8353b99948ea1d28688e88bc4a9 type: template @@ -3507,43 +3511,43 @@ files: - path: monitor/hooks/lib/__init__.py hash: sha256:bfab6ee249c52f412c02502479da649b69d044938acaa6ab0aa39dafe6dee9bf type: monitor - size: 30 + size: 29 - path: monitor/hooks/lib/enrich.py hash: sha256:20dfa73b4b20d7a767e52c3ec90919709c4447c6e230902ba797833fc6ddc22c type: monitor - size: 1702 + size: 1644 - path: monitor/hooks/lib/send_event.py hash: sha256:59d61311f718fb373a5cf85fd7a01c23a4fd727e8e022ad6930bba533ef4615d type: monitor - size: 1237 + size: 1190 - path: monitor/hooks/notification.py hash: sha256:8a1a6ce0ff2b542014de177006093b9caec9b594e938a343dc6bd62df2504f22 type: monitor - size: 528 + size: 499 - path: monitor/hooks/post_tool_use.py hash: sha256:47dbe37073d432c55657647fc5b907ddb56efa859d5c3205e8362aa916d55434 type: monitor - size: 1185 + size: 1140 - path: monitor/hooks/pre_compact.py hash: sha256:f287cf45e83deed6f1bc0e30bd9348dfa1bf08ad770c5e58bb34e3feb210b30b type: monitor - size: 529 + size: 500 - path: monitor/hooks/pre_tool_use.py hash: sha256:a4d1d3ffdae9349e26a383c67c9137effff7d164ac45b2c87eea9fa1ab0d6d98 type: monitor - size: 1021 + size: 981 - path: monitor/hooks/stop.py hash: sha256:edb382f0cf46281a11a8588bc20eafa7aa2b5cc3f4ad775d71b3d20a7cfab385 type: monitor - size: 519 + size: 490 - path: monitor/hooks/subagent_stop.py hash: sha256:fa5357309247c71551dba0a19f28dd09bebde749db033d6657203b50929c0a42 type: monitor - size: 541 + size: 512 - path: monitor/hooks/user_prompt_submit.py hash: sha256:af57dca79ef55cdf274432f4abb4c20a9778b95e107ca148f47ace14782c5828 type: monitor - size: 856 + size: 818 - path: package.json hash: sha256:9fdf0dcee2dcec6c0643634ee384ba181ad077dcff1267d8807434d4cb4809c7 type: other @@ -3691,7 +3695,7 @@ files: - path: product/templates/adr.hbs hash: sha256:d68653cae9e64414ad4f58ea941b6c6e337c5324c2c7247043eca1461a652d10 type: template - size: 2337 + size: 2212 - path: product/templates/agent-template.yaml hash: sha256:98676fcc493c0d5f09264dcc52fcc2cf1129f9a195824ecb4c2ec035c2515121 type: template @@ -3743,7 +3747,7 @@ files: - path: product/templates/dbdr.hbs hash: sha256:5a2781ffaa3da9fc663667b5a63a70b7edfc478ed14cad02fc6ed237ff216315 type: template - size: 4380 + size: 4139 - path: product/templates/design-story-tmpl.yaml hash: sha256:2bfefc11ae2bcfc679dbd924c58f8b764fa23538c14cb25344d6edef41968f29 type: template @@ -3807,7 +3811,7 @@ files: - path: product/templates/epic.hbs hash: sha256:dcbcc26f6dd8f3782b3ef17aee049b689f1d6d92931615c3df9513eca0de2ef7 type: template - size: 4080 + size: 3868 - path: product/templates/eslintrc-security.json hash: sha256:657d40117261d6a52083984d29f9f88e79040926a64aa4c2058a602bfe91e0d5 type: template @@ -3915,7 +3919,7 @@ files: - path: product/templates/pmdr.hbs hash: sha256:d529cebbb562faa82c70477ece70de7cda871eaa6896f2962b48b2a8b67b1cbe type: template - size: 3425 + size: 3239 - path: product/templates/prd-tmpl.yaml hash: sha256:25c239f40e05f24aee1986601a98865188dbe3ea00a705028efc3adad6d420f3 type: template @@ -3923,11 +3927,11 @@ files: - path: product/templates/prd-v2.0.hbs hash: sha256:21a20ef5333a85a11f5326d35714e7939b51bab22bd6e28d49bacab755763bea type: template - size: 4728 + size: 4512 - path: product/templates/prd.hbs hash: sha256:4a1a030a5388c6a8bf2ce6ea85e54cae6cf1fe64f1bb2af7f17d349d3c24bf1d type: template - size: 3626 + size: 3425 - path: product/templates/project-brief-tmpl.yaml hash: sha256:b8d388268c24dc5018f48a87036d591b11cb122fafe9b59c17809b06ea5d9d58 type: template @@ -3975,7 +3979,7 @@ files: - path: product/templates/story.hbs hash: sha256:3f0ac8b39907634a2b53f43079afc33663eee76f46e680d318ff253e0befc2c4 type: template - size: 5846 + size: 5583 - path: product/templates/task-execution-report.md hash: sha256:e0f08a3e199234f3d2207ba8f435786b7d8e1b36174f46cb82fc3666b9a9309e type: template @@ -3987,67 +3991,67 @@ files: - path: product/templates/task.hbs hash: sha256:621e987e142c455cd290dc85d990ab860faa0221f66cf1f57ac296b076889ea5 type: template - size: 2875 + size: 2705 - path: product/templates/tmpl-comment-on-examples.sql hash: sha256:254002c3fbc63cfcc5848b1d4b15822ce240bf5f57e6a1c8bb984e797edc2691 type: template - size: 6373 + size: 6215 - path: product/templates/tmpl-migration-script.sql hash: sha256:44ef63ea475526d21a11e3c667c9fdb78a9fddace80fdbaa2312b7f2724fbbb5 type: template - size: 3038 + size: 2947 - path: product/templates/tmpl-rls-granular-policies.sql hash: sha256:36c2fd8c6d9eebb5d164acb0fb0c87bc384d389264b4429ce21e77e06318f5f3 type: template - size: 3426 + size: 3322 - path: product/templates/tmpl-rls-kiss-policy.sql hash: sha256:5210d37fce62e5a9a00e8d5366f5f75653cd518be73fbf96333ed8a6712453c7 type: template - size: 309 + size: 299 - path: product/templates/tmpl-rls-roles.sql hash: sha256:2d032a608a8e87440c3a430c7d69ddf9393d8813d8d4129270f640dd847425c3 type: template - size: 4727 + size: 4592 - path: product/templates/tmpl-rls-simple.sql hash: sha256:f67af0fa1cdd2f2af9eab31575ac3656d82457421208fd9ccb8b57ca9785275e type: template - size: 2992 + size: 2915 - path: product/templates/tmpl-rls-tenant.sql hash: sha256:36629ed87a2c72311809cc3fb96298b6f38716bba35bc56c550ac39d3321757a type: template - size: 5130 + size: 4978 - path: product/templates/tmpl-rollback-script.sql hash: sha256:8b84046a98f1163faf7350322f43831447617c5a63a94c88c1a71b49804e022b type: template - size: 2734 + size: 2657 - path: product/templates/tmpl-seed-data.sql hash: sha256:a65e73298f46cd6a8e700f29b9d8d26e769e12a57751a943a63fd0fe15768615 type: template - size: 5716 + size: 5576 - path: product/templates/tmpl-smoke-test.sql hash: sha256:aee7e48bb6d9c093769dee215cacc9769939501914e20e5ea8435b25fad10f3c type: template - size: 739 + size: 723 - path: product/templates/tmpl-staging-copy-merge.sql hash: sha256:55988caeb47cc04261665ba7a37f4caa2aa5fac2e776fdbc5964e0587af24450 type: template - size: 4220 + size: 4081 - path: product/templates/tmpl-stored-proc.sql hash: sha256:2b205ff99dc0adfade6047a4d79f5b50109e50ceb45386e5c886437692c7a2a3 type: template - size: 3979 + size: 3839 - path: product/templates/tmpl-trigger.sql hash: sha256:93abdc92e1b475d1370094e69a9d1b18afd804da6acb768b878355c798bd8e0e type: template - size: 5424 + size: 5272 - path: product/templates/tmpl-view-materialized.sql hash: sha256:47935510f03d4ad9b2200748e65441ce6c2d6a7c74750395eca6831d77c48e91 type: template - size: 4496 + size: 4363 - path: product/templates/tmpl-view.sql hash: sha256:22557b076003a856b32397f05fa44245a126521de907058a95e14dd02da67aff type: template - size: 5093 + size: 4916 - path: product/templates/token-exports-css-tmpl.css hash: sha256:d937b8d61cdc9e5b10fdff871c6cb41c9f756004d060d671e0ae26624a047f62 type: template diff --git a/tests/core/memory/decision-memory.test.js b/tests/core/memory/decision-memory.test.js new file mode 100644 index 000000000..bce3142cf --- /dev/null +++ b/tests/core/memory/decision-memory.test.js @@ -0,0 +1,493 @@ +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); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// 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); + }); + }); + + 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 increase confidence on success (up to cap)', () => { + const mem = createMemory(); + const d = mem.recordDecision({ description: 'Enable compression' }); + + // Reduce confidence first via a failure, then verify success increases it + mem.updateOutcome(d.id, Outcome.FAILURE); + const afterFailure = d.confidence; + + d.outcome = Outcome.PENDING; // reset to allow re-update + mem.updateOutcome(d.id, Outcome.SUCCESS); + expect(d.confidence).toBeGreaterThan(afterFailure); + }); + + 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 }); + const d = mem.recordDecision({ description: 'Bad idea' }); + + // Multiple failures + for (let i = 0; i < 10; i++) { + d.outcome = Outcome.PENDING; + mem.updateOutcome(d.id, Outcome.FAILURE); + } + + 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 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); + }); + }); + + 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('✅'); + }); + + 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 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); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 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); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 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); + }); + }); +}); From dc8b007b64d34a81181ca59ff00e0332c4b960f1 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Wed, 25 Feb 2026 23:48:14 -0300 Subject: [PATCH 2/7] fix(ideation): corrige import named do GotchasMemory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gotchas-memory.js exporta named export { GotchasMemory, ... }, mas ideation-engine.js atribuía o objeto module inteiro à variável. Resultado: new GotchasMemory() lançava TypeError silenciado pelo try/catch, e gotchasMemory ficava sempre null. Corrige com destructuring: ({ GotchasMemory } = require(...)) Closes #517 --- .aiox-core/core/ideation/ideation-engine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.aiox-core/core/ideation/ideation-engine.js b/.aiox-core/core/ideation/ideation-engine.js index 1baea2c5f..0e31648b8 100644 --- a/.aiox-core/core/ideation/ideation-engine.js +++ b/.aiox-core/core/ideation/ideation-engine.js @@ -13,7 +13,7 @@ const { execSync } = require('child_process'); // Import dependencies with fallbacks let GotchasMemory; try { - GotchasMemory = require('../memory/gotchas-memory'); + ({ GotchasMemory } = require('../memory/gotchas-memory')); } catch { GotchasMemory = null; } From 7bf0f6590d2508c83c4f79a4c3e519efc3fa7482 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 27 Feb 2026 12:19:33 -0300 Subject: [PATCH 3/7] =?UTF-8?q?fix(core):=20corrige=20import=20GotchasMemo?= =?UTF-8?q?ry=20em=203=20m=C3=B3dulos=20+=20log=20no=20catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit encontrou o mesmo bug de named export em context-injector.js e subagent-dispatcher.js. Aplica destructuring ({ GotchasMemory }) nos 3 módulos e adiciona console.warn no catch para facilitar diagnóstico. Módulos corrigidos: - ideation-engine.js (catch vazio → console.warn) - context-injector.js (import + catch) - subagent-dispatcher.js (import + catch) Regenera install-manifest.yaml. --- .aiox-core/core/execution/context-injector.js | 5 +++-- .aiox-core/core/execution/subagent-dispatcher.js | 5 +++-- .aiox-core/core/ideation/ideation-engine.js | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.aiox-core/core/execution/context-injector.js b/.aiox-core/core/execution/context-injector.js index 8be0c1f90..2f46a10d8 100644 --- a/.aiox-core/core/execution/context-injector.js +++ b/.aiox-core/core/execution/context-injector.js @@ -21,8 +21,9 @@ try { MemoryQuery = null; } try { - GotchasMemory = require('../memory/gotchas-memory'); -} catch { + ({ GotchasMemory } = require('../memory/gotchas-memory')); +} catch (error) { + console.warn('[ContextInjector] Failed to load GotchasMemory:', error.message); GotchasMemory = null; } try { diff --git a/.aiox-core/core/execution/subagent-dispatcher.js b/.aiox-core/core/execution/subagent-dispatcher.js index dc5bc2678..b4cc62407 100644 --- a/.aiox-core/core/execution/subagent-dispatcher.js +++ b/.aiox-core/core/execution/subagent-dispatcher.js @@ -28,8 +28,9 @@ try { MemoryQuery = null; } try { - GotchasMemory = require('../memory/gotchas-memory'); -} catch { + ({ GotchasMemory } = require('../memory/gotchas-memory')); +} catch (error) { + console.warn('[SubagentDispatcher] Failed to load GotchasMemory:', error.message); GotchasMemory = null; } diff --git a/.aiox-core/core/ideation/ideation-engine.js b/.aiox-core/core/ideation/ideation-engine.js index 0e31648b8..6153fe71b 100644 --- a/.aiox-core/core/ideation/ideation-engine.js +++ b/.aiox-core/core/ideation/ideation-engine.js @@ -14,7 +14,8 @@ const { execSync } = require('child_process'); let GotchasMemory; try { ({ GotchasMemory } = require('../memory/gotchas-memory')); -} catch { +} catch (error) { + console.warn('[IdeationEngine] Failed to load GotchasMemory:', error.message); GotchasMemory = null; } From 828a4e7ee5bef372eda9855f1e9991897185c1de Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 27 Feb 2026 12:22:56 -0300 Subject: [PATCH 4/7] test: adiciona regression test para GotchasMemory import (#517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifica que IdeationEngine instancia GotchasMemory corretamente via named export. Cobre 3 cenários: - Auto-criação quando config.gotchasMemory omitido - Uso do gotchasMemory fornecido via config - Fallback para null quando módulo falha ao carregar --- .../ideation/ideation-engine-gotchas.test.js | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/core/ideation/ideation-engine-gotchas.test.js diff --git a/tests/core/ideation/ideation-engine-gotchas.test.js b/tests/core/ideation/ideation-engine-gotchas.test.js new file mode 100644 index 000000000..c7f90e486 --- /dev/null +++ b/tests/core/ideation/ideation-engine-gotchas.test.js @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Regression test for GotchasMemory import in IdeationEngine. + * + * Verifies that the constructor correctly uses the named export + * ({ GotchasMemory }) instead of the raw module object. + * See: https://github.com/SynkraAI/aios-core/issues/517 + */ + +const path = require('path'); + +// Mock fs and child_process to prevent real filesystem access +jest.mock('fs'); +jest.mock('child_process'); + +describe('IdeationEngine — GotchasMemory import regression (#517)', () => { + let IdeationEngine; + let GotchasMemory; + + beforeEach(() => { + jest.resetModules(); + }); + + test('auto-creates GotchasMemory instance when config.gotchasMemory is omitted', () => { + // Require fresh to get the module-level import + IdeationEngine = require('../../../.aios-core/core/ideation/ideation-engine'); + ({ GotchasMemory } = require('../../../.aios-core/core/memory/gotchas-memory')); + + const engine = new IdeationEngine({ rootPath: '/tmp/test' }); + + // Before the fix, gotchasMemory was always null because + // the bare require() returned the module object, not the class + expect(engine.gotchasMemory).not.toBeNull(); + expect(engine.gotchasMemory).toBeInstanceOf(GotchasMemory); + }); + + test('uses provided gotchasMemory when passed via config', () => { + IdeationEngine = require('../../../.aios-core/core/ideation/ideation-engine'); + + const customMemory = { getAll: jest.fn().mockReturnValue([]) }; + const engine = new IdeationEngine({ + rootPath: '/tmp/test', + gotchasMemory: customMemory, + }); + + expect(engine.gotchasMemory).toBe(customMemory); + }); + + test('sets gotchasMemory to null when module fails to load', () => { + // Mock the gotchas-memory module to throw on require + jest.doMock('../../../.aios-core/core/memory/gotchas-memory', () => { + throw new Error('Module not found'); + }); + + // Suppress the expected console.warn + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + IdeationEngine = require('../../../.aios-core/core/ideation/ideation-engine'); + const engine = new IdeationEngine({ rootPath: '/tmp/test' }); + + expect(engine.gotchasMemory).toBeNull(); + + console.warn.mockRestore(); + }); +}); From 9caf3fd06c2c43d1de0d289e52c9627e4472d718 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 27 Feb 2026 12:55:36 -0300 Subject: [PATCH 5/7] fix(tests): try/finally no mockRestore do console.warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Garante que o spy do console.warn é restaurado mesmo se a asserção falhar, evitando leak entre testes. --- .../core/ideation/ideation-engine-gotchas.test.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/core/ideation/ideation-engine-gotchas.test.js b/tests/core/ideation/ideation-engine-gotchas.test.js index c7f90e486..299bd0957 100644 --- a/tests/core/ideation/ideation-engine-gotchas.test.js +++ b/tests/core/ideation/ideation-engine-gotchas.test.js @@ -54,13 +54,15 @@ describe('IdeationEngine — GotchasMemory import regression (#517)', () => { }); // Suppress the expected console.warn - jest.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - IdeationEngine = require('../../../.aios-core/core/ideation/ideation-engine'); - const engine = new IdeationEngine({ rootPath: '/tmp/test' }); - - expect(engine.gotchasMemory).toBeNull(); + try { + IdeationEngine = require('../../../.aios-core/core/ideation/ideation-engine'); + const engine = new IdeationEngine({ rootPath: '/tmp/test' }); - console.warn.mockRestore(); + expect(engine.gotchasMemory).toBeNull(); + } finally { + warnSpy.mockRestore(); + } }); }); From d15bdc92fe604512cb150af9016452d6109fda17 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Tue, 10 Mar 2026 11:25:48 -0300 Subject: [PATCH 6/7] fix(ideation): adicionar wrappers .aios-core e corrigir path do mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Criado .aios-core/core/ideation/ideation-engine.js (wrapper retrocompatível) - Criado .aios-core/core/memory/gotchas-memory.js (wrapper retrocompatível) - Corrigido path do jest.doMock no teste de GotchasMemory import failure (apontava para .aios-core mas o engine importa de .aiox-core) --- .aios-core/core/ideation/ideation-engine.js | 2 ++ .aios-core/core/memory/gotchas-memory.js | 2 ++ tests/core/ideation/ideation-engine-gotchas.test.js | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .aios-core/core/ideation/ideation-engine.js create mode 100644 .aios-core/core/memory/gotchas-memory.js diff --git a/.aios-core/core/ideation/ideation-engine.js b/.aios-core/core/ideation/ideation-engine.js new file mode 100644 index 000000000..b5258210c --- /dev/null +++ b/.aios-core/core/ideation/ideation-engine.js @@ -0,0 +1,2 @@ +// Backward compatibility wrapper +module.exports = require('../../../.aiox-core/core/ideation/ideation-engine'); diff --git a/.aios-core/core/memory/gotchas-memory.js b/.aios-core/core/memory/gotchas-memory.js new file mode 100644 index 000000000..53f88d64e --- /dev/null +++ b/.aios-core/core/memory/gotchas-memory.js @@ -0,0 +1,2 @@ +// Backward compatibility wrapper +module.exports = require('../../../.aiox-core/core/memory/gotchas-memory'); diff --git a/tests/core/ideation/ideation-engine-gotchas.test.js b/tests/core/ideation/ideation-engine-gotchas.test.js index 299bd0957..ff6d28726 100644 --- a/tests/core/ideation/ideation-engine-gotchas.test.js +++ b/tests/core/ideation/ideation-engine-gotchas.test.js @@ -49,7 +49,7 @@ describe('IdeationEngine — GotchasMemory import regression (#517)', () => { test('sets gotchasMemory to null when module fails to load', () => { // Mock the gotchas-memory module to throw on require - jest.doMock('../../../.aios-core/core/memory/gotchas-memory', () => { + jest.doMock('../../../.aiox-core/core/memory/gotchas-memory', () => { throw new Error('Module not found'); }); From 2c5f4c3cb2a804db2fdec26156c0257b3d68bd2f Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 12 Mar 2026 15:33:00 -0300 Subject: [PATCH 7/7] chore: regenera manifesto --- .aiox-core/install-manifest.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 87e59724b..cc9d290e2 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T02:23:43.232Z" +generated_at: "2026-03-12T18:32:31.753Z" generator: scripts/generate-install-manifest.js file_count: 1090 files: @@ -409,9 +409,9 @@ files: type: core size: 48948 - path: core/execution/context-injector.js - hash: sha256:a183255eb4831f701086f23f371f94a9ce10c46ea18c8369ec0fd756777f042f + hash: sha256:b53f19422d4df0470852ed2a6015408d08dd9182998819667c96bd1c0be33c61 type: core - size: 14860 + size: 14956 - path: core/execution/parallel-executor.js hash: sha256:46870e5c8ff8db3ee0386e477d427cc98eeb008f630818b093a9524b410590ae type: core @@ -433,9 +433,9 @@ files: type: core size: 51556 - path: core/execution/subagent-dispatcher.js - hash: sha256:7affbc04de9be2bc53427670009a885f0b35e1cc183f82c2e044abf9611344b6 + hash: sha256:7b9d3f4bdca4bceb6e533fd7a20b29f529325193e65dda3021daf534a6eb18ee type: core - size: 25738 + size: 25837 - path: core/execution/wave-executor.js hash: sha256:4e2324edb37ae0729062b5ac029f2891e050e7efd3a48d0f4a1dc4f227a6716b type: core @@ -689,9 +689,9 @@ files: type: core size: 7755 - path: core/ideation/ideation-engine.js - hash: sha256:d9108fa47ed7a9131703739befb214b97d5b8e546faf1b49d8ae9d083756c589 + hash: sha256:14ac8d59fdb804341eb9ef7a1e11289d906bfc47a51a89bce7c09fab22420075 type: core - size: 22865 + size: 22960 - path: core/ids/circuit-breaker.js hash: sha256:1b35331ba71a6ce17869bab255e087fc540291243f9884fc21ed89f7efc122a4 type: core