Files
luckyyzh ccb038988e feat: 识图提速(模型常驻/精简模板/磁盘缓存)+ WebUI 上传压缩 + 缓存清理脚本
- vision.ts: keep_alive:-1 常驻显存(治冷加载 7.7GB);转录缓存持久化
  data/agent/vision-cache.json(重启后历史图秒回);hook 转录用精简模板
- ChatInput/image-attachments: 上传 JPEG 长边>1600px 自动压缩 q0.85
- scripts/cleanup-cache.mjs: npm/opengrep 缓存、过期会话、旧图瘦身清理
- README 双语同步
2026-08-03 06:33:43 +08:00

300 lines
11 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
/**
* pi-agent-integrated 缓存/会话清理脚本
*
* 背景:会话 jsonl 内嵌上传图片的 base64(单张相机照片可达 5.7MB),
* 每次 WebUI 加载会话要整体读出+序列化+前端解码渲染 → 加载变慢;
* opengrep/npm 缓存占磁盘数 GB。
*
* 用法:node scripts/cleanup-cache.mjs [--apply] [--target all|sessions|strip-images|npm|opengrep|artifacts] [--days N] [--keep N] [--purge] [--yes]
*
* --target all (默认) | sessions(移走旧会话) | strip-images(旧会话图片替换为占位,保留对话文本) |
* npm(清 cache/npm) | opengrep(清 AppData/Local/opengrep) | artifacts(清旧子代理产物)
* --days N 会话保留 N 天内的 (默认 7);配合 --target sessions/strip-images
* --keep N 额外保留最近 N 个会话文件 (默认 0)
* --apply 真正执行;缺省为 dry-run 只预览
* --purge 同时删除回收目录中 30 天前的备份(默认只清理,不删回收站)
* --yes 跳过确认(--apply 时)
*
* 安全边界:永不触碰 agent/npm(扩展安装)、.pi-lens/tools、.pi-lens/bin、memory(记忆仓库)。
* 所有删除 = 先移入回收目录 data/agent/tmp/session-trash/<时间戳>/,--purge 才清旧备份。
*/
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const DATA = path.join(ROOT, "data");
const SESSIONS_DIR = path.join(DATA, "agent", "sessions");
const TRASH_ROOT = path.join(DATA, "agent", "tmp", "session-trash");
const NPM_CACHE = path.join(DATA, "cache", "npm");
const OPENGREP = path.join(DATA, "home", "AppData", "Local", "opengrep");
const CUTOFF_DAYS = () => Date.now() - opt.days * 86400_000;
const args = process.argv.slice(2);
const opt = {
apply: args.includes("--apply"),
purge: args.includes("--purge"),
yes: args.includes("--yes"),
days: 7,
keep: 0,
target: "all",
};
for (let i = 0; i < args.length; i++) {
if (args[i] === "--days") opt.days = parseInt(args[++i], 10);
if (args[i] === "--keep") opt.keep = parseInt(args[++i], 10);
if (args[i] === "--target") opt.target = args[++i];
}
if (isNaN(opt.days) || opt.days < 0) opt.days = 7;
const TARGETS = ["all", "sessions", "strip-images", "npm", "opengrep", "artifacts"];
if (!TARGETS.includes(opt.target)) {
console.error(`未知 --target: ${opt.target}(可选 ${TARGETS.join(" / ")})`);
process.exit(1);
}
const want = (t) => opt.target === "all" || opt.target === t;
const mode = opt.apply ? "执行" : "预览(dry-run)";
console.log(`[cleanup-cache] ${mode} | target=${opt.target} | 会话保留 ${opt.days} 天${opt.keep ? ` + 最近 ${opt.keep} 个` : ""}\n`);
let freed = 0;
const actions = [];
const note = (s) => console.log(" " + s);
const planned = (file, size, why) => {
freed += size;
actions.push({ file, why });
note(`- ${why}: ${file} (${fmtSize(size)})`);
};
function fmtSize(n) {
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(2) + " GB";
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + " KB";
return n + " B";
}
function dirSize(p) {
if (!fs.existsSync(p)) return 0;
let total = 0;
const st = fs.statSync(p);
if (st.isFile()) return st.size;
for (const e of fs.readdirSync(p, { withFileTypes: true })) {
total += dirSize(path.join(p, e.name));
}
return total;
}
function moveToTrash(p) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(TRASH_ROOT, stamp, path.basename(p));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(p, dest);
return dest;
}
function countImages(file) {
let n = 0;
let bytes = 0;
try {
const lines = fs.readFileSync(file, "utf8").split("\n");
for (const line of lines) {
if (!line || !line.includes('"image"')) continue;
let e;
try { e = JSON.parse(line); } catch { continue; }
if (e?.type !== "message" || !Array.isArray(e.message?.content)) continue;
for (const b of e.message.content) {
if (b?.type !== "image") continue;
const data = typeof b.data === "string" ? b.data
: b.source?.type === "base64" ? b.source.data : null;
n++;
if (data) {
const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
bytes += Math.floor((data.length * 3) / 4) - pad;
}
}
}
} catch { /* skip */ }
return { n, bytes };
}
function stripImagesInPlace(file) {
const lines = fs.readFileSync(file, "utf8").split("\n");
let stripped = 0;
let bytes = 0;
const out = [];
for (const line of lines) {
if (!line || !line.includes('"image"')) { out.push(line); continue; }
try {
const e = JSON.parse(line);
if (e?.type === "message" && Array.isArray(e.message?.content)
&& (e.message.role === "user" || e.message.role === "toolResult")) {
const content = [];
for (const b of e.message.content) {
if (b?.type === "image") {
const data = typeof b.data === "string" ? b.data
: b.source?.type === "base64" ? b.source.data : null;
const pad = data?.endsWith("==") ? 2 : data?.endsWith("=") ? 1 : 0;
bytes += data ? Math.floor((data.length * 3) / 4) - pad : 0;
stripped++;
content.push({ type: "text", text: "[图片已由 cleanup-cache 替换为占位,原始数据已移入回收目录]" });
} else content.push(b);
}
e.message.content = content;
out.push(JSON.stringify(e));
continue;
}
} catch { /* keep as-is */ }
out.push(line);
}
if (stripped > 0) {
fs.writeFileSync(file, out.join("\n"));
}
return { stripped, bytes };
}
function listSessionFiles() {
const files = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === "subagent-artifacts") continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith(".jsonl")) files.push(p);
}
};
walk(SESSIONS_DIR);
return files;
}
function sessionInfo(file) {
const st = fs.statSync(file);
const { n, bytes } = countImages(file);
return { file, size: st.size, mtime: st.mtimeMs, images: n, imgBytes: bytes };
}
// ---------- 1. sessions / strip-images ----------
if (want("sessions") || want("strip-images")) {
console.log(`[会话] 目录 ${SESSIONS_DIR}`);
const infos = listSessionFiles().map(sessionInfo).sort((a, b) => b.mtime - a.mtime);
if (infos.length === 0) { console.log(" (无会话文件)"); }
const cutoff = CUTOFF_DAYS();
const keepIdx = Math.min(opt.keep, infos.length);
const doomed = infos.filter((s, i) => s.mtime < cutoff && i >= keepIdx);
if (want("sessions")) {
for (const s of doomed) {
planned(s.file, s.size, s.images > 0 ? `旧会话(含 ${s.images} 图)` : "旧会话");
}
if (doomed.length === 0) console.log(` (最近 ${opt.days} 天内无会话需要移走)`);
}
if (want("strip-images")) {
for (const s of doomed) {
if (s.images === 0) continue;
note(`- 图片瘦身: ${path.basename(s.file)} (${s.images} 图, ~${fmtSize(s.imgBytes)} 将替换为占位)`);
freed += s.imgBytes;
}
const recent = infos.filter((s, i) => !(s.mtime < cutoff && i >= keepIdx));
for (const s of recent) {
if (s.images === 0) continue;
note(`- 图片瘦身(最近窗口, 可选): ${path.basename(s.file)} (${s.images} 图, ~${fmtSize(s.imgBytes)})`);
}
}
console.log();
}
// ---------- 2. npm cache ----------
if (want("npm")) {
const sz = dirSize(NPM_CACHE);
if (sz > 0) planned(NPM_CACHE, sz, "npm 缓存(_cacache/_npx/_logs)");
else console.log("[npm] 缓存已空");
}
// ---------- 3. opengrep ----------
if (want("opengrep")) {
const sz = dirSize(OPENGREP);
if (sz > 0) planned(OPENGREP, sz, "opengrep 语义扫描缓存");
else console.log("[opengrep] 缓存已空");
}
// ---------- 4. artifacts ----------
if (want("artifacts")) {
const dirs = [];
const findArtifactDirs = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "subagent-artifacts") dirs.push(p);
else findArtifactDirs(p);
}
}
};
findArtifactDirs(SESSIONS_DIR);
let cnt = 0;
let sz = 0;
const files = [];
for (const d of dirs) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
const st = fs.statSync(p);
if (st.mtimeMs < CUTOFF_DAYS()) { cnt++; sz += st.size; files.push(p); }
}
}
if (cnt > 0) { note(`- ${cnt} 个旧子代理产物文件 (~${fmtSize(sz)})`); for (const f of files) planned(f, fs.statSync(f).size, "旧子代理产物"); }
else console.log("[artifacts] 无旧产物");
}
// ---------- 汇总 ----------
console.log(`\n合计可释放: ${fmtSize(freed)}`);
if (!opt.apply) {
console.log("\n以上为预览。加 --apply 真正执行(删除前先移入回收目录)。");
process.exit(0);
}
if (!opt.yes) {
console.log(`\n确认执行 ${actions.length} 项清理?输入 yes 继续:`);
const readline = (await import("node:readline")).default;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ans = await new Promise((r) => rl.question("> ", r));
rl.close();
if (ans.trim().toLowerCase() !== "yes") { console.log("已取消。"); process.exit(1); }
}
for (const a of actions) {
try {
if (a.file.includes("opengrep") || a.file === NPM_CACHE) {
fs.rmSync(a.file, { recursive: true, force: true });
console.log(`已删除 ${a.file}`);
} else {
const dest = moveToTrash(a.file);
console.log(`已移入回收目录 ${dest}`);
}
} catch (err) {
console.error(`失败: ${a.file}: ${err.message}`);
}
}
if (want("strip-images")) {
const infos = listSessionFiles().map(sessionInfo).sort((a, b) => b.mtime - a.mtime);
const keepIdx = Math.min(opt.keep, infos.length);
const cutoff = CUTOFF_DAYS();
for (const s of infos) {
if (s.images === 0) continue;
const i = infos.indexOf(s);
if (!(s.mtime < cutoff && i >= keepIdx)) continue;
const backup = moveToTrash(s.file);
const { stripped, bytes } = stripImagesInPlace(s.file);
console.log(`图片瘦身完成: ${path.basename(s.file)} (${stripped} 图, ~${fmtSize(bytes)}, 原备份 ${backup})`);
}
}
if (opt.purge) {
const purged = [];
const old = Date.now() - 30 * 86400_000;
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
const st = fs.statSync(p);
if (st.mtimeMs < old) { fs.rmSync(p, { recursive: true, force: true }); purged.push(p); }
else walk(p);
}
}
};
walk(TRASH_ROOT);
console.log(purged.length ? `回收目录已清旧备份: ${purged.join(", ")}` : "回收目录无 30 天前备份");
}
console.log(`\n完成。释放 ${fmtSize(freed)}。注意:当前正在使用的会话若被移走会丢失历史——建议先退出 pi 再执行。`);