mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
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 双语同步
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
#!/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 再执行。`);
|
||||
Reference in New Issue
Block a user