bookworm-smart-assistant/scripts/auto-cleanup.js

664 lines
23 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* Bookworm Smart Assistant - 自动磁盘清理脚本
*
* 功能:
* 1. 清理 debug/ 目录 (保留最近 7 )
* 2. 清理 projects/ 会话日志 (非活跃项目 >14, 活跃项目 >30)
* 3. 清理 projects/ tool-results 缓存 (>7)
* 4. 清理 shell-snapshots/ (保留最近 30 )
* 5. 清理 backups/ (保留最近 10 )
* 6. 磁盘健康度报告
*
* 使用方式:
* node auto-cleanup.js # 预览模式 (dry-run)
* node auto-cleanup.js --execute # 执行清理
* node auto-cleanup.js --report # 仅输出 JSON 报告 ( self-auditor 消费)
*
* 集成点:
* - self-auditor D8 维度: 读取 --report 输出
* - self-healer M5 模式: 调用 --execute 执行
* - 可配合 cron/Task Scheduler 定期运行
*
* 创建: 2026-02-20
*/
const fs = require('fs');
const path = require('path');
// ─── 配置 ───────────────────────────────────────────────
// 优先从 paths.config.js 获取根目录,回退到环境检测
let _detectedRoot;
try {
_detectedRoot = require('./paths.config.js').PATHS.root;
} catch {
const os = require('os');
const IS_WSL = process.platform === 'linux' && fs.existsSync('/mnt/c');
// CLEANUP_PATHS_PORTABLE_2026_04_21: 跨机可移植, 用 os.homedir() 替代硬编码用户名
_detectedRoot = IS_WSL
? path.join('/mnt/c/Users', path.basename(os.homedir()), '.claude')
: path.join(os.homedir(), '.claude');
}
const CONFIG = {
claudeRoot: _detectedRoot,
thresholds: {
debug: { maxAgeDays: 7 },
shellSnapshots: { maxAgeDays: 30 },
toolResults: { maxAgeDays: 7 },
inactiveProjects: { maxAgeDays: 7 }, // C--MYBIO-* 等非主项目 (14→7)
activeProjects: { maxAgeDays: 14 }, // C--Users-* 主工作目录 (30→14)
backups: { maxCount: 10 },
},
// 主工作目录标识 (这些项目保留更久)
activeProjectPatterns: [/^C--Users-/],
// 不可删除的文件/目录
protectedFiles: [
'sessions-index.json',
'CLAUDE.md',
'.gitignore',
],
// 磁盘告警阈值
diskWarningMB: 8192, // >8GB 警告
diskCriticalMB: 16384, // >16GB 严重
};
// ─── Pinned Sessions 保护 ─────────────────────────────────
// 读取 pinned-sessions.json提取未过期的 sessionId 集合
// 这些会话由 /rename 触发 pin不被 auto-cleanup 删除
function loadPinnedSessionIds() {
const pinFile = path.join(CONFIG.claudeRoot, 'pinned-sessions.json');
const pinned = new Set();
try {
const data = JSON.parse(fs.readFileSync(pinFile, 'utf8'));
const now = Date.now();
for (const [sid, entry] of Object.entries(data.pins || {})) {
const expiry = entry.expiresAt ? new Date(entry.expiresAt).getTime() : Infinity;
if (isNaN(expiry) || expiry > now) {
pinned.add(sid);
}
}
} catch { /* 文件不存在或损坏,不阻塞清理 */ }
return pinned;
}
// ─── 工具函数 ───────────────────────────────────────────
function getDaysSinceModified(filePath) {
try {
const stat = fs.statSync(filePath);
const now = Date.now();
return (now - stat.mtimeMs) / (1000 * 60 * 60 * 24);
} catch {
return Infinity;
}
}
function getDirectorySizeMB(dirPath) {
// 内部递归统一用字节,仅最外层转 MB
function getSizeBytes(dir) {
let total = 0;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink()) continue; // 跳过符号链接,防止穿越
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
total += getSizeBytes(fullPath); // 返回字节
} else if (entry.isFile()) {
try {
total += fs.statSync(fullPath).size; // 字节
} catch { /* 忽略无权限文件 */ }
}
}
} catch { /* 目录不存在或无权限 */ }
return total;
}
return getSizeBytes(dirPath) / (1024 * 1024);
}
function formatMB(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${(mb * 1024).toFixed(0)} KB`;
}
function collectFiles(dirPath, options = {}) {
const { maxDepth = 1, pattern = null, type = 'file' } = options;
const results = [];
function walk(dir, depth) {
if (depth > maxDepth) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink()) continue; // 跳过符号链接,防止穿越删除
const fullPath = path.join(dir, entry.name);
if (type === 'file' && entry.isFile()) {
if (!pattern || pattern.test(entry.name)) {
results.push(fullPath);
}
} else if (type === 'directory' && entry.isDirectory()) {
if (!pattern || pattern.test(entry.name)) {
results.push(fullPath);
}
}
if (entry.isDirectory() && depth < maxDepth) {
walk(fullPath, depth + 1);
}
}
} catch { /* 忽略 */ }
}
walk(dirPath, 0);
return results;
}
function safeDelete(filePath, isDryRun) {
if (isDryRun) return true;
try {
// 使用 lstatSync 检测符号链接,防止穿越删除
const lstat = fs.lstatSync(filePath);
if (lstat.isSymbolicLink()) {
fs.unlinkSync(filePath); // 仅删除链接本身,不跟随
return true;
}
if (lstat.isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
} else {
fs.unlinkSync(filePath);
}
return true;
} catch {
return false;
}
}
// ─── JSONL 行数轮转清理 ─────────────────────────────────
/**
* 对指定 JSONL 文件做行数限制轮转保留最新 maxLines
* @param {string} filePath - 文件完整路径
* @param {number} maxLines - 最大保留行数
* @param {boolean} isDryRun - 预览模式
* @returns {{ truncated: boolean, removedLines: number, freedMB: number }}
*/
function truncateJsonl(filePath, maxLines, isDryRun) {
const result = { truncated: false, removedLines: 0, freedMB: 0 };
try {
if (!fs.existsSync(filePath)) return result;
const raw = fs.readFileSync(filePath, 'utf8');
const lines = raw.split('\n').filter(l => l.trim() !== '');
if (lines.length <= maxLines) return result;
const removed = lines.length - maxLines;
const kept = lines.slice(removed); // 保留最新 maxLines 行
result.truncated = true;
result.removedLines = removed;
result.freedMB = Buffer.byteLength(lines.slice(0, removed).join('\n'), 'utf8') / (1024 * 1024);
if (!isDryRun) {
// P2-3: 原子写入防止截断中断数据丢失
const tmpTrunc = filePath + ".tmp." + process.pid;
fs.writeFileSync(tmpTrunc, kept.join('\n') + '\n', 'utf8');
fs.renameSync(tmpTrunc, filePath);
}
} catch { /* 忽略 */ }
return result;
}
/**
* 清理 route-metrics.jsonl ab-experiments.jsonl行数限制轮转
*/
function cleanJsonlMetrics(isDryRun) {
const debugDir = path.join(CONFIG.claudeRoot, 'debug');
const result = { deleted: 0, freedMB: 0, kept: 0, errors: 0 };
// route-metrics.jsonl 保留最近 5000 行
const metricsFile = path.join(debugDir, 'route-metrics.jsonl');
const metricsResult = truncateJsonl(metricsFile, 5000, isDryRun);
if (metricsResult.truncated) {
result.deleted += metricsResult.removedLines;
result.freedMB += metricsResult.freedMB;
}
// ab-experiments.jsonl 保留最近 2000 行
const abFile = path.join(debugDir, 'ab-experiments.jsonl');
const abResult = truncateJsonl(abFile, 2000, isDryRun);
if (abResult.truncated) {
result.deleted += abResult.removedLines;
result.freedMB += abResult.freedMB;
}
// P2: 根目录 history.jsonl 保留最近 5000 行
const historyFile = path.join(CONFIG.claudeRoot, 'history.jsonl');
const historyResult = truncateJsonl(historyFile, 5000, isDryRun);
if (historyResult.truncated) {
result.deleted += historyResult.removedLines;
result.freedMB += historyResult.freedMB;
}
// P2: 根目录 evolution-log.jsonl 保留最近 2000 行
const evoFile = path.join(CONFIG.claudeRoot, 'evolution-log.jsonl');
const evoResult = truncateJsonl(evoFile, 2000, isDryRun);
if (evoResult.truncated) {
result.deleted += evoResult.removedLines;
result.freedMB += evoResult.freedMB;
}
return result;
}
// ─── 清理任务 ───────────────────────────────────────────
function cleanDebug(isDryRun) {
const debugDir = path.join(CONFIG.claudeRoot, 'debug');
const maxAge = CONFIG.thresholds.debug.maxAgeDays;
const result = { deleted: 0, freedMB: 0, kept: 0, errors: 0 };
if (!fs.existsSync(debugDir)) return result;
// 同时清理 .tmp.* 孤儿文件 (log-rotator 崩溃残留)
const files = collectFiles(debugDir, { pattern: /\.(txt|jsonl)$|\.tmp\.\d+$/ });
for (const file of files) {
const age = getDaysSinceModified(file);
if (age > maxAge) {
const size = fs.statSync(file).size / (1024 * 1024);
if (safeDelete(file, isDryRun)) {
result.deleted++;
result.freedMB += size;
} else {
result.errors++;
}
} else {
result.kept++;
}
}
return result;
}
function cleanShellSnapshots(isDryRun) {
const snapDir = path.join(CONFIG.claudeRoot, 'shell-snapshots');
const maxAge = CONFIG.thresholds.shellSnapshots.maxAgeDays;
const result = { deleted: 0, freedMB: 0, kept: 0, errors: 0 };
if (!fs.existsSync(snapDir)) return result;
const files = collectFiles(snapDir, { pattern: /\.sh$/ });
for (const file of files) {
const age = getDaysSinceModified(file);
if (age > maxAge) {
const size = fs.statSync(file).size / (1024 * 1024);
if (safeDelete(file, isDryRun)) {
result.deleted++;
result.freedMB += size;
} else {
result.errors++;
}
} else {
result.kept++;
}
}
return result;
}
function cleanToolResults(isDryRun) {
const projDir = path.join(CONFIG.claudeRoot, 'projects');
const maxAge = CONFIG.thresholds.toolResults.maxAgeDays;
const result = { deleted: 0, freedMB: 0, kept: 0, errors: 0 };
if (!fs.existsSync(projDir)) return result;
// 递归查找所有 tool-results 目录
function findToolResults(dir, depth = 0) {
if (depth > 4) return;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const fullPath = path.join(dir, entry.name);
if (entry.name === 'tool-results') {
// 清理此目录内的旧文件
const trFiles = collectFiles(fullPath);
for (const file of trFiles) {
const age = getDaysSinceModified(file);
if (age > maxAge) {
const size = fs.statSync(file).size / (1024 * 1024);
if (safeDelete(file, isDryRun)) {
result.deleted++;
result.freedMB += size;
} else {
result.errors++;
}
} else {
result.kept++;
}
}
} else {
findToolResults(fullPath, depth + 1);
}
}
} catch { /* 忽略 */ }
}
findToolResults(projDir);
return result;
}
function cleanProjectSessions(isDryRun) {
const projDir = path.join(CONFIG.claudeRoot, 'projects');
const result = { deleted: 0, freedMB: 0, kept: 0, errors: 0 };
if (!fs.existsSync(projDir)) return result;
try {
const projects = fs.readdirSync(projDir, { withFileTypes: true })
.filter(e => e.isDirectory());
// 加载 pinned sessions (renamed 会话保护) — 全局加载一次
const pinnedIds = loadPinnedSessionIds();
for (const proj of projects) {
const projPath = path.join(projDir, proj.name);
const isActive = CONFIG.activeProjectPatterns.some(p => p.test(proj.name));
const maxAge = isActive
? CONFIG.thresholds.activeProjects.maxAgeDays
: CONFIG.thresholds.inactiveProjects.maxAgeDays;
// 清理 JSONL 会话日志
const jsonlFiles = collectFiles(projPath, { pattern: /\.jsonl$/ });
for (const file of jsonlFiles) {
const basename = path.basename(file);
if (CONFIG.protectedFiles.includes(basename)) continue;
// 跳过已 pin 的会话 (通过 /rename 命名的会话)
const sessionIdFromFile = basename.replace(/\.jsonl$/, '');
if (pinnedIds.has(sessionIdFromFile)) {
result.kept++;
continue;
}
const age = getDaysSinceModified(file);
if (age > maxAge) {
const size = fs.statSync(file).size / (1024 * 1024);
if (safeDelete(file, isDryRun)) {
result.deleted++;
result.freedMB += size;
} else {
result.errors++;
}
} else {
result.kept++;
}
}
// 清理同名会话子目录
const sessionDirs = collectFiles(projPath, { type: 'directory' });
for (const dir of sessionDirs) {
const dirName = path.basename(dir);
// UUID 格式的会话目录
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(dirName)) continue;
// B2 安全: 验证目录实际位于 projPath 下 (防符号链接/上级跳出)
const rel_b2 = path.relative(projPath, dir);
if (rel_b2.startsWith('..') || path.isAbsolute(rel_b2)) continue;
try {
const st_b2 = fs.lstatSync(dir);
if (st_b2.isSymbolicLink()) continue;
} catch { continue; }
// 跳过已 pin 的会话目录
if (pinnedIds.has(dirName)) {
result.kept++;
continue;
}
const age = getDaysSinceModified(dir);
if (age > maxAge) {
const size = getDirectorySizeMB(dir);
if (safeDelete(dir, isDryRun)) {
result.deleted++;
result.freedMB += size;
} else {
result.errors++;
}
} else {
result.kept++;
}
}
}
} catch { /* 忽略 */ }
return result;
}
function cleanBackups(isDryRun) {
const backupDir = path.join(CONFIG.claudeRoot, 'backups');
const maxCount = CONFIG.thresholds.backups.maxCount;
const result = { deleted: 0, freedMB: 0, kept: 0, errors: 0 };
if (!fs.existsSync(backupDir)) return result;
const files = collectFiles(backupDir)
.map(f => ({ path: f, mtime: fs.statSync(f).mtimeMs, size: fs.statSync(f).size }))
.sort((a, b) => b.mtime - a.mtime); // 最新在前
for (let i = 0; i < files.length; i++) {
if (i < maxCount) {
result.kept++;
} else {
const sizeMB = files[i].size / (1024 * 1024);
if (safeDelete(files[i].path, isDryRun)) {
result.deleted++;
result.freedMB += sizeMB;
} else {
result.errors++;
}
}
}
return result;
}
// ─── 磁盘健康报告 ───────────────────────────────────────
function generateDiskReport() {
const root = CONFIG.claudeRoot;
const dirs = [
'debug', 'projects', 'shell-snapshots', 'skills', 'agents',
'hooks', 'backups', 'artifacts', 'plans', 'tasks', 'todos',
'teams', 'file-history', 'cache', 'plugins', '.pnpm-store',
'telemetry', 'mcp-servers', 'scripts',
];
const report = { totalMB: 0, breakdown: {} };
for (const dir of dirs) {
const dirPath = path.join(root, dir);
if (fs.existsSync(dirPath)) {
const sizeMB = getDirectorySizeMB(dirPath);
report.breakdown[dir] = sizeMB;
report.totalMB += sizeMB;
}
}
// 根目录文件
try {
const rootFiles = fs.readdirSync(root, { withFileTypes: true })
.filter(e => e.isFile());
let rootFileSizeMB = 0;
for (const f of rootFiles) {
rootFileSizeMB += fs.statSync(path.join(root, f.name)).size / (1024 * 1024);
}
report.breakdown['_root_files'] = rootFileSizeMB;
report.totalMB += rootFileSizeMB;
} catch { /* 忽略 */ }
// 健康度评分
if (report.totalMB > CONFIG.diskCriticalMB) {
report.health = 'CRITICAL';
report.score = 30;
} else if (report.totalMB > CONFIG.diskWarningMB) {
report.health = 'WARNING';
report.score = 60;
} else if (report.totalMB > 1024) {
report.health = 'GOOD';
report.score = 80;
} else {
report.health = 'EXCELLENT';
report.score = 95;
}
return report;
}
// ─── 主程序 ─────────────────────────────────────────────
function main(opts) {
// P1: 支持 opts 参数 (来自 stop-dispatcher) 和 process.argv (CLI 模式)
opts = opts || {};
const args = process.argv.slice(2);
const isExecute = opts.execute || args.includes('--execute');
const isReport = args.includes('--report');
const isDryRun = !isExecute;
// 节流:支持 opts.ifStale (dispatcher) 或 --if-stale <seconds> (CLI)
let staleSecs = null;
if (opts.ifStale) staleSecs = opts.ifStale;
else if (args.indexOf('--if-stale') !== -1) {
staleSecs = parseInt(args[args.indexOf('--if-stale') + 1], 10) || 86400;
}
if (staleSecs && isExecute) {
const stampFile = path.join(CONFIG.claudeRoot, 'debug', '.last-cleanup');
try {
const lastRun = fs.statSync(stampFile).mtimeMs;
if (Date.now() - lastRun < staleSecs * 1000) {
return; // 节流:未过期,静默跳过
}
} catch (_) { /* 文件不存在,继续执行 */ }
}
// 仅报告模式 (供 self-auditor 消费)
if (isReport) {
const diskReport = generateDiskReport();
const output = {
timestamp: new Date().toISOString(),
disk: diskReport,
thresholds: CONFIG.thresholds,
};
process.stdout.write(JSON.stringify(output, null, 2));
process.exit(0);
return;
}
const mode = isDryRun ? '预览' : '执行';
console.log(`\n${'═'.repeat(50)}`);
console.log(` Bookworm 自动清理 [${mode}模式]`);
console.log(` ${new Date().toISOString().slice(0, 19)}`);
console.log(`${'═'.repeat(50)}\n`);
// 清理前磁盘报告
const beforeDisk = generateDiskReport();
console.log(`清理前总大小: ${formatMB(beforeDisk.totalMB)} (${beforeDisk.health})\n`);
// 执行各项清理
const tasks = [
{ name: 'debug/ 调试日志', fn: cleanDebug, rule: `保留 ${CONFIG.thresholds.debug.maxAgeDays}` },
{ name: 'shell-snapshots/', fn: cleanShellSnapshots, rule: `保留 ${CONFIG.thresholds.shellSnapshots.maxAgeDays}` },
{ name: 'tool-results/ 缓存', fn: cleanToolResults, rule: `保留 ${CONFIG.thresholds.toolResults.maxAgeDays}` },
{ name: 'projects/ 会话日志', fn: cleanProjectSessions, rule: `活跃 ${CONFIG.thresholds.activeProjects.maxAgeDays}天 / 非活跃 ${CONFIG.thresholds.inactiveProjects.maxAgeDays}` },
{ name: 'backups/ 配置备份', fn: cleanBackups, rule: `保留最新 ${CONFIG.thresholds.backups.maxCount}` },
{ name: 'JSONL 指标轮转', fn: cleanJsonlMetrics, rule: 'route-metrics 5000行 / ab-experiments 2000行 / history 5000行 / evolution-log 2000行' },
];
let totalDeleted = 0;
let totalFreed = 0;
for (const task of tasks) {
const result = task.fn(isDryRun);
totalDeleted += result.deleted;
totalFreed += result.freedMB;
const status = result.deleted > 0 ? '清理' : '无需清理';
console.log(` [${status}] ${task.name}`);
console.log(` 规则: ${task.rule}`);
console.log(` ${isDryRun ? '将删除' : '已删除'}: ${result.deleted} 项 (${formatMB(result.freedMB)}), 保留: ${result.kept}`);
if (result.errors > 0) console.log(` 错误: ${result.errors}`);
console.log('');
}
// 清理后磁盘报告
const afterDisk = isExecute ? generateDiskReport() : beforeDisk;
console.log(`${'─'.repeat(50)}`);
console.log(` 总计 ${isDryRun ? '可清理' : '已清理'}: ${totalDeleted} 项, 释放 ${formatMB(totalFreed)}`);
if (isExecute) {
console.log(` 清理后总大小: ${formatMB(afterDisk.totalMB)} (${afterDisk.health})`);
// 写入时间戳供 --if-stale 节流使用
const stampFile = path.join(CONFIG.claudeRoot, 'debug', '.last-cleanup');
try { fs.writeFileSync(stampFile, new Date().toISOString()); } catch (_) {}
// T08: 写归档记录到 evolution-log (仅有实际清理时)
if (totalDeleted > 0) {
try {
const logFile = path.join(CONFIG.claudeRoot, 'evolution-log.jsonl');
const lines = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8').trim().split('\n') : [];
const lastSeq = lines.length > 0 ? (JSON.parse(lines[lines.length - 1]).seq || 0) : 0;
// 动态读取版本号,失败时回退到 'v6.0'
let currentVersion = 'v6.0';
try {
const statsFile = path.join(CONFIG.claudeRoot, 'stats-compiled.json');
const stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
if (stats.version) currentVersion = stats.version;
} catch { /* 读取失败使用默认值 v6.0 */ }
const entry = {
seq: lastSeq + 1,
ts: new Date().toISOString().slice(0, 10),
version: currentVersion,
scope: 'auto-cleanup',
summary: `自动清理: ${totalDeleted} 项, 释放 ${formatMB(totalFreed)}, 清理后 ${formatMB(afterDisk.totalMB)}`,
trigger: 'auto-cleanup --execute',
};
fs.appendFileSync(logFile, JSON.stringify(entry) + '\n');
} catch (_) {}
}
}
console.log(`${'═'.repeat(50)}\n`);
// 磁盘详细分布
const disk = isExecute ? afterDisk : beforeDisk;
console.log('磁盘分布:');
const sorted = Object.entries(disk.breakdown)
.sort((a, b) => b[1] - a[1]);
for (const [dir, sizeMB] of sorted) {
if (sizeMB < 0.01) continue;
const bar = '█'.repeat(Math.ceil(sizeMB / (disk.totalMB / 30)));
console.log(` ${(dir + '/').padEnd(20)} ${formatMB(sizeMB).padStart(10)} ${bar}`);
}
if (isDryRun && totalDeleted > 0) {
console.log(`\n提示: 以上为预览结果。运行 \`node auto-cleanup.js --execute\` 执行实际清理。`);
}
}
// ─── 模块导出 (供测试使用) ─────────────────────────────
if (typeof module !== 'undefined') {
module.exports = {
CONFIG,
getDaysSinceModified,
getDirectorySizeMB,
formatMB,
collectFiles,
safeDelete,
cleanDebug,
cleanShellSnapshots,
cleanToolResults,
cleanProjectSessions,
cleanBackups,
generateDiskReport,
main,
};
}
if (require.main === module) {
main();
}