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