aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
654 lines (555 loc) • 21.1 kB
JavaScript
/**
* Gotcha Registry - Story 12.4
*
* Learn from past mistakes (gotchas) to prevent repeating errors.
* Absorbed from Auto-Claude's GOTCHA episode type in GraphitiMemory.
*
* @module gotcha-registry
* @version 1.0.0
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ═══════════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════════
const DEFAULT_CONFIG = {
storePath: '.aios/gotchas.json',
maxGotchas: 500,
minConfidence: 0.5,
autoQueryBeforeExecution: true,
relevanceThreshold: 0.7,
};
// ═══════════════════════════════════════════════════════════════════════════════════
// GOTCHA SCHEMA
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Gotcha schema for validation
*
* @typedef {Object} Gotcha
* @property {string} id - Unique identifier
* @property {string} pattern - The problematic pattern/approach
* @property {string} context - Where this gotcha applies
* @property {string} error - The error that occurred
* @property {string} reason - Why it's a gotcha
* @property {string} alternative - Recommended alternative
* @property {string[]} keywords - Keywords for search
* @property {number} occurrences - Times this gotcha was hit
* @property {number} confidence - Confidence score (0-1)
* @property {string} createdAt - ISO timestamp
* @property {string} updatedAt - ISO timestamp
* @property {string} source - Where this gotcha was learned
*/
const GOTCHA_SCHEMA = {
required: ['pattern', 'context', 'reason'],
optional: ['error', 'alternative', 'keywords', 'source'],
};
// ═══════════════════════════════════════════════════════════════════════════════════
// GOTCHA REGISTRY CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
class GotchaRegistry {
/**
* Create a new GotchaRegistry
*
* @param {Object} options - Configuration options
* @param {string} [options.rootPath] - Project root path
* @param {Object} [options.config] - Config overrides
*/
constructor(options = {}) {
this.rootPath = options.rootPath || process.cwd();
this.config = { ...DEFAULT_CONFIG, ...options.config };
this.storePath = path.join(this.rootPath, this.config.storePath);
this._gotchas = null;
this._index = null;
}
// ─────────────────────────────────────────────────────────────────────────────────
// STORAGE
// ─────────────────────────────────────────────────────────────────────────────────
/**
* Load gotchas from storage
* @returns {Object} Gotchas data
*/
load() {
if (this._gotchas) {
return this._gotchas;
}
if (!fs.existsSync(this.storePath)) {
this._gotchas = { gotchas: [], metadata: { version: '1.0.0', lastUpdated: null } };
return this._gotchas;
}
try {
const content = fs.readFileSync(this.storePath, 'utf-8');
this._gotchas = JSON.parse(content);
this._buildIndex();
return this._gotchas;
} catch (error) {
console.error('Failed to load gotchas:', error.message);
this._gotchas = { gotchas: [], metadata: { version: '1.0.0', lastUpdated: null } };
return this._gotchas;
}
}
/**
* Save gotchas to storage
*/
save() {
if (!this._gotchas) {
return;
}
// Ensure directory exists
const dir = path.dirname(this.storePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Update metadata
this._gotchas.metadata.lastUpdated = new Date().toISOString();
// Enforce max gotchas limit (remove oldest low-confidence ones)
if (this._gotchas.gotchas.length > this.config.maxGotchas) {
this._gotchas.gotchas = this._gotchas.gotchas
.sort((a, b) => b.confidence - a.confidence)
.slice(0, this.config.maxGotchas);
}
fs.writeFileSync(this.storePath, JSON.stringify(this._gotchas, null, 2), 'utf-8');
this._buildIndex();
}
/**
* Build search index
* @private
*/
_buildIndex() {
this._index = new Map();
for (const gotcha of this._gotchas.gotchas) {
// Index by keywords
const keywords = this._extractKeywords(gotcha);
for (const keyword of keywords) {
if (!this._index.has(keyword)) {
this._index.set(keyword, []);
}
this._index.get(keyword).push(gotcha.id);
}
}
}
/**
* Extract keywords from gotcha
* @private
*/
_extractKeywords(gotcha) {
const keywords = new Set(gotcha.keywords || []);
// Extract from pattern
const patternWords = gotcha.pattern
.toLowerCase()
.split(/\W+/)
.filter((w) => w.length > 2);
patternWords.forEach((w) => keywords.add(w));
// Extract from context
const contextWords = gotcha.context
.toLowerCase()
.split(/\W+/)
.filter((w) => w.length > 2);
contextWords.forEach((w) => keywords.add(w));
return Array.from(keywords);
}
// ─────────────────────────────────────────────────────────────────────────────────
// RECORD GOTCHA
// ─────────────────────────────────────────────────────────────────────────────────
/**
* Record a new gotcha
*
* @param {Object} gotcha - Gotcha data
* @returns {Object} Created gotcha
*/
recordGotcha(gotcha) {
// Validate required fields
for (const field of GOTCHA_SCHEMA.required) {
if (!gotcha[field]) {
throw new Error(`Missing required field: ${field}`);
}
}
this.load();
// Check for existing similar gotcha
const existing = this._findSimilar(gotcha);
if (existing) {
return this._updateExisting(existing, gotcha);
}
// Create new gotcha
const newGotcha = {
id: this._generateId(),
pattern: gotcha.pattern,
context: gotcha.context,
error: gotcha.error || null,
reason: gotcha.reason,
alternative: gotcha.alternative || null,
keywords: gotcha.keywords || [],
occurrences: 1,
confidence: 0.7, // Start with moderate confidence
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
source: gotcha.source || 'manual',
};
this._gotchas.gotchas.push(newGotcha);
this.save();
return newGotcha;
}
/**
* Find similar existing gotcha
* @private
*/
_findSimilar(gotcha) {
const keywords = this._extractKeywords(gotcha);
const candidates = new Map();
for (const keyword of keywords) {
const ids = this._index?.get(keyword) || [];
for (const id of ids) {
candidates.set(id, (candidates.get(id) || 0) + 1);
}
}
// Find best match
let bestMatch = null;
let bestScore = 0;
for (const [id, score] of candidates) {
const normalizedScore = score / keywords.length;
if (normalizedScore > this.config.relevanceThreshold && normalizedScore > bestScore) {
bestMatch = this._gotchas.gotchas.find((g) => g.id === id);
bestScore = normalizedScore;
}
}
return bestMatch;
}
/**
* Update existing gotcha with new occurrence
* @private
*/
_updateExisting(existing, newData) {
existing.occurrences++;
existing.confidence = Math.min(1, existing.confidence + 0.1);
existing.updatedAt = new Date().toISOString();
// Merge keywords
if (newData.keywords) {
existing.keywords = [...new Set([...existing.keywords, ...newData.keywords])];
}
// Update alternative if better one provided
if (newData.alternative && !existing.alternative) {
existing.alternative = newData.alternative;
}
this.save();
return existing;
}
/**
* Generate unique ID
* @private
*/
_generateId() {
const timestamp = Date.now().toString(36);
const random = crypto.randomBytes(4).toString('hex');
return `gotcha-${timestamp}-${random}`;
}
// ─────────────────────────────────────────────────────────────────────────────────
// QUERY GOTCHAS
// ─────────────────────────────────────────────────────────────────────────────────
/**
* Query gotchas for a given context
*
* @param {Object} context - Query context
* @param {string} [context.pattern] - Pattern being executed
* @param {string} [context.action] - Action being performed
* @param {string[]} [context.files] - Files being modified
* @param {string} [context.agent] - Agent executing
* @returns {Object[]} Relevant gotchas
*/
queryGotchas(context) {
this.load();
if (this._gotchas.gotchas.length === 0) {
return [];
}
// Build query keywords
const queryKeywords = new Set();
if (context.pattern) {
context.pattern
.toLowerCase()
.split(/\W+/)
.filter((w) => w.length > 2)
.forEach((w) => queryKeywords.add(w));
}
if (context.action) {
context.action
.toLowerCase()
.split(/\W+/)
.filter((w) => w.length > 2)
.forEach((w) => queryKeywords.add(w));
}
if (context.files) {
context.files.forEach((f) => {
const parts = f
.toLowerCase()
.split(/[/\\.]/)
.filter((p) => p.length > 2);
parts.forEach((p) => queryKeywords.add(p));
});
}
if (context.agent) {
queryKeywords.add(context.agent.toLowerCase());
}
// Score gotchas by relevance
const scored = this._gotchas.gotchas.map((gotcha) => {
const gotchaKeywords = this._extractKeywords(gotcha);
const matches = gotchaKeywords.filter((k) => queryKeywords.has(k));
const relevance = matches.length / Math.max(gotchaKeywords.length, queryKeywords.size);
return {
...gotcha,
relevance,
matchedKeywords: matches,
};
});
// Filter and sort by relevance * confidence
return scored
.filter((g) => g.relevance >= this.config.relevanceThreshold)
.filter((g) => g.confidence >= this.config.minConfidence)
.sort((a, b) => b.relevance * b.confidence - a.relevance * a.confidence)
.slice(0, 5);
}
/**
* Get gotchas for a specific pattern/sequence
*
* @param {string[]} sequence - Command sequence
* @returns {Object[]} Relevant gotchas
*/
getGotchasForSequence(sequence) {
return this.queryGotchas({
pattern: sequence.join(' '),
action: sequence[sequence.length - 1],
});
}
/**
* Get all gotchas
*
* @param {Object} options - Filter options
* @returns {Object[]} Gotchas
*/
getAllGotchas(options = {}) {
this.load();
let gotchas = [...this._gotchas.gotchas];
if (options.minConfidence) {
gotchas = gotchas.filter((g) => g.confidence >= options.minConfidence);
}
if (options.context) {
gotchas = gotchas.filter((g) =>
g.context.toLowerCase().includes(options.context.toLowerCase()),
);
}
if (options.sortBy === 'occurrences') {
gotchas.sort((a, b) => b.occurrences - a.occurrences);
} else if (options.sortBy === 'confidence') {
gotchas.sort((a, b) => b.confidence - a.confidence);
} else if (options.sortBy === 'recent') {
gotchas.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}
if (options.limit) {
gotchas = gotchas.slice(0, options.limit);
}
return gotchas;
}
// ─────────────────────────────────────────────────────────────────────────────────
// GOTCHA MANAGEMENT
// ─────────────────────────────────────────────────────────────────────────────────
/**
* Get gotcha by ID
*
* @param {string} id - Gotcha ID
* @returns {Object|null} Gotcha or null
*/
getGotcha(id) {
this.load();
return this._gotchas.gotchas.find((g) => g.id === id) || null;
}
/**
* Update gotcha
*
* @param {string} id - Gotcha ID
* @param {Object} updates - Fields to update
* @returns {Object|null} Updated gotcha or null
*/
updateGotcha(id, updates) {
this.load();
const gotcha = this._gotchas.gotchas.find((g) => g.id === id);
if (!gotcha) {
return null;
}
// Update allowed fields
const allowedFields = [
'pattern',
'context',
'error',
'reason',
'alternative',
'keywords',
'confidence',
];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
gotcha[field] = updates[field];
}
}
gotcha.updatedAt = new Date().toISOString();
this.save();
return gotcha;
}
/**
* Delete gotcha
*
* @param {string} id - Gotcha ID
* @returns {boolean} Success
*/
deleteGotcha(id) {
this.load();
const index = this._gotchas.gotchas.findIndex((g) => g.id === id);
if (index === -1) {
return false;
}
this._gotchas.gotchas.splice(index, 1);
this.save();
return true;
}
/**
* Deprecate gotcha (reduce confidence instead of delete)
*
* @param {string} id - Gotcha ID
* @returns {Object|null} Updated gotcha
*/
deprecateGotcha(id) {
return this.updateGotcha(id, { confidence: 0.1 });
}
// ─────────────────────────────────────────────────────────────────────────────────
// STATISTICS
// ─────────────────────────────────────────────────────────────────────────────────
/**
* Get gotcha statistics
*
* @returns {Object} Statistics
*/
getStatistics() {
this.load();
const gotchas = this._gotchas.gotchas;
if (gotchas.length === 0) {
return {
total: 0,
highConfidence: 0,
mediumConfidence: 0,
lowConfidence: 0,
totalOccurrences: 0,
topContexts: [],
recentlyUpdated: [],
};
}
return {
total: gotchas.length,
highConfidence: gotchas.filter((g) => g.confidence >= 0.8).length,
mediumConfidence: gotchas.filter((g) => g.confidence >= 0.5 && g.confidence < 0.8).length,
lowConfidence: gotchas.filter((g) => g.confidence < 0.5).length,
totalOccurrences: gotchas.reduce((sum, g) => sum + g.occurrences, 0),
topContexts: this._getTopContexts(gotchas, 5),
recentlyUpdated: gotchas
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
.slice(0, 5)
.map((g) => ({ id: g.id, pattern: g.pattern, updatedAt: g.updatedAt })),
};
}
/**
* Get top contexts
* @private
*/
_getTopContexts(gotchas, limit) {
const contextCounts = new Map();
for (const gotcha of gotchas) {
const context = gotcha.context.split(' ')[0]; // First word as category
contextCounts.set(context, (contextCounts.get(context) || 0) + 1);
}
return Array.from(contextCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([context, count]) => ({ context, count }));
}
// ─────────────────────────────────────────────────────────────────────────────────
// FORMATTING
// ─────────────────────────────────────────────────────────────────────────────────
/**
* Format gotcha for display
*
* @param {Object} gotcha - Gotcha to format
* @returns {string} Formatted string
*/
formatGotcha(gotcha) {
const lines = [];
lines.push(`⚠️ GOTCHA: ${gotcha.pattern}`);
lines.push(` Context: ${gotcha.context}`);
lines.push(` Reason: ${gotcha.reason}`);
if (gotcha.error) {
lines.push(` Error: ${gotcha.error}`);
}
if (gotcha.alternative) {
lines.push(` ✅ Alternative: ${gotcha.alternative}`);
}
lines.push(
` Confidence: ${(gotcha.confidence * 100).toFixed(0)}% (${gotcha.occurrences} occurrences)`,
);
return lines.join('\n');
}
/**
* Format gotchas as markdown
*
* @returns {string} Markdown content
*/
toMarkdown() {
this.load();
const lines = [
'# Gotchas Registry',
'',
`*Last updated: ${this._gotchas.metadata.lastUpdated || 'Never'}*`,
'',
`Total gotchas: ${this._gotchas.gotchas.length}`,
'',
'---',
'',
];
// Group by context
const byContext = new Map();
for (const gotcha of this._gotchas.gotchas) {
const context = gotcha.context.split(' ')[0];
if (!byContext.has(context)) {
byContext.set(context, []);
}
byContext.get(context).push(gotcha);
}
for (const [context, gotchas] of byContext) {
lines.push(`## ${context}`);
lines.push('');
for (const gotcha of gotchas.sort((a, b) => b.confidence - a.confidence)) {
lines.push(`### ${gotcha.pattern}`);
lines.push('');
lines.push(`**Reason:** ${gotcha.reason}`);
lines.push('');
if (gotcha.error) {
lines.push(`**Error:** \`${gotcha.error}\``);
lines.push('');
}
if (gotcha.alternative) {
lines.push(`**Alternative:** ${gotcha.alternative}`);
lines.push('');
}
lines.push(
`*Confidence: ${(gotcha.confidence * 100).toFixed(0)}% | Occurrences: ${gotcha.occurrences}*`,
);
lines.push('');
lines.push('---');
lines.push('');
}
}
return lines.join('\n');
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = {
GotchaRegistry,
DEFAULT_CONFIG,
GOTCHA_SCHEMA,
};