aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
798 lines (711 loc) • 23.4 kB
JavaScript
/**
* @module SuggestionEngine
* @description High-level suggestion engine for the *next task
* @story WIS-3 - *next Task Implementation
* @story WIS-5 - Pattern Capture Integration
* @version 1.1.0
*
* @example
* const { SuggestionEngine } = require('./suggestion-engine');
* const engine = new SuggestionEngine();
*
* const context = await engine.buildContext({ storyOverride: 'path/to/story.md' });
* const result = await engine.suggestNext(context);
*/
'use strict';
const fs = require('fs');
const path = require('path');
// Lazy-loaded dependencies for performance
let wis = null;
let SessionContextLoader = null;
let learning = null;
let WorkflowStateManager = null;
/**
* Default cache TTL for suggestions (5 minutes)
* @type {number}
*/
const SUGGESTION_CACHE_TTL = 5 * 60 * 1000;
/**
* Low confidence threshold for marking suggestions as "uncertain"
* @type {number}
*/
const LOW_CONFIDENCE_THRESHOLD = 0.5;
/**
* SuggestionEngine class for generating context-aware command suggestions
*/
class SuggestionEngine {
/**
* Create a SuggestionEngine instance
* @param {Object} options - Configuration options
* @param {number} options.cacheTTL - Cache time-to-live in milliseconds
* @param {boolean} options.lazyLoad - Whether to lazy-load dependencies
* @param {boolean} options.useLearnedPatterns - Whether to use learned patterns (default: true)
* @param {number} options.learnedPatternBoost - Confidence boost for learned patterns (default: 0.15)
*/
constructor(options = {}) {
this.cacheTTL = options.cacheTTL || SUGGESTION_CACHE_TTL;
this.lazyLoad = options.lazyLoad !== false;
this.useLearnedPatterns = options.useLearnedPatterns !== false;
this.learnedPatternBoost = options.learnedPatternBoost || 0.15;
this.suggestionCache = null;
this.cacheTimestamp = null;
this.cacheKey = null;
// Load dependencies immediately if not lazy loading
if (!this.lazyLoad) {
this._loadDependencies();
}
}
/**
* Lazy-load WIS and session dependencies
* @private
*/
_loadDependencies() {
if (!wis) {
try {
wis = require('../index');
} catch (error) {
console.warn('[SuggestionEngine] Failed to load WIS module:', error.message);
wis = null;
}
}
if (!SessionContextLoader) {
try {
SessionContextLoader = require('../../core/session/context-loader');
} catch (error) {
console.warn('[SuggestionEngine] Failed to load SessionContextLoader:', error.message);
SessionContextLoader = null;
}
}
if (!learning && this.useLearnedPatterns) {
try {
learning = require('../learning');
} catch (error) {
console.warn('[SuggestionEngine] Failed to load learning module:', error.message);
learning = null;
}
}
if (!WorkflowStateManager) {
try {
({ WorkflowStateManager } = require('../../development/scripts/workflow-state-manager'));
} catch (error) {
console.warn('[SuggestionEngine] Failed to load WorkflowStateManager:', error.message);
WorkflowStateManager = null;
}
}
}
/**
* Build context from multiple sources
* @param {Object} options - Context building options
* @param {string} options.storyOverride - Explicit story path (optional)
* @param {boolean} options.autoDetect - Whether to auto-detect context (default: true)
* @param {string} options.agentId - Current agent ID (optional)
* @returns {Promise<Object>} Built context object
*/
async buildContext(options = {}) {
this._loadDependencies();
const context = {
agentId: options.agentId || this._detectCurrentAgent(),
lastCommand: null,
lastCommands: [],
storyPath: null,
branch: null,
projectState: {},
};
// 1. Load session context if available
if (options.autoDetect !== false && SessionContextLoader) {
try {
const loader = new SessionContextLoader();
const sessionContext = loader.loadContext(context.agentId);
context.lastCommands = sessionContext.lastCommands || [];
context.lastCommand = context.lastCommands[context.lastCommands.length - 1] || null;
context.storyPath = sessionContext.currentStory || null;
context.workflowActive = sessionContext.workflowActive || null;
} catch (error) {
console.warn('[SuggestionEngine] Failed to load session context:', error.message);
}
}
// 2. Story override takes precedence
if (options.storyOverride) {
const resolvedPath = this._resolveStoryPath(options.storyOverride);
if (resolvedPath) {
context.storyPath = resolvedPath;
}
}
// 3. Detect git branch
context.branch = this._detectGitBranch();
// 4. Build project state
context.projectState = await this._buildProjectState(context);
return context;
}
/**
* Get suggestions for next commands based on context
* @param {Object} context - Current session context
* @returns {Promise<Object>} Suggestion result
*/
async suggestNext(context) {
this._loadDependencies();
const runtimeNext = this._getRuntimeNextRecommendation(context);
// Check cache first
const cacheKey = this._generateCacheKey(context);
if (this._isCacheValid(cacheKey)) {
return this._withRuntimeRecommendation(this.suggestionCache, runtimeNext);
}
// Default result for when WIS is not available
const defaultResult = {
workflow: null,
currentState: null,
confidence: 0,
suggestions: [],
isUncertain: true,
message: 'Unable to determine workflow context',
};
if (!wis) {
return this._withRuntimeRecommendation(defaultResult, runtimeNext);
}
try {
// Get suggestions from WIS
const suggestions = wis.getSuggestions(context);
if (!suggestions || suggestions.length === 0) {
return this._withRuntimeRecommendation({
...defaultResult,
message: 'No matching workflow found for current context',
}, runtimeNext);
}
// Get workflow match info
const commands = context.lastCommands || (context.lastCommand ? [context.lastCommand] : []);
const match = wis.matchWorkflow(commands);
// Calculate overall confidence
const _avgConfidence =
suggestions.length > 0
? suggestions.reduce((sum, s) => sum + (s.confidence || 0), 0) / suggestions.length
: 0;
// Format base suggestions
let formattedSuggestions = suggestions.map((s, index) => ({
command: `*${s.command}`,
args: this._interpolateArgs(s.args_template, context),
description: s.description || '',
confidence: Math.round((s.confidence || 0) * 100) / 100,
priority: s.priority || index + 1,
source: 'workflow',
}));
// Apply learned pattern boost (WIS-5)
if (this.useLearnedPatterns && learning) {
formattedSuggestions = this._applyLearnedPatternBoost(formattedSuggestions, context);
}
// Re-sort after boost
formattedSuggestions.sort((a, b) => b.confidence - a.confidence);
// Recalculate average confidence
const finalAvgConfidence =
formattedSuggestions.length > 0
? formattedSuggestions.reduce((sum, s) => sum + s.confidence, 0) /
formattedSuggestions.length
: 0;
// Build result
let result = {
workflow: match?.name || suggestions[0]?.workflow || null,
currentState: suggestions[0]?.state || null,
confidence: Math.round(finalAvgConfidence * 100) / 100,
suggestions: formattedSuggestions,
isUncertain: finalAvgConfidence < LOW_CONFIDENCE_THRESHOLD,
message: null,
};
result = this._withRuntimeRecommendation(result, runtimeNext);
// Cache the result
this._cacheResult(cacheKey, result);
return result;
} catch (error) {
console.error('[SuggestionEngine] Error getting suggestions:', error.message);
return {
...defaultResult,
message: `Error: ${error.message}`,
};
}
}
/**
* Build runtime execution signals for deterministic next-action recommendation
* @param {Object} context - Current session context
* @returns {Object} Normalized runtime signals
* @private
*/
_buildRuntimeSignals(context = {}) {
const projectState = context.projectState || {};
const hasUncommitted =
typeof projectState.hasUncommittedChanges === 'boolean'
? projectState.hasUncommittedChanges
: false;
const baseSignals = {
story_status:
projectState.story_status || projectState.storyStatus || (projectState.activeStory ? 'in_progress' : 'unknown'),
qa_status: projectState.qa_status || projectState.qaStatus || 'unknown',
ci_status:
projectState.ci_status ||
projectState.ciStatus ||
(projectState.failingTests ? 'failed' : 'unknown'),
has_uncommitted_changes: hasUncommitted,
};
return {
...baseSignals,
...(context.executionSignals || {}),
};
}
/**
* Get deterministic runtime-first recommendation if signals are available
* @param {Object} context - Current session context
* @returns {Object|null} Runtime recommendation or null
* @private
*/
_getRuntimeNextRecommendation(context = {}) {
if (!WorkflowStateManager) {
return null;
}
try {
const manager = new WorkflowStateManager();
const runtimeSignals = this._buildRuntimeSignals(context);
const recommendation = manager.getNextActionRecommendation(runtimeSignals, {
story: context.storyPath || '',
});
if (!recommendation || recommendation.state === 'unknown') {
return null;
}
return recommendation;
} catch (_error) {
return null;
}
}
/**
* Merge runtime-first deterministic recommendation into suggestion result.
* @param {Object} result - Suggestion result
* @param {Object|null} runtimeNext - Runtime recommendation
* @returns {Object} Enhanced result
* @private
*/
_withRuntimeRecommendation(result, runtimeNext) {
if (!result || !runtimeNext) {
return result;
}
const runtimeSuggestion = {
command: runtimeNext.command,
args: '',
description: runtimeNext.rationale,
confidence: runtimeNext.confidence,
priority: 0,
source: 'runtime_first',
agent: runtimeNext.agent,
executionState: runtimeNext.state,
};
const existing = Array.isArray(result.suggestions) ? result.suggestions : [];
const normalizedRuntimeCommand = String(runtimeSuggestion.command || '').trim().toLowerCase();
const deduped = existing.filter(
(s) => String((s.command || '') + (s.args ? ` ${s.args}` : '')).trim().toLowerCase() !== normalizedRuntimeCommand,
);
return {
...result,
suggestions: [runtimeSuggestion, ...deduped],
confidence: Math.max(result.confidence || 0, runtimeNext.confidence || 0),
isUncertain: false,
runtimeState: runtimeNext.state,
};
}
/**
* Detect current active agent from environment or session
* @returns {string} Agent ID
* @private
*/
_detectCurrentAgent() {
// Check environment variable first
if (process.env.AIOS_CURRENT_AGENT) {
return process.env.AIOS_CURRENT_AGENT.replace('@', '');
}
// Default to 'dev' if unknown
return 'dev';
}
/**
* Resolve and validate story path
* @param {string} storyPath - Story path to resolve
* @returns {string|null} Resolved path or null if invalid
* @private
*/
_resolveStoryPath(storyPath) {
if (!storyPath) return null;
// Handle relative paths
const resolved = path.isAbsolute(storyPath)
? storyPath
: path.resolve(process.cwd(), storyPath);
// Validate file exists
try {
if (fs.existsSync(resolved)) {
return resolved;
}
} catch (_error) {
// File doesn't exist
}
console.warn(`[SuggestionEngine] Story path not found: ${storyPath}`);
return null;
}
/**
* Detect current git branch
* @returns {string|null} Branch name or null
* @private
*/
_detectGitBranch() {
try {
const gitHeadPath = path.join(process.cwd(), '.git', 'HEAD');
if (fs.existsSync(gitHeadPath)) {
const content = fs.readFileSync(gitHeadPath, 'utf8').trim();
if (content.startsWith('ref: refs/heads/')) {
return content.replace('ref: refs/heads/', '');
}
}
} catch (_error) {
// Git not available or not a git repo
}
return null;
}
/**
* Build project state object
* @param {Object} context - Current context
* @returns {Promise<Object>} Project state
* @private
*/
async _buildProjectState(context) {
const state = {
activeStory: !!context.storyPath,
hasUncommittedChanges: false,
failingTests: false,
workflowPhase: null,
};
// Check for uncommitted changes
try {
const gitStatusPath = path.join(process.cwd(), '.git', 'index');
if (fs.existsSync(gitStatusPath)) {
// Simple check - if there are modified files in git status
// For production, would use git status command
state.hasUncommittedChanges = true; // Assume true for now
}
} catch (_error) {
// Ignore
}
// Infer workflow phase from last command
if (context.lastCommand) {
const cmd = context.lastCommand.toLowerCase();
if (cmd.includes('develop') || cmd.includes('implement')) {
state.workflowPhase = 'development';
} else if (cmd.includes('review') || cmd.includes('qa')) {
state.workflowPhase = 'review';
} else if (cmd.includes('push') || cmd.includes('pr') || cmd.includes('deploy')) {
state.workflowPhase = 'deployment';
} else if (cmd.includes('create') || cmd.includes('story') || cmd.includes('epic')) {
state.workflowPhase = 'planning';
}
}
return state;
}
/**
* Interpolate argument templates with context values
* @param {string} argsTemplate - Template string with ${var} placeholders
* @param {Object} context - Context for interpolation
* @returns {string} Interpolated arguments
* @private
*/
_interpolateArgs(argsTemplate, context) {
if (!argsTemplate) return '';
return argsTemplate
.replace(/\$\{story_path\}/g, context.storyPath || '')
.replace(/\$\{epic_path\}/g, context.epicPath || '')
.replace(/\$\{doc_path\}/g, context.docPath || '')
.replace(/\$\{file_path\}/g, context.filePath || '')
.replace(/\$\{feature_name\}/g, context.featureName || '')
.replace(/\$\{topic\}/g, context.topic || '')
.replace(/\$\{branch\}/g, context.branch || '')
.trim();
}
/**
* Generate cache key from context
* @param {Object} context - Context object
* @returns {string} Cache key
* @private
*/
_generateCacheKey(context) {
const keyParts = [
context.agentId || '',
context.lastCommand || '',
(context.lastCommands || []).slice(-3).join(','),
context.storyPath || '',
context.branch || '',
];
return keyParts.join('|');
}
/**
* Check if cache is valid for given key
* @param {string} key - Cache key
* @returns {boolean} True if cache is valid
* @private
*/
_isCacheValid(key) {
if (!this.suggestionCache || !this.cacheTimestamp || !this.cacheKey) {
return false;
}
if (this.cacheKey !== key) {
return false;
}
return Date.now() - this.cacheTimestamp < this.cacheTTL;
}
/**
* Cache suggestion result
* @param {string} key - Cache key
* @param {Object} result - Result to cache
* @private
*/
_cacheResult(key, result) {
this.suggestionCache = result;
this.cacheTimestamp = Date.now();
this.cacheKey = key;
}
/**
* Apply learned pattern boost to suggestions
* @param {Object[]} suggestions - Base suggestions
* @param {Object} context - Session context
* @returns {Object[]} Boosted suggestions
* @private
*/
_applyLearnedPatternBoost(suggestions, context) {
if (!learning) {
return suggestions;
}
try {
// Get commands to match against
const lastCommands = context.lastCommands || [];
if (lastCommands.length === 0 && context.lastCommand) {
lastCommands.push(context.lastCommand);
}
if (lastCommands.length === 0) {
return suggestions;
}
// Find matching learned patterns
const matchingPatterns = learning.findMatchingPatterns(lastCommands);
if (!matchingPatterns || matchingPatterns.length === 0) {
return suggestions;
}
// Build a map of command -> boost based on learned patterns
const boostMap = new Map();
for (const pattern of matchingPatterns) {
// Find the next command in the pattern after the current position
const patternSeq = pattern.sequence || [];
const matchIndex = this._findSequencePosition(lastCommands, patternSeq);
if (matchIndex >= 0 && matchIndex < patternSeq.length - 1) {
const nextCommand = patternSeq[matchIndex + 1];
const currentBoost = boostMap.get(nextCommand) || 0;
// Calculate boost based on pattern quality
const occurrenceBoost = Math.min(pattern.occurrences * 0.02, 0.1);
const successBoost = (pattern.successRate || 1) * 0.05;
const similarityBoost = (pattern.similarity || 0.5) * 0.05;
const totalBoost =
this.learnedPatternBoost + occurrenceBoost + successBoost + similarityBoost;
boostMap.set(nextCommand, Math.max(currentBoost, totalBoost));
}
}
// Apply boosts to suggestions
return suggestions.map((suggestion) => {
const cmdNormalized = suggestion.command.replace(/^\*/, '').toLowerCase();
const boost = boostMap.get(cmdNormalized) || 0;
if (boost > 0) {
return {
...suggestion,
confidence: Math.min(1.0, suggestion.confidence + boost),
source: 'learned_pattern',
learnedBoost: Math.round(boost * 100) / 100,
};
}
return suggestion;
});
} catch (error) {
console.warn('[SuggestionEngine] Failed to apply learned pattern boost:', error.message);
return suggestions;
}
}
/**
* Find position of a subsequence in a pattern sequence
* @param {string[]} subseq - Subsequence to find
* @param {string[]} pattern - Full pattern sequence
* @returns {number} End index of match, or -1 if not found
* @private
*/
_findSequencePosition(subseq, pattern) {
if (!subseq || !pattern || subseq.length === 0) {
return -1;
}
// Normalize for comparison
const normalizedSubseq = subseq.map((c) => c.toLowerCase().replace(/^\*/, ''));
const normalizedPattern = pattern.map((c) => c.toLowerCase().replace(/^\*/, ''));
// Find where the subsequence ends in the pattern
for (let i = 0; i <= normalizedPattern.length - normalizedSubseq.length; i++) {
let matches = true;
for (let j = 0; j < normalizedSubseq.length; j++) {
if (normalizedPattern[i + j] !== normalizedSubseq[j]) {
matches = false;
break;
}
}
if (matches) {
return i + normalizedSubseq.length - 1;
}
}
// Try partial match (last command matches)
const lastCmd = normalizedSubseq[normalizedSubseq.length - 1];
for (let i = 0; i < normalizedPattern.length; i++) {
if (normalizedPattern[i] === lastCmd) {
return i;
}
}
return -1;
}
/**
* Invalidate the cache
*/
invalidateCache() {
this.suggestionCache = null;
this.cacheTimestamp = null;
this.cacheKey = null;
}
/**
* Get fallback suggestions when WIS is unavailable
* @param {Object} context - Context object
* @returns {Object} Fallback suggestions
*/
getFallbackSuggestions(context) {
const agent = context.agentId || 'dev';
// Agent-specific fallback suggestions
const fallbacks = {
dev: [
{
command: '*help',
args: '',
description: 'Show available commands',
confidence: 0.3,
priority: 1,
},
{
command: '*run-tests',
args: '',
description: 'Run test suite',
confidence: 0.25,
priority: 2,
},
{
command: '*develop',
args: '',
description: 'Start development mode',
confidence: 0.2,
priority: 3,
},
],
po: [
{
command: '*help',
args: '',
description: 'Show available commands',
confidence: 0.3,
priority: 1,
},
{
command: '*backlog-review',
args: '',
description: 'Review backlog',
confidence: 0.25,
priority: 2,
},
{
command: '*create-story',
args: '',
description: 'Create new story',
confidence: 0.2,
priority: 3,
},
],
qa: [
{
command: '*help',
args: '',
description: 'Show available commands',
confidence: 0.3,
priority: 1,
},
{
command: '*run-tests',
args: '',
description: 'Run test suite',
confidence: 0.25,
priority: 2,
},
{
command: '*review-qa',
args: '',
description: 'Start QA review',
confidence: 0.2,
priority: 3,
},
],
sm: [
{
command: '*help',
args: '',
description: 'Show available commands',
confidence: 0.3,
priority: 1,
},
{
command: '*create-next-story',
args: '',
description: 'Create next story',
confidence: 0.25,
priority: 2,
},
{
command: '*validate-story-draft',
args: '',
description: 'Validate story',
confidence: 0.2,
priority: 3,
},
],
default: [
{
command: '*help',
args: '',
description: 'Show available commands',
confidence: 0.3,
priority: 1,
},
{
command: '*status',
args: '',
description: 'Show project status',
confidence: 0.2,
priority: 2,
},
],
};
return {
workflow: null,
currentState: null,
confidence: 0.25,
suggestions: fallbacks[agent] || fallbacks.default,
isUncertain: true,
message: 'Using fallback suggestions - context unclear',
};
}
}
/**
* Create a new SuggestionEngine instance
* @param {Object} options - Configuration options
* @returns {SuggestionEngine} New engine instance
*/
function createSuggestionEngine(options = {}) {
return new SuggestionEngine(options);
}
module.exports = {
SuggestionEngine,
createSuggestionEngine,
SUGGESTION_CACHE_TTL,
LOW_CONFIDENCE_THRESHOLD,
};