@claude-vector/cli
Version:
CLI for Claude-integrated vector search
670 lines (561 loc) • 17.1 kB
JavaScript
/**
* FeedbackSystem - 段階的フィードバック制御システム
*
* 機能:
* - 段階的フィードバック(silent/normal/verbose)
* - 適応的出力制御(状況に応じた情報量調整)
* - 構造化ログ出力(JSON形式)
* - ログローテーション
* - リアルタイム統計表示
* - カラー出力対応
*/
import { EventEmitter } from 'events';
import fs from 'fs/promises';
import fsSync from 'fs';
import path from 'path';
import chalk from 'chalk';
/**
* フィードバックシステムのデフォルト設定
*/
const FEEDBACK_DEFAULTS = {
// フィードバックレベル
level: 'normal', // 'silent', 'normal', 'verbose'
// 出力制御
colorOutput: true, // カラー出力有効
enableEmojis: true, // 絵文字使用
showTimestamps: false, // タイムスタンプ表示
showProgress: true, // プログレス表示
// ログファイル設定
logToFile: true, // ログファイル出力
logFile: '.claude-watch.log', // ログファイル名
logFormat: 'json', // 'json' or 'text'
// ログローテーション
maxLogSize: 10 * 1024 * 1024, // 10MB
maxLogFiles: 5, // 最大ファイル数
// 統計表示
showStatistics: true, // 統計情報表示
statisticsInterval: 30000, // 30秒間隔
// プログレス制御
progressThreshold: 5, // 5個以上で進捗表示
batchSummaryThreshold: 10, // 10個以上でサマリー表示
// 通知制御
notifications: false, // デスクトップ通知
soundAlerts: false // 音声アラート
};
/**
* フィードバックレベル定義
*/
const FEEDBACK_LEVELS = {
silent: {
console: false,
progress: false,
statistics: false,
logFile: true,
errorOnly: true
},
normal: {
console: true,
progress: true,
statistics: false,
logFile: true,
errorOnly: false,
summaryOnly: true
},
verbose: {
console: true,
progress: true,
statistics: true,
logFile: true,
errorOnly: false,
summaryOnly: false,
detailed: true
}
};
export class FeedbackSystem extends EventEmitter {
constructor(config = {}) {
super();
this.config = { ...FEEDBACK_DEFAULTS, ...config };
this.levelConfig = FEEDBACK_LEVELS[this.config.level] || FEEDBACK_LEVELS.normal;
// 状態管理
this.state = {
isActive: false,
startTime: Date.now(),
lastLogRotation: Date.now(),
// 統計情報
messagesLogged: 0,
errorsLogged: 0,
warningsLogged: 0,
infosLogged: 0,
// プログレス管理
currentProgress: null,
lastProgressUpdate: 0,
// バッチ統計
currentBatch: {
startTime: null,
count: 0,
errors: 0,
totalTime: 0
}
};
// ログファイルパス
this.logFilePath = null;
this.projectRoot = null;
// タイマー
this.statisticsTimer = null;
this.progressTimer = null;
// カラー設定
this.colors = {
error: chalk.red,
warn: chalk.yellow,
info: chalk.cyan,
success: chalk.green,
debug: chalk.gray,
progress: chalk.blue,
statistics: chalk.magenta,
timestamp: chalk.gray
};
// 絵文字設定
this.emojis = {
error: '❌',
warn: '⚠️',
info: 'ℹ️',
success: '✅',
progress: '🔄',
statistics: '📊',
watch: '👀',
file: '📝',
batch: '📦',
time: '⏱️',
memory: '💾'
};
}
/**
* 初期化
*/
async initialize(projectRoot) {
this.projectRoot = projectRoot;
this.logFilePath = path.join(projectRoot, this.config.logFile);
// ログファイルの初期化
if (this.config.logToFile) {
await this.initializeLogFile();
}
// 統計タイマー開始
if (this.levelConfig.statistics && this.config.showStatistics) {
this.startStatisticsTimer();
}
this.state.isActive = true;
this.emit('initialized', { projectRoot, logFile: this.logFilePath });
}
/**
* ログファイル初期化
*/
async initializeLogFile() {
try {
// ログローテーションチェック
await this.checkLogRotation();
// セッション開始ログ
const sessionStart = {
type: 'session_start',
timestamp: new Date().toISOString(),
pid: process.pid,
config: this.config,
projectRoot: this.projectRoot
};
await this.writeToLogFile(sessionStart);
} catch (error) {
console.error('Failed to initialize log file:', error.message);
}
}
/**
* ログローテーション確認
*/
async checkLogRotation() {
if (!fsSync.existsSync(this.logFilePath)) {
return;
}
const stats = await fs.stat(this.logFilePath);
if (stats.size > this.config.maxLogSize) {
await this.rotateLogFile();
}
}
/**
* ログファイルローテーション
*/
async rotateLogFile() {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const rotatedPath = `${this.logFilePath}.${timestamp}`;
// 現在のログファイルをリネーム
await fs.rename(this.logFilePath, rotatedPath);
// 古いログファイルを削除
await this.cleanupOldLogFiles();
this.state.lastLogRotation = Date.now();
} catch (error) {
console.warn('Log rotation failed:', error.message);
}
}
/**
* 古いログファイル削除
*/
async cleanupOldLogFiles() {
try {
const logDir = path.dirname(this.logFilePath);
const logBasename = path.basename(this.logFilePath);
const files = await fs.readdir(logDir);
const logFiles = files
.filter(file => file.startsWith(logBasename + '.'))
.map(file => ({
name: file,
path: path.join(logDir, file),
stat: fsSync.statSync(path.join(logDir, file))
}))
.sort((a, b) => b.stat.mtime - a.stat.mtime); // 新しい順
// 最大ファイル数を超える古いファイルを削除
const filesToDelete = logFiles.slice(this.config.maxLogFiles);
for (const file of filesToDelete) {
await fs.unlink(file.path);
}
} catch (error) {
console.warn('Log cleanup failed:', error.message);
}
}
/**
* ファイル変更通知
*/
reportFileChange(filePath, metadata = {}) {
const relativePath = this.getRelativePath(filePath);
const message = {
type: 'file_change',
file: relativePath,
timestamp: new Date().toISOString(),
metadata
};
this.log('info', this.formatFileChangeMessage(relativePath, metadata), message);
}
/**
* バッチ開始通知
*/
reportBatchStart(count) {
this.state.currentBatch = {
startTime: Date.now(),
count,
errors: 0,
totalTime: 0
};
if (count >= this.config.progressThreshold && this.levelConfig.progress) {
const message = `${this.emoji('batch')} Processing batch: ${count} files...`;
this.log('info', message, { type: 'batch_start', count });
}
}
/**
* バッチ完了通知
*/
reportBatchComplete(stats) {
const duration = Date.now() - (this.state.currentBatch.startTime || Date.now());
this.state.currentBatch.totalTime = duration;
const message = {
type: 'batch_complete',
timestamp: new Date().toISOString(),
duration,
...stats
};
if (this.levelConfig.summaryOnly || this.levelConfig.detailed) {
const formattedMessage = this.formatBatchCompleteMessage(stats, duration);
this.log('success', formattedMessage, message);
}
// 統計更新
this.updateBatchStatistics(stats);
}
/**
* エラー通知
*/
reportError(error, context = {}) {
const message = {
type: 'error',
timestamp: new Date().toISOString(),
error: error.message,
stack: error.stack,
context
};
const formattedMessage = this.formatErrorMessage(error, context);
this.log('error', formattedMessage, message);
this.state.errorsLogged++;
}
/**
* 警告通知
*/
reportWarning(warning, context = {}) {
const message = {
type: 'warning',
timestamp: new Date().toISOString(),
warning,
context
};
const formattedMessage = this.formatWarningMessage(warning, context);
this.log('warn', formattedMessage, message);
this.state.warningsLogged++;
}
/**
* 統計情報表示
*/
showStatistics(stats) {
if (!this.levelConfig.statistics) {
return;
}
const formattedStats = this.formatStatistics(stats);
this.log('statistics', formattedStats, { type: 'statistics', ...stats });
}
/**
* プログレス表示
*/
showProgress(current, total, description = '') {
if (!this.levelConfig.progress) {
return;
}
const now = Date.now();
// プログレス更新頻度制限(500ms間隔)
if (now - this.state.lastProgressUpdate < 500) {
return;
}
this.state.lastProgressUpdate = now;
this.state.currentProgress = { current, total, description };
const percentage = Math.round((current / total) * 100);
const progressBar = this.createProgressBar(percentage);
const message = `${this.emoji('progress')} ${description} ${progressBar} ${current}/${total} (${percentage}%)`;
// プログレスは一時的な表示なのでログファイルには記録しない
if (this.levelConfig.console) {
this.outputToConsole('progress', message);
}
}
/**
* ログ出力メイン関数
*/
log(level, message, data = null) {
// レベル制御
if (!this.shouldOutput(level)) {
return;
}
// コンソール出力
if (this.levelConfig.console && !this.levelConfig.errorOnly || level === 'error') {
this.outputToConsole(level, message);
}
// ログファイル出力
if (this.config.logToFile) {
const logEntry = {
level,
message: this.stripColors(message),
timestamp: new Date().toISOString(),
pid: process.pid,
data
};
this.writeToLogFile(logEntry);
}
// 統計更新
this.updateMessageStatistics(level);
// イベント発行
this.emit('log', { level, message, data });
}
/**
* 出力制御判定
*/
shouldOutput(level) {
if (!this.state.isActive) {
return false;
}
if (this.config.level === 'silent' && level !== 'error') {
return false;
}
return true;
}
/**
* コンソール出力
*/
outputToConsole(level, message) {
const coloredMessage = this.config.colorOutput ? this.applyColors(level, message) : message;
const timestampedMessage = this.config.showTimestamps ? this.addTimestamp(coloredMessage) : coloredMessage;
console.log(timestampedMessage);
}
/**
* ログファイル書き込み
*/
async writeToLogFile(entry) {
try {
const logLine = this.config.logFormat === 'json'
? JSON.stringify(entry) + '\n'
: this.formatTextLog(entry) + '\n';
await fs.appendFile(this.logFilePath, logLine);
} catch (error) {
// ログ書き込みエラーは無視(無限ループ防止)
}
}
/**
* メッセージフォーマット関数群
*/
formatFileChangeMessage(filePath, metadata) {
const emoji = this.emoji('file');
return `${emoji} File changed: ${filePath}`;
}
formatBatchCompleteMessage(stats, duration) {
const emoji = this.emoji('success');
const durationStr = this.formatDuration(duration);
return `${emoji} Batch complete: ${stats.count || 0} files, ${stats.chunks || 0} chunks (${durationStr})`;
}
formatErrorMessage(error, context) {
const emoji = this.emoji('error');
const contextStr = Object.keys(context).length > 0 ? ` [${JSON.stringify(context)}]` : '';
return `${emoji} Error: ${error.message}${contextStr}`;
}
formatWarningMessage(warning, context) {
const emoji = this.emoji('warn');
const contextStr = Object.keys(context).length > 0 ? ` [${JSON.stringify(context)}]` : '';
return `${emoji} Warning: ${warning}${contextStr}`;
}
formatStatistics(stats) {
const emoji = this.emoji('statistics');
const uptime = this.formatDuration(Date.now() - this.state.startTime);
return `${emoji} Statistics: ${stats.filesWatched || 0} files, ${stats.changesProcessed || 0} changes, uptime: ${uptime}`;
}
/**
* ユーティリティ関数群
*/
emoji(type) {
return this.config.enableEmojis ? this.emojis[type] || '' : '';
}
applyColors(level, message) {
const colorFunc = this.colors[level];
return colorFunc ? colorFunc(message) : message;
}
stripColors(message) {
// ANSI escape codes を除去
// eslint-disable-next-line no-control-regex
return message.replace(/\x1b\[[0-9;]*m/g, '');
}
addTimestamp(message) {
const timestamp = this.colors.timestamp(`[${new Date().toISOString()}]`);
return `${timestamp} ${message}`;
}
getRelativePath(filePath) {
if (!this.projectRoot) {
return filePath;
}
return path.relative(this.projectRoot, filePath);
}
formatDuration(ms) {
if (ms < 1000) {
return `${ms}ms`;
} else if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
createProgressBar(percentage, width = 20) {
const filled = Math.round((percentage / 100) * width);
const empty = width - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
return `[${bar}]`;
}
formatTextLog(entry) {
return `${entry.timestamp} [${entry.level.toUpperCase()}] ${entry.message}`;
}
/**
* 統計更新
*/
updateMessageStatistics(level) {
this.state.messagesLogged++;
switch (level) {
case 'error':
this.state.errorsLogged++;
break;
case 'warn':
this.state.warningsLogged++;
break;
case 'info':
case 'success':
this.state.infosLogged++;
break;
}
}
updateBatchStatistics(stats) {
// バッチ統計の更新(必要に応じて実装)
}
/**
* 統計タイマー
*/
startStatisticsTimer() {
this.statisticsTimer = setInterval(() => {
if (this.levelConfig.statistics) {
const stats = this.getInternalStatistics();
this.showStatistics(stats);
}
}, this.config.statisticsInterval);
}
getInternalStatistics() {
return {
uptime: Date.now() - this.state.startTime,
messagesLogged: this.state.messagesLogged,
errorsLogged: this.state.errorsLogged,
warningsLogged: this.state.warningsLogged,
memoryUsage: Math.round(process.memoryUsage().heapUsed / 1024 / 1024)
};
}
/**
* 設定更新
*/
updateConfig(newConfig) {
const oldLevel = this.config.level;
this.config = { ...this.config, ...newConfig };
this.levelConfig = FEEDBACK_LEVELS[this.config.level] || FEEDBACK_LEVELS.normal;
// レベル変更時の処理
if (oldLevel !== this.config.level) {
this.log('info', `Feedback level changed: ${oldLevel} → ${this.config.level}`);
// 統計タイマーの制御
if (this.levelConfig.statistics && !this.statisticsTimer) {
this.startStatisticsTimer();
} else if (!this.levelConfig.statistics && this.statisticsTimer) {
clearInterval(this.statisticsTimer);
this.statisticsTimer = null;
}
}
this.emit('config-updated', this.config);
}
/**
* 状態取得
*/
getState() {
return {
...this.state,
config: this.config,
levelConfig: this.levelConfig,
logFilePath: this.logFilePath,
uptime: Date.now() - this.state.startTime
};
}
/**
* クリーンアップ
*/
async destroy() {
this.state.isActive = false;
if (this.statisticsTimer) {
clearInterval(this.statisticsTimer);
this.statisticsTimer = null;
}
if (this.progressTimer) {
clearInterval(this.progressTimer);
this.progressTimer = null;
}
// セッション終了ログ
if (this.config.logToFile) {
const sessionEnd = {
type: 'session_end',
timestamp: new Date().toISOString(),
duration: Date.now() - this.state.startTime,
statistics: this.getInternalStatistics()
};
await this.writeToLogFile(sessionEnd);
}
this.emit('destroyed');
}
}