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

664 lines
23 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 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();
}