@ace-sdk/cli
Version:
ACE CLI - Command-line tool for intelligent pattern learning and playbook management
313 lines ⢠14.1 kB
JavaScript
/**
* Learning command - submit execution traces to update playbook
*
* Supports SSE streaming for real-time progress feedback (v2.1.0+)
* Supports git context for AI-Trail pattern correlation (v2.2.0+)
*
* @see https://github.com/ce-dot-net/ace-sdk/issues/16
*/
import { readFileSync } from 'fs';
import { globalOptions } from '../cli.js';
import { createContext, loadConfig } from '../types/config.js';
import { ACEServerClient } from '../services/server-client.js';
import { LEARNING_STAGE_ICONS } from '../types/pattern.js';
import { Logger } from '../services/logger.js';
import { GitContextService } from '../services/git-context.js';
import chalk from 'chalk';
/**
* Submit learning event to update playbook
*/
export async function learnCommand(options) {
const logger = new Logger(globalOptions);
let trace = null;
// Read from file if specified
if (options.transcript) {
try {
const content = readFileSync(options.transcript, 'utf8');
trace = JSON.parse(content);
}
catch (error) {
if (logger.isJson()) {
logger.error('Failed to read transcript file');
}
else {
logger.error(chalk.red(`Error: Failed to read transcript file: ${error instanceof Error ? error.message : String(error)}`));
}
process.exit(1);
}
}
// Read from stdin if specified
else if (options.stdin) {
try {
const stdinData = readFileSync(0, 'utf8'); // fd 0 is stdin
trace = JSON.parse(stdinData.trim());
}
catch (error) {
if (logger.isJson()) {
logger.error('Failed to read from stdin');
}
else {
logger.error(chalk.red(`Error: Failed to read from stdin: ${error instanceof Error ? error.message : String(error)}`));
}
process.exit(1);
}
}
// Build from command-line options
else if (options.task && (options.success || options.failure) && options.output) {
const trajectory = [{
step: 1,
action: options.task,
args: {},
result: 'Completed'
}];
trace = {
task: options.task,
trajectory,
result: {
success: options.success || false,
output: options.output,
error: options.failure ? 'Task failed' : undefined
},
playbook_used: [],
timestamp: new Date().toISOString()
};
// Add git context if enabled (default: true)
if (options.gitContext !== false) {
const gitContext = detectGitContextForTrace(options, logger);
if (gitContext) {
trace.git = gitContext;
logger.debug(`Git context detected: ${gitContext.commit_hash.substring(0, 7)} on ${gitContext.branch}`);
}
}
}
else {
if (logger.isJson()) {
logger.error('Must provide either --transcript, --stdin, or --task + --success/--failure + --output');
}
else {
logger.error(chalk.red('Error: Must provide either:'));
logger.info(chalk.dim(' - --transcript <file>'));
logger.info(chalk.dim(' - --stdin'));
logger.info(chalk.dim(' - --task + --success/--failure + --output'));
}
process.exit(1);
}
if (!trace) {
if (logger.isJson()) {
logger.error('Failed to create execution trace');
}
else {
logger.error(chalk.red('Error: Failed to create execution trace'));
}
process.exit(1);
}
// Use streaming by default (options.stream is true unless --no-stream)
const useStream = options.stream !== false;
// Verbosity priority: CLI flag > ENV > config file > default
// Note: ENV is already loaded into config by loadConfig(), but CLI --verbosity has highest priority
const config = loadConfig();
const envVerbosity = process.env.ACE_VERBOSITY?.toLowerCase();
const verbosity = options.verbosity
|| (envVerbosity === 'compact' || envVerbosity === 'detailed' ? envVerbosity : undefined)
|| config.verbosity
|| 'compact';
// Start with spinner for initial message
let spinner = logger.spinner('Submitting learning event...');
try {
const context = await createContext({ org: globalOptions.org, project: globalOptions.project });
const client = new ACEServerClient(context, logger);
// Check server config for learning settings
try {
const serverConfig = await client.getConfig();
const runtimeSettings = serverConfig?.runtime_settings || {};
// Warn if learning is disabled on server
if (runtimeSettings.learningEnabled === false) {
logger.warn('Learning is disabled on the server. Trace will be stored but not processed.');
}
// Log server thresholds
logger.debug(`Learning settings: enabled=${runtimeSettings.learningEnabled ?? true}, min_confidence=${runtimeSettings.learningMinConfidence ?? 0.3}, min_tokens=${runtimeSettings.learningMinTokens ?? 100}`);
}
catch (error) {
logger.debug('Failed to fetch server config, proceeding with submission');
}
let result;
if (useStream) {
// Use SSE streaming for real-time progress
logger.debug('Using SSE streaming for learning submission');
result = await client.storeExecutionTraceStream(trace, {
verbosity,
onEvent: (event) => {
// Update spinner with progress
const icon = LEARNING_STAGE_ICONS[event.stage] || 'ā³';
if (event.stage === 'error') {
spinner?.fail(`${icon} ${event.message}`);
return;
}
if (event.stage === 'done') {
// Don't update spinner here - we'll handle it after
return;
}
// Update spinner text with stage progress
if (spinner) {
spinner.text = `${icon} ${event.message}`;
}
// In detailed mode, show extra data
if (verbosity === 'detailed' && event.data && event.stage === 'synthesizing') {
const insightsCount = event.data.insights_count;
if (insightsCount !== undefined) {
logger.debug(` Extracted ${insightsCount} insights`);
}
}
},
onError: (error) => {
logger.debug(`SSE stream error: ${error.message}`);
},
fallbackOnError: true
});
}
else {
// Use traditional sync POST
logger.debug('Using sync POST for learning submission (streaming disabled)');
result = await client.storeExecutionTrace(trace);
}
// Invalidate cache so next request gets fresh data
client.invalidateCache();
spinner?.succeed('Learning event submitted');
if (logger.isJson()) {
// JSON output includes full response
logger.output({
success: true,
message: 'Execution trace stored successfully',
task: trace.task,
timestamp: trace.timestamp,
analysis_performed: result.analysis_performed,
learning_statistics: result.learning_statistics
});
}
else {
// Human-readable output
logger.info(chalk.green('\nā
Learning event submitted successfully'));
logger.info(chalk.dim(` Task: ${trace.task}`));
logger.info(chalk.dim(` Timestamp: ${trace.timestamp}`));
// Show learning statistics if available (server v3.10.0+)
if (result.learning_statistics) {
const stats = result.learning_statistics;
logger.info(chalk.bold('\nš Learning Results:'));
// Show creation/update counts
if (stats.patterns_created > 0) {
logger.info(chalk.green(` ⢠${stats.patterns_created} new pattern${stats.patterns_created === 1 ? '' : 's'} created`));
}
if (stats.patterns_updated > 0) {
logger.info(chalk.cyan(` ⢠${stats.patterns_updated} pattern${stats.patterns_updated === 1 ? '' : 's'} updated`));
}
if (stats.patterns_pruned > 0) {
logger.info(chalk.yellow(` ⢠${stats.patterns_pruned} low-quality pattern${stats.patterns_pruned === 1 ? '' : 's'} pruned`));
}
if (stats.patterns_deduplicated > 0) {
logger.info(chalk.blue(` ⢠${stats.patterns_deduplicated} duplicate${stats.patterns_deduplicated === 1 ? '' : 's'} merged`));
}
// Show section breakdown if verbose and available
if (logger.isVerbose() && stats.by_section) {
const sections = stats.by_section;
const totalSectionChanges = (sections.strategies_and_hard_rules || 0) +
(sections.useful_code_snippets || 0) +
(sections.troubleshooting_and_pitfalls || 0) +
(sections.apis_to_use || 0);
if (totalSectionChanges > 0) {
logger.info(chalk.bold('\n Affected Sections:'));
if (sections.strategies_and_hard_rules > 0) {
logger.info(chalk.dim(` - Strategies & Hard Rules: ${sections.strategies_and_hard_rules}`));
}
if (sections.useful_code_snippets > 0) {
logger.info(chalk.dim(` - Useful Code Snippets: ${sections.useful_code_snippets}`));
}
if (sections.troubleshooting_and_pitfalls > 0) {
logger.info(chalk.dim(` - Troubleshooting & Pitfalls: ${sections.troubleshooting_and_pitfalls}`));
}
if (sections.apis_to_use > 0) {
logger.info(chalk.dim(` - APIs to Use: ${sections.apis_to_use}`));
}
}
}
// Show quality metrics if available
logger.info(chalk.bold('\n Quality Metrics:'));
if (stats.average_confidence !== undefined) {
logger.info(chalk.dim(` - Average confidence: ${(stats.average_confidence * 100).toFixed(0)}%`));
}
if (stats.helpful_delta !== undefined && stats.helpful_delta > 0) {
logger.info(chalk.dim(` - Helpful feedback: +${stats.helpful_delta}`));
}
if (stats.analysis_time_seconds !== undefined) {
logger.info(chalk.dim(` - Analysis time: ${stats.analysis_time_seconds.toFixed(1)}s`));
}
logger.info('');
}
else {
// Fallback for older servers without statistics
logger.info(chalk.dim(' Server is analyzing and updating playbook...\n'));
}
}
}
catch (error) {
spinner?.fail('Failed to submit learning event');
if (logger.isJson()) {
logger.error(error instanceof Error ? error.message : String(error));
}
else {
logger.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}\n`));
}
process.exit(1);
}
}
/**
* Detect git context for execution trace.
*
* Uses explicit options if provided, otherwise auto-detects from cwd.
* Returns null gracefully if not in a git repository.
*
* @since v2.2.0
*/
function detectGitContextForTrace(options, logger) {
try {
const gitService = new GitContextService();
// Check if we're in a git repo
if (!gitService.isGitRepository()) {
logger.debug('Not in a git repository, skipping git context');
return null;
}
// Use explicit commit if provided
const commitHash = options.gitCommit || gitService.getCurrentHead();
if (!commitHash) {
logger.debug('No commit found, skipping git context');
return null;
}
// Use explicit branch if provided, otherwise detect
const branch = options.gitBranch || gitService.getBranch();
if (!branch) {
logger.debug('No branch found, skipping git context');
return null;
}
// Build context
const context = {
commit_hash: commitHash,
branch,
files_changed: gitService.getChangedFiles(commitHash),
commit_message: gitService.getCommitMessage(commitHash) || undefined,
timestamp: gitService.getCommitTimestamp(commitHash) || undefined,
author: gitService.getCommitAuthor(commitHash) || undefined,
parent_commits: gitService.getParentCommits(commitHash)
};
// Get stats (may fail for initial commit)
const stats = gitService.getCommitStats(commitHash);
if (stats) {
context.insertions = stats.insertions;
context.deletions = stats.deletions;
}
return context;
}
catch (error) {
logger.debug(`Failed to detect git context: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
//# sourceMappingURL=learn.js.map