aiwf
Version:
AI Workflow Framework for Claude Code with multi-language support (Korean/English)
389 lines (323 loc) • 12.9 kB
JavaScript
import { ContextCompressor } from '../utils/context-compressor.js';
import { PersonaAwareCompressor } from '../utils/persona-aware-compressor.js';
import { getBackgroundMonitor } from '../utils/background-monitor.js';
import { TokenCounter } from '../utils/token-counter.js';
import { CompressionMetrics } from '../utils/compression-metrics.js';
import fs from 'fs/promises';
import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* compress-context 명령어 실행
*/
export async function executeCompressContext(args = []) {
const spinner = ora('Context 압축 준비 중...').start();
try {
// 파라미터 파싱
const { mode, targetPath, usePersona } = parseArguments(args);
spinner.text = '압축 대상 분석 중...';
// 대상 경로 결정
const resolvedPath = await resolveTargetPath(targetPath);
const files = await collectMarkdownFiles(resolvedPath);
if (files.length === 0) {
spinner.fail('압축할 마크다운 파일이 없습니다.');
return;
}
// 압축 전 통계
spinner.text = '원본 토큰 수 계산 중...';
const beforeStats = await calculateStats(files);
// 압축 시작
console.log('');
console.log(chalk.cyan('🗜️ Context 압축 시작'));
console.log(chalk.gray('━'.repeat(50)));
console.log(`압축 모드: ${chalk.yellow(mode)}`);
console.log(`대상: ${chalk.yellow(resolvedPath)}`);
console.log(`페르소나 연동: ${chalk.yellow(usePersona ? '활성화' : '비활성화')}`);
console.log('');
console.log(chalk.cyan('📊 압축 전 통계:'));
console.log(`- 총 파일 수: ${files.length}`);
console.log(`- 원본 토큰: ${chalk.red(beforeStats.totalTokens.toLocaleString())}`);
console.log(`- 원본 크기: ${(beforeStats.totalSize / 1024).toFixed(1)} KB`);
console.log('');
spinner.text = '압축 진행 중...';
// 압축 수행
const compressor = usePersona
? new PersonaAwareCompressor(mode)
: new ContextCompressor(mode);
const compressionResults = [];
const startTime = Date.now();
console.log(chalk.cyan('⚙️ 압축 진행 중...'));
for (const file of files) {
const fileName = path.basename(file);
const content = await fs.readFile(file, 'utf8');
const result = await compressor.compress(content, {
strategy: mode,
preserveStructure: true,
enableSummarization: mode === 'aggressive',
enableNormalization: true,
enableFiltering: mode !== 'minimal'
});
if (result.success) {
const savedTokens = result.originalTokens - result.compressedTokens;
const ratio = result.compressionRatio.toFixed(1);
console.log(chalk.green(`✓ ${fileName} (${result.originalTokens.toLocaleString()} → ${result.compressedTokens.toLocaleString()} 토큰, -${ratio}%)`));
compressionResults.push({
filePath: file,
fileName,
...result
});
} else {
console.log(chalk.red(`✗ ${fileName} (압축 실패: ${result.error})`));
}
}
const processingTime = Date.now() - startTime;
// 압축 후 통계
const afterStats = calculateCompressedStats(compressionResults);
const totalSaved = beforeStats.totalTokens - afterStats.totalTokens;
const overallRatio = ((totalSaved / beforeStats.totalTokens) * 100).toFixed(1);
// 압축된 파일 저장
spinner.text = '압축된 파일 저장 중...';
const outputDir = await saveCompressedFiles(compressionResults, resolvedPath);
// 압축 보고서 생성
await generateReport(compressionResults, {
mode,
targetPath: resolvedPath,
outputDir,
beforeStats,
afterStats,
processingTime
});
spinner.stop();
// 결과 출력
console.log('');
console.log(chalk.cyan('📊 압축 후 통계:'));
console.log(`- 압축된 토큰: ${chalk.green(afterStats.totalTokens.toLocaleString())}`);
console.log(`- 절약된 토큰: ${chalk.green(totalSaved.toLocaleString())}`);
console.log(`- 토큰 절약률: ${chalk.green(overallRatio + '%')}`);
console.log(`- 압축 시간: ${(processingTime / 1000).toFixed(1)}초`);
// 평균 품질 점수 계산
const avgQuality = compressionResults.reduce((sum, r) => {
return sum + (r.metadata?.validation?.qualityScore || 0);
}, 0) / compressionResults.length;
console.log(`- 품질 점수: ${chalk.green(avgQuality.toFixed(0) + '/100')}`);
// 페르소나 정보 출력
if (usePersona && compressor.currentPersona) {
console.log(`- 활성 페르소나: ${chalk.cyan(compressor.currentPersona)}`);
}
console.log('');
console.log(chalk.green('✅ 압축 완료!'));
console.log(`압축된 파일 위치: ${chalk.yellow(outputDir)}`);
// 페르소나 인식 압축인 경우 백그라운드 품질 체크
if (usePersona && compressor.currentPersona) {
const monitor = getBackgroundMonitor();
// 압축된 내용의 샘플로 품질 체크 (비차단)
const sampleResult = compressionResults[0];
if (sampleResult) {
monitor.monitorResponse(
sampleResult.compressed.substring(0, 1000), // 샘플만
compressor.currentPersona,
{ silent: false }
).then(feedback => {
if (feedback && feedback.feedback) {
console.log('');
console.log(feedback.feedback);
}
}).catch(() => {}); // 에러 무시
}
}
// 목표 달성 여부 확인
const targetRange = getTargetRange(mode);
if (parseFloat(overallRatio) < targetRange.min) {
console.log('');
console.log(chalk.yellow(`⚠️ 경고: 압축률이 목표 범위(${targetRange.min}-${targetRange.max}%)에 미달했습니다.`));
console.log('더 높은 압축률이 필요하면 aggressive 모드를 시도해보세요.');
}
// 리소스 정리
compressor.cleanup();
} catch (error) {
spinner.fail(`압축 실패: ${error.message}`);
console.error(error);
}
}
/**
* 명령어 인자를 파싱합니다
* @param {Array<string>} args - 인자 배열
* @returns {Object} 파싱된 옵션
*/
function parseArguments(args) {
let mode = 'balanced';
let targetPath = null;
let usePersona = false;
for (const arg of args) {
if (['aggressive', 'balanced', 'minimal'].includes(arg)) {
mode = arg;
} else if (arg === '--persona' || arg === '-p') {
usePersona = true;
} else if (arg && !arg.startsWith('-')) {
targetPath = arg;
}
}
return { mode, targetPath, usePersona };
}
/**
* 대상 경로를 결정합니다
* @param {string} targetPath - 사용자 지정 경로
* @returns {string} 결정된 경로
*/
async function resolveTargetPath(targetPath) {
if (targetPath) {
// 절대 경로가 아니면 현재 디렉토리 기준으로 변환
if (!path.isAbsolute(targetPath)) {
targetPath = path.join(process.cwd(), targetPath);
}
return targetPath;
}
// 기본값: 프로젝트의 .aiwf 디렉토리
const aiwfPath = path.join(process.cwd(), '.aiwf');
try {
await fs.access(aiwfPath);
return aiwfPath;
} catch {
// .aiwf가 없으면 현재 디렉토리
return process.cwd();
}
}
/**
* 마크다운 파일들을 수집합니다
* @param {string} targetPath - 대상 경로
* @returns {Array<string>} 파일 경로 배열
*/
async function collectMarkdownFiles(targetPath) {
const files = [];
try {
const stats = await fs.stat(targetPath);
if (stats.isFile() && targetPath.endsWith('.md')) {
files.push(targetPath);
} else if (stats.isDirectory()) {
await collectFromDirectory(targetPath, files);
}
} catch (error) {
console.error(`경로 접근 오류: ${targetPath}`);
}
return files;
}
/**
* 디렉토리에서 마크다운 파일을 재귀적으로 수집합니다
* @param {string} dirPath - 디렉토리 경로
* @param {Array<string>} files - 파일 배열
*/
async function collectFromDirectory(dirPath, files) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.')) {
await collectFromDirectory(fullPath, files);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
}
/**
* 파일 통계를 계산합니다
* @param {Array<string>} files - 파일 경로 배열
* @returns {Object} 통계 정보
*/
async function calculateStats(files) {
const tokenCounter = new TokenCounter();
let totalTokens = 0;
let totalSize = 0;
for (const file of files) {
const content = await fs.readFile(file, 'utf8');
totalTokens += tokenCounter.countTokens(content);
totalSize += Buffer.byteLength(content, 'utf8');
}
tokenCounter.cleanup();
return { totalTokens, totalSize };
}
/**
* 압축된 통계를 계산합니다
* @param {Array<Object>} results - 압축 결과 배열
* @returns {Object} 통계 정보
*/
function calculateCompressedStats(results) {
const totalTokens = results.reduce((sum, r) => sum + r.compressedTokens, 0);
const totalSize = results.reduce((sum, r) => sum + Buffer.byteLength(r.compressed, 'utf8'), 0);
return { totalTokens, totalSize };
}
/**
* 압축된 파일들을 저장합니다
* @param {Array<Object>} results - 압축 결과 배열
* @param {string} targetPath - 원본 경로
* @returns {string} 출력 디렉토리
*/
async function saveCompressedFiles(results, targetPath) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const outputDir = path.join(path.dirname(targetPath), `.aiwf_compressed`, timestamp);
await fs.mkdir(outputDir, { recursive: true });
for (const result of results) {
const outputPath = path.join(outputDir, result.fileName);
await fs.writeFile(outputPath, result.compressed);
}
return outputDir;
}
/**
* 압축 보고서를 생성합니다
* @param {Array<Object>} results - 압축 결과 배열
* @param {Object} stats - 통계 정보
*/
async function generateReport(results, stats) {
const report = [];
report.push('# Context 압축 보고서');
report.push('');
report.push(`**생성 시간**: ${new Date().toISOString()}`);
report.push(`**압축 모드**: ${stats.mode}`);
report.push(`**대상 경로**: ${stats.targetPath}`);
report.push(`**출력 경로**: ${stats.outputDir}`);
report.push('');
report.push('## 전체 통계');
report.push(`- **파일 수**: ${results.length}`);
report.push(`- **원본 토큰**: ${stats.beforeStats.totalTokens.toLocaleString()}`);
report.push(`- **압축 토큰**: ${stats.afterStats.totalTokens.toLocaleString()}`);
report.push(`- **절약 토큰**: ${(stats.beforeStats.totalTokens - stats.afterStats.totalTokens).toLocaleString()}`);
report.push(`- **전체 압축률**: ${((stats.beforeStats.totalTokens - stats.afterStats.totalTokens) / stats.beforeStats.totalTokens * 100).toFixed(1)}%`);
report.push(`- **처리 시간**: ${(stats.processingTime / 1000).toFixed(1)}초`);
report.push('');
report.push('## 파일별 상세');
report.push('');
report.push('| 파일명 | 원본 토큰 | 압축 토큰 | 압축률 | 품질 점수 |');
report.push('|--------|-----------|-----------|--------|-----------|');
for (const result of results) {
const qualityScore = result.metadata?.validation?.qualityScore || 0;
report.push(
`| ${result.fileName} | ${result.originalTokens.toLocaleString()} | ` +
`${result.compressedTokens.toLocaleString()} | ${result.compressionRatio}% | ` +
`${qualityScore}/100 |`
);
}
const reportPath = path.join(stats.outputDir, 'compression-report.md');
await fs.writeFile(reportPath, report.join('\n'));
}
/**
* 압축 모드별 목표 범위를 반환합니다
* @param {string} mode - 압축 모드
* @returns {Object} 목표 범위
*/
function getTargetRange(mode) {
const ranges = {
aggressive: { min: 50, max: 70 },
balanced: { min: 30, max: 50 },
minimal: { min: 10, max: 30 }
};
return ranges[mode] || { min: 0, max: 100 };
}
// CLI로 직접 실행하는 경우
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
executeCompressContext(args);
}
export default executeCompressContext;