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