220 lines
6.9 KiB
JavaScript
220 lines
6.9 KiB
JavaScript
#!/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();
|
||
}
|