bookworm-smart-assistant/scripts/watch-activity.js

220 lines
6.9 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Bookworm 活动日志实时查看器
*
* 使用方式:
* node watch-activity.js # 实时 tail 今日日志
* node watch-activity.js --date 2026-02-19 # 查看历史
* node watch-activity.js --filter mcp,bash # 分类过滤
* node watch-activity.js --stats # 统计摘要
*
* 特性:
* - ANSI 彩色输出 (skill=, agent=, mcp=, bash=绿, write=)
* - fs.watchFile 轮询 (500ms)兼容 WSL+NTFS
* - 增量读取只读文件新增字节
* - --stats 模式事件类型直方图 + Top 10 工具排名
*/
const fs = require('fs');
const path = require('path');
// 路径检测: 优先 paths.config.js回退环境检测
let CLAUDE_ROOT;
try {
CLAUDE_ROOT = require('./paths.config.js').PATHS.root;
} catch {
const IS_WSL = process.platform === 'linux' && fs.existsSync('/mnt/c');
CLAUDE_ROOT = IS_WSL ? '/mnt/c/Users/janson9527us/.claude' : 'C:/Users/janson9527us/.claude';
}
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
// ANSI 颜色
const C = {
skill: '\x1b[35m', // 紫
agent: '\x1b[36m', // 青
mcp: '\x1b[33m', // 黄
bash: '\x1b[32m', // 绿
write: '\x1b[34m', // 蓝
reset: '\x1b[0m',
dim: '\x1b[2m',
bold: '\x1b[1m',
};
// 解析命令行参数
function parseArgs() {
const args = process.argv.slice(2);
const opts = { date: null, filter: null, stats: false };
for (let i = 0; i < args.length; i++) {
if (args[i] === '--date' && args[i + 1]) {
opts.date = args[++i];
} else if (args[i] === '--filter' && args[i + 1]) {
opts.filter = new Set(args[++i].split(','));
} else if (args[i] === '--stats') {
opts.stats = true;
}
}
return opts;
}
// 获取日志文件路径
function getLogFile(dateStr) {
return path.join(DEBUG_DIR, `activity-${dateStr}.jsonl`);
}
// UTC ISO 字符串 → 本地时间 HH:MM:SS
function toLocalTime(isoStr) {
if (!isoStr) return '??:??:??';
const d = new Date(isoStr);
if (isNaN(d.getTime())) return '??:??:??';
return d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// 格式化单行日志输出
function formatEntry(entry) {
const color = C[entry.event] || '';
const time = toLocalTime(entry.ts);
const tag = (entry.event || '?').toUpperCase().padEnd(5);
const detail = entry.detail || '';
return `${C.dim}${time}${C.reset} ${color}${tag}${C.reset} ${entry.tool} ${C.dim}${detail}${C.reset}`;
}
// --stats 统计模式
function showStats(logFile) {
if (!fs.existsSync(logFile)) {
console.log('日志文件不存在:', logFile);
process.exit(1);
}
const content = fs.readFileSync(logFile, 'utf8').trim();
if (!content) {
console.log('日志为空');
process.exit(0);
}
const lines = content.split('\n');
const eventCounts = {};
const toolCounts = {};
let validCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
eventCounts[entry.event] = (eventCounts[entry.event] || 0) + 1;
toolCounts[entry.tool] = (toolCounts[entry.tool] || 0) + 1;
validCount++;
} catch { /* 跳过无效行 */ }
}
if (validCount === 0) {
console.log('无有效日志条目');
process.exit(0);
}
console.log(`\n${C.bold}活动统计 — ${path.basename(logFile)}${C.reset}`);
console.log(`总计: ${validCount} 条事件\n`);
// 事件类型直方图
console.log(`${C.bold}事件类型分布:${C.reset}`);
const maxCount = Math.max(...Object.values(eventCounts));
const barWidth = 30;
const sortedEvents = Object.entries(eventCounts).sort((a, b) => b[1] - a[1]);
for (const [event, count] of sortedEvents) {
const color = C[event] || '';
const bar = '█'.repeat(Math.max(1, Math.ceil(count / maxCount * barWidth)));
console.log(` ${color}${event.padEnd(6)}${C.reset} ${String(count).padStart(4)} ${color}${bar}${C.reset}`);
}
// Top 10 工具排名
console.log(`\n${C.bold}Top 10 工具:${C.reset}`);
const sortedTools = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
for (let i = 0; i < sortedTools.length; i++) {
const [tool, count] = sortedTools[i];
console.log(` ${String(i + 1).padStart(2)}. ${tool.padEnd(50)} ${count}`);
}
console.log('');
}
// 实时 tail 模式
function tailWatch(logFile, filterSet) {
console.log(`${C.bold}活动日志 — ${path.basename(logFile)}${C.reset}`);
console.log(`${C.dim}Ctrl+C 退出${filterSet ? ` | 过滤: ${[...filterSet].join(',')}` : ''}${C.reset}\n`);
// 读取现有内容(最后 20 行作为上下文)
let fileSize = 0;
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, 'utf8');
const lines = content.trim().split('\n').filter(Boolean);
const recent = lines.slice(-20);
fileSize = Buffer.byteLength(content, 'utf8');
for (const line of recent) {
try {
const entry = JSON.parse(line);
if (!filterSet || filterSet.has(entry.event)) {
console.log(formatEntry(entry));
}
} catch { /* 跳过 */ }
}
if (recent.length > 0) {
console.log(`${C.dim}--- 以上为历史 (最近 ${recent.length} 条) ---${C.reset}\n`);
}
}
// 增量轮询 (500ms),兼容 WSL+NTFS
const pollInterval = setInterval(() => {
try {
if (!fs.existsSync(logFile)) return;
const stat = fs.statSync(logFile);
if (stat.size <= fileSize) return;
// 只读新增字节,不重读全文件
const fd = fs.openSync(logFile, 'r');
const newSize = stat.size - fileSize;
const buffer = Buffer.alloc(newSize);
fs.readSync(fd, buffer, 0, newSize, fileSize);
fs.closeSync(fd);
fileSize = stat.size;
const newContent = buffer.toString('utf8');
const newLines = newContent.trim().split('\n').filter(Boolean);
for (const line of newLines) {
try {
const entry = JSON.parse(line);
if (!filterSet || filterSet.has(entry.event)) {
console.log(formatEntry(entry));
}
} catch { /* 跳过 */ }
}
} catch { /* 轮询异常静默处理 */ }
}, 500);
// 优雅退出
process.on('SIGINT', () => {
clearInterval(pollInterval);
console.log(`\n${C.dim}已停止监听${C.reset}`);
process.exit(0);
});
}
// 主程序
function main() {
const opts = parseArgs();
const dateStr = opts.date || new Date().toISOString().slice(0, 10);
const logFile = getLogFile(dateStr);
if (opts.stats) {
showStats(logFile);
} else {
tailWatch(logFile, opts.filter);
}
}
// 模块导出 (供测试使用)
if (typeof module !== 'undefined') {
module.exports = { C, parseArgs, getLogFile, toLocalTime, formatEntry, showStats, tailWatch, main };
}
if (require.main === module) {
main();
}