aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
498 lines (424 loc) • 13.7 kB
JavaScript
/**
* @module PatternStore
* @description Stores and manages learned workflow patterns
* @story WIS-5 - Pattern Capture (Internal)
* @version 1.0.0
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const yaml = require('js-yaml');
/**
* Default storage path for learned patterns
* @type {string}
*/
const DEFAULT_STORAGE_PATH = path.join(__dirname, '../../..', 'data', 'learned-patterns.yaml');
/**
* Default maximum number of patterns
* @type {number}
*/
const DEFAULT_MAX_PATTERNS = 100;
/**
* Default auto-prune threshold (percentage of max)
* @type {number}
*/
const DEFAULT_PRUNE_THRESHOLD = 0.9;
/**
* Pattern status values
* @type {Object}
*/
const PATTERN_STATUS = {
PENDING: 'pending',
ACTIVE: 'active',
PROMOTED: 'promoted',
DEPRECATED: 'deprecated',
};
/**
* PatternStore class for persisting workflow patterns
*/
class PatternStore {
/**
* Create a PatternStore instance
* @param {Object} options - Configuration options
* @param {string} options.storagePath - Path to storage file
* @param {number} options.maxPatterns - Maximum patterns to store
* @param {number} options.pruneThreshold - Auto-prune threshold (0-1)
* @param {string} options.pruneStrategy - Prune strategy ('oldest_low_occurrence' | 'lowest_success_rate')
*/
constructor(options = {}) {
this.storagePath = options.storagePath || DEFAULT_STORAGE_PATH;
this.maxPatterns = options.maxPatterns || DEFAULT_MAX_PATTERNS;
this.pruneThreshold = options.pruneThreshold || DEFAULT_PRUNE_THRESHOLD;
this.pruneStrategy = options.pruneStrategy || 'oldest_low_occurrence';
this._cache = null;
this._cacheTime = null;
}
/**
* Save a pattern to storage
* @param {Object} pattern - Pattern to save
* @returns {Object} Save result
*/
save(pattern) {
const data = this._load();
// Ensure pattern has required fields
const normalizedPattern = this._normalizePattern(pattern);
// Check for existing pattern with same sequence
const existingIndex = data.patterns.findIndex((p) =>
this._sequenceEquals(p.sequence, normalizedPattern.sequence),
);
if (existingIndex >= 0) {
// Update existing pattern
const existing = data.patterns[existingIndex];
existing.occurrences = (existing.occurrences || 1) + 1;
existing.lastSeen = new Date().toISOString();
// Update success rate (weighted average)
const totalOccurrences = existing.occurrences;
const newSuccessRate = normalizedPattern.successRate || 1.0;
existing.successRate =
(existing.successRate * (totalOccurrences - 1) + newSuccessRate) / totalOccurrences;
data.patterns[existingIndex] = existing;
this._save(data);
return { action: 'updated', pattern: existing };
}
// Add new pattern
data.patterns.push(normalizedPattern);
// Check if auto-prune needed
if (data.patterns.length > this.maxPatterns * this.pruneThreshold) {
this._autoPrune(data);
}
this._save(data);
return { action: 'created', pattern: normalizedPattern };
}
/**
* Load all patterns from storage
* @returns {Object} Loaded data with patterns array
*/
load() {
return this._load();
}
/**
* Find patterns similar to a given sequence
* @param {string[]} sequence - Sequence to match
* @returns {Object[]} Matching patterns sorted by relevance
*/
findSimilar(sequence) {
const data = this._load();
if (!sequence || sequence.length === 0) {
return [];
}
const matches = [];
for (const pattern of data.patterns) {
const similarity = this._calculateSimilarity(sequence, pattern.sequence);
if (similarity > 0.3) {
// Minimum threshold for consideration
matches.push({
...pattern,
similarity: similarity,
});
}
}
// Sort by similarity (descending), then by occurrences
return matches.sort((a, b) => {
if (Math.abs(a.similarity - b.similarity) > 0.1) {
return b.similarity - a.similarity;
}
return (b.occurrences || 1) - (a.occurrences || 1);
});
}
/**
* Get storage statistics
* @returns {Object} Statistics object
*/
getStats() {
const data = this._load();
const patterns = data.patterns;
const statusCounts = {
pending: 0,
active: 0,
promoted: 0,
deprecated: 0,
};
let totalSuccessRate = 0;
let totalOccurrences = 0;
for (const pattern of patterns) {
statusCounts[pattern.status || 'pending']++;
totalSuccessRate += pattern.successRate || 0;
totalOccurrences += pattern.occurrences || 1;
}
const avgSuccessRate = patterns.length > 0 ? totalSuccessRate / patterns.length : 0;
return {
totalPatterns: patterns.length,
maxPatterns: this.maxPatterns,
utilizationPercent: Math.round((patterns.length / this.maxPatterns) * 100),
statusCounts: statusCounts,
avgSuccessRate: Math.round(avgSuccessRate * 100) / 100,
totalOccurrences: totalOccurrences,
storageFile: this.storagePath,
lastUpdated: data.lastUpdated || null,
};
}
/**
* Prune patterns based on configured strategy
* @param {Object} options - Prune options
* @param {number} options.keepCount - Number of patterns to keep
* @param {string} options.strategy - Prune strategy override
* @returns {Object} Prune result
*/
prune(options = {}) {
const data = this._load();
const originalCount = data.patterns.length;
const keepCount = options.keepCount || Math.floor(this.maxPatterns * 0.7);
const strategy = options.strategy || this.pruneStrategy;
if (data.patterns.length <= keepCount) {
return { pruned: 0, remaining: data.patterns.length };
}
// Sort patterns for pruning
let sorted;
if (strategy === 'lowest_success_rate') {
sorted = [...data.patterns].sort((a, b) => {
// Keep promoted/active patterns
if (a.status === 'promoted' || a.status === 'active') return -1;
if (b.status === 'promoted' || b.status === 'active') return 1;
// Then by success rate
return (b.successRate || 0) - (a.successRate || 0);
});
} else {
// oldest_low_occurrence (default)
sorted = [...data.patterns].sort((a, b) => {
// Keep promoted patterns
if (a.status === 'promoted') return -1;
if (b.status === 'promoted') return 1;
// Keep active patterns
if (a.status === 'active') return -1;
if (b.status === 'active') return 1;
// Then by occurrences (higher = keep)
if ((a.occurrences || 1) !== (b.occurrences || 1)) {
return (b.occurrences || 1) - (a.occurrences || 1);
}
// Then by last seen (newer = keep)
return new Date(b.lastSeen || 0) - new Date(a.lastSeen || 0);
});
}
// Keep top patterns
data.patterns = sorted.slice(0, keepCount);
this._save(data);
return {
pruned: originalCount - data.patterns.length,
remaining: data.patterns.length,
};
}
/**
* Update pattern status
* @param {string} patternId - Pattern ID
* @param {string} newStatus - New status value
* @returns {Object} Update result
*/
updateStatus(patternId, newStatus) {
const data = this._load();
const pattern = data.patterns.find((p) => p.id === patternId);
if (!pattern) {
return { success: false, error: 'Pattern not found' };
}
const validStatuses = Object.values(PATTERN_STATUS);
if (!validStatuses.includes(newStatus)) {
return { success: false, error: `Invalid status: ${newStatus}` };
}
pattern.status = newStatus;
pattern.lastUpdated = new Date().toISOString();
this._save(data);
return { success: true, pattern: pattern };
}
/**
* Get patterns by status
* @param {string} status - Status to filter by
* @returns {Object[]} Patterns with given status
*/
getByStatus(status) {
const data = this._load();
return data.patterns.filter((p) => p.status === status);
}
/**
* Get active and promoted patterns (for SuggestionEngine)
* @returns {Object[]} Active patterns
*/
getActivePatterns() {
const data = this._load();
return data.patterns.filter(
(p) => p.status === PATTERN_STATUS.ACTIVE || p.status === PATTERN_STATUS.PROMOTED,
);
}
/**
* Delete a pattern by ID
* @param {string} patternId - Pattern ID
* @returns {Object} Delete result
*/
delete(patternId) {
const data = this._load();
const index = data.patterns.findIndex((p) => p.id === patternId);
if (index < 0) {
return { success: false, error: 'Pattern not found' };
}
data.patterns.splice(index, 1);
this._save(data);
return { success: true };
}
/**
* Load data from storage file
* @returns {Object} Loaded data
* @private
*/
_load() {
// Use cache if valid (within 5 seconds)
if (this._cache && this._cacheTime && Date.now() - this._cacheTime < 5000) {
return this._cache;
}
try {
if (fs.existsSync(this.storagePath)) {
const content = fs.readFileSync(this.storagePath, 'utf8');
const data = yaml.load(content) || {};
data.patterns = data.patterns || [];
this._cache = data;
this._cacheTime = Date.now();
return data;
}
} catch (error) {
console.warn('[PatternStore] Failed to load:', error.message);
}
// Return empty structure
const empty = { version: '1.0', patterns: [] };
this._cache = empty;
this._cacheTime = Date.now();
return empty;
}
/**
* Save data to storage file
* @param {Object} data - Data to save
* @private
*/
_save(data) {
try {
data.lastUpdated = new Date().toISOString();
// Sort patterns for git-friendly diffs
data.patterns.sort((a, b) => a.id.localeCompare(b.id));
// Ensure directory exists
const dir = path.dirname(this.storagePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const content = yaml.dump(data, { indent: 2, lineWidth: 120 });
fs.writeFileSync(this.storagePath, content, 'utf8');
// Invalidate cache
this._cache = data;
this._cacheTime = Date.now();
} catch (error) {
console.error('[PatternStore] Failed to save:', error.message);
throw error;
}
}
/**
* Normalize pattern object
* @param {Object} pattern - Pattern to normalize
* @returns {Object} Normalized pattern
* @private
*/
_normalizePattern(pattern) {
return {
id: pattern.id || crypto.randomUUID(),
sequence: pattern.sequence || [],
agents: pattern.agents || [],
occurrences: pattern.occurrences || 1,
successRate: pattern.successRate || 1.0,
firstSeen: pattern.firstSeen || new Date().toISOString(),
lastSeen: pattern.lastSeen || new Date().toISOString(),
workflow: pattern.workflow || null,
status: pattern.status || PATTERN_STATUS.PENDING,
};
}
/**
* Check if two sequences are equal
* @param {string[]} seq1 - First sequence
* @param {string[]} seq2 - Second sequence
* @returns {boolean} True if equal
* @private
*/
_sequenceEquals(seq1, seq2) {
if (!seq1 || !seq2 || seq1.length !== seq2.length) {
return false;
}
return seq1.every((cmd, i) => cmd === seq2[i]);
}
/**
* Calculate similarity between sequences
* @param {string[]} seq1 - First sequence
* @param {string[]} seq2 - Second sequence
* @returns {number} Similarity score (0-1)
* @private
*/
_calculateSimilarity(seq1, seq2) {
if (!seq1 || !seq2) return 0;
// Check for subsequence match
const joined1 = seq1.join('|');
const joined2 = seq2.join('|');
if (joined2.includes(joined1) || joined1.includes(joined2)) {
return 0.9;
}
// Check for partial overlap
let matches = 0;
for (const cmd of seq1) {
if (seq2.includes(cmd)) {
matches++;
}
}
return matches / Math.max(seq1.length, seq2.length);
}
/**
* Auto-prune when approaching limit
* @param {Object} data - Data object
* @private
*/
_autoPrune(data) {
const keepCount = Math.floor(this.maxPatterns * 0.8);
// Sort by priority (keep promoted > active > pending)
// Then by occurrences, then by last seen
data.patterns.sort((a, b) => {
// Status priority
const statusPriority = { promoted: 3, active: 2, pending: 1, deprecated: 0 };
const aPriority = statusPriority[a.status] || 0;
const bPriority = statusPriority[b.status] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
// Then by occurrences
if ((a.occurrences || 1) !== (b.occurrences || 1)) {
return (b.occurrences || 1) - (a.occurrences || 1);
}
// Then by last seen
return new Date(b.lastSeen || 0) - new Date(a.lastSeen || 0);
});
data.patterns = data.patterns.slice(0, keepCount);
}
/**
* Invalidate cache
*/
invalidateCache() {
this._cache = null;
this._cacheTime = null;
}
}
/**
* Create a new PatternStore instance
* @param {Object} options - Configuration options
* @returns {PatternStore} New instance
*/
function createPatternStore(options = {}) {
return new PatternStore(options);
}
module.exports = {
PatternStore,
createPatternStore,
DEFAULT_STORAGE_PATH,
DEFAULT_MAX_PATTERNS,
DEFAULT_PRUNE_THRESHOLD,
PATTERN_STATUS,
};