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

220 lines
6.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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();
}