dsh-disk-manager 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/assets/screenshots/scan-result-2.png +0 -0
- package/assets/screenshots/scan-result.png +0 -0
- package/assets/screenshots/settings.png +0 -0
- package/client.js +244 -0
- package/config.example.json +7 -0
- package/config.mjs +97 -0
- package/cordis.patch.yml +5 -0
- package/core/active.mjs +70 -0
- package/core/classify.mjs +127 -0
- package/core/executor.mjs +110 -0
- package/core/riskmap.mjs +310 -0
- package/core/safety.mjs +119 -0
- package/core/scanner.mjs +140 -0
- package/index.mjs +287 -0
- package/package.json +73 -0
- package/screenshots.json +5 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { classify as ruleClassify, isCategory, CATEGORY_META } from "./riskmap.mjs";
|
|
2
|
+
import { loadClassifyCache, saveClassifyCache } from "../config.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* classify.mjs —— 分类引擎,三级:
|
|
6
|
+
* 1) 静态规则库 riskmap(确定,零成本)
|
|
7
|
+
* 2) 本地分类缓存 classify-cache(用户之前确认过的,跳过 LLM)
|
|
8
|
+
* 3) LLM 兜底:特征包 -> LLM 判断类别 -> 人工确认 -> 回写缓存
|
|
9
|
+
*
|
|
10
|
+
* 红线(E)强规则:任何 config/storage/memory/database/.openviking/.qclaw 特征,
|
|
11
|
+
* 即使 LLM 判错,也不自动执行(由 safety.mjs 最终兜底)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 把目录特征包组装成给 LLM 的提示文本 */
|
|
15
|
+
function buildLlmPrompt(feature) {
|
|
16
|
+
const subs = (feature.subdirs || []).join("、") || "(空)";
|
|
17
|
+
return `你是一个 Windows 磁盘清理参谋。下面是一个占用 ${feature.sizeMB ?? "?"} MB 的目录,请判断它属于哪一类。
|
|
18
|
+
|
|
19
|
+
目录名: ${feature.name}
|
|
20
|
+
完整路径: ${feature.path}
|
|
21
|
+
一级子目录: ${subs}
|
|
22
|
+
|
|
23
|
+
类别定义:
|
|
24
|
+
A 无忧缓存 —— Cache/GPUCache/Code Cache/Temp/log/updater 等,可再生,可删或 junction 搬
|
|
25
|
+
B 官方可改址 —— 软件自己支持改存储位置(Docker/npm/pnpm/浏览器profile等)
|
|
26
|
+
C 可junction搬 —— 软件不支持改址但数据纯可再生(视频剪辑/聊天/Notion/游戏客户端等)
|
|
27
|
+
D 冷废弃软件 —— 很久没用的大体积软件
|
|
28
|
+
E 配置/记忆(红线) —— Config/Storage/IndexedDB/MEMORY/数据库/.openviking/.qclaw 等,绝不能动
|
|
29
|
+
|
|
30
|
+
只返回一个 JSON,不要其他文字:
|
|
31
|
+
{"category":"A|B|C|D|E","confidence":"high|medium|low","reason":"一句话依据","recommendedAction":"建议动作"}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* LLM 兜底判断。通过 ctx.llm? 调用 DSH 自带通道。
|
|
36
|
+
* 若 DSH 通道不可用,返回 { category:null, needUser:true },让前端请求人工确认。
|
|
37
|
+
*/
|
|
38
|
+
async function llmClassify(ctx, feature) {
|
|
39
|
+
try {
|
|
40
|
+
const prompt = buildLlmPrompt(feature);
|
|
41
|
+
if (!ctx.llm || typeof ctx.llm.call !== "function") {
|
|
42
|
+
throw new Error("DSH LLM 通道不可用");
|
|
43
|
+
}
|
|
44
|
+
const resp = await ctx.llm.call(prompt);
|
|
45
|
+
const text = typeof resp === "string" ? resp : resp?.content ?? JSON.stringify(resp);
|
|
46
|
+
const match = text.match(/\{[\s\S]*\}/);
|
|
47
|
+
if (!match) throw new Error("LLM 返回无 JSON");
|
|
48
|
+
const parsed = JSON.parse(match[0]);
|
|
49
|
+
const cat = String(parsed.category || "").trim().toUpperCase();
|
|
50
|
+
if (!isCategory(cat)) throw new Error(`LLM 返回非法类别: ${cat}`);
|
|
51
|
+
return {
|
|
52
|
+
category: cat,
|
|
53
|
+
confidence: parsed.confidence || "medium",
|
|
54
|
+
reason: parsed.reason || "LLM 判断",
|
|
55
|
+
recommendedAction: parsed.recommendedAction || "",
|
|
56
|
+
source: "llm",
|
|
57
|
+
};
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { category: null, confidence: "low", reason: `LLM 失败: ${e.message}`, source: "llm-failed", needUser: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 主分类入口。
|
|
65
|
+
* @param {object} entry 扫描条目 { name, path, sizeMB, subdirs, lastAccess }
|
|
66
|
+
* @param {object} opts { config, ctx, targetDrive }
|
|
67
|
+
* @returns {object} 最终分类,含 category/source/confidence/reason/needUser
|
|
68
|
+
*/
|
|
69
|
+
export async function classifyEntry(entry, opts) {
|
|
70
|
+
const { config, ctx } = opts;
|
|
71
|
+
const cache = loadClassifyCache();
|
|
72
|
+
|
|
73
|
+
// 1) 静态规则库
|
|
74
|
+
const rule = ruleClassify(entry);
|
|
75
|
+
if (rule.category) {
|
|
76
|
+
// 规则判为 E 或其他,且用户曾确认过则直接采纳 confirm;否则用规则
|
|
77
|
+
if (rule.confidence === "high") {
|
|
78
|
+
return { ...rule, source: "rule" };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2) 本地分类缓存(基于目录名特征签名)
|
|
83
|
+
const sig = featureSignature(entry);
|
|
84
|
+
const cached = cache[entry.name] || cache[sig];
|
|
85
|
+
if (cached && isCategory(cached.category)) {
|
|
86
|
+
return { category: cached.category, reason: cached.reason || "本地缓存", confidence: cached.confidence || "medium", source: "cache" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3) LLM 兜底
|
|
90
|
+
const feature = { name: entry.name, path: entry.path, sizeMB: entry.sizeMB, subdirs: entry.subdirs };
|
|
91
|
+
const llm = await llmClassify(ctx, feature);
|
|
92
|
+
if (llm.category) {
|
|
93
|
+
// 强红线:LLM 判错但特征命中红线 -> 回退 E
|
|
94
|
+
if (looksLikeRedline(feature)) {
|
|
95
|
+
return { category: "E", reason: "特征命中红线,LLM 判断被回退为 E", confidence: "high", source: "redline-override", llm: llm.category };
|
|
96
|
+
}
|
|
97
|
+
return { ...llm, needUser: true, source: "llm" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// LLM 失败 -> 需要人工
|
|
101
|
+
return { category: null, confidence: "low", reason: llm.reason || "无法自动分类,请人工指定", source: "unclassified", needUser: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 强红线:特征命中 config/storage/memory/database/.点目录 等 */
|
|
105
|
+
function looksLikeRedline(feature) {
|
|
106
|
+
const n = feature.name.toLowerCase();
|
|
107
|
+
if (n.startsWith(".")) return true; // .openviking/.qclaw/.config 等点目录一律红线
|
|
108
|
+
return /memory|sqlite|database|indexeddb|\.openviking|\.qclaw|storage/.test(n);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 目录分类签名:名称 + 是否有 cache/config/storage 大字样 */
|
|
112
|
+
function featureSignature(entry) {
|
|
113
|
+
const n = entry.name.toLowerCase();
|
|
114
|
+
return `${n}|${/cache|temp|log|updater/i.test(n) ? "c" : "-"}|${/config|setting|prefer/i.test(n) ? "s" : "-"}|${/storage|indexeddb|memory|database/.test(n) ? "d" : "-"}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 用户确认后回写本地缓存 */
|
|
118
|
+
export function recordUserDecision(entry, category, reason = "") {
|
|
119
|
+
if (!isCategory(category)) return;
|
|
120
|
+
const cache = loadClassifyCache();
|
|
121
|
+
const sig = featureSignature(entry);
|
|
122
|
+
cache[entry.name] = { category, reason, confidence: "high", confirmedAt: new Date().toISOString() };
|
|
123
|
+
cache[sig] = { category, reason, confidence: "high", confirmedAt: new Date().toISOString() };
|
|
124
|
+
saveClassifyCache(cache);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { CATEGORY_META };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, renameSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* executor.mjs —— 执行引擎。
|
|
7
|
+
* 动作:
|
|
8
|
+
* delete 删缓存(仅 A)
|
|
9
|
+
* junction 把目录物理搬到目标盘,C 盘原位建 junction(仅 A/C)
|
|
10
|
+
* redirect 引导用户走官方设置改址(B),或直接设环境变量
|
|
11
|
+
*
|
|
12
|
+
* 所有操作都用 -LiteralPath / 关闭软件前提;每步返回 before/after 供 undo。
|
|
13
|
+
* 注意:junction 需要目标盘的父目录存在、且源目录可用(软件已关闭)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function ps(Script) {
|
|
17
|
+
return execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", Script], {
|
|
18
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
19
|
+
timeout: 600000,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
}).toString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function dirSizeMB(p) {
|
|
25
|
+
try {
|
|
26
|
+
const s = ps(`$ErrorActionPreference='SilentlyContinue'; [math]::Round(((Get-ChildItem -LiteralPath '${p.replace(/'/g, "''")}' -Recurse -Force -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum)/1MB,1)`);
|
|
27
|
+
const n = Number(s.trim());
|
|
28
|
+
return isNaN(n) ? null : n;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 删除类操作:仅信任调用方已将 category 过滤为 A */
|
|
35
|
+
async function deleteDir(action, config) {
|
|
36
|
+
const p = action.path;
|
|
37
|
+
if (!p || !existsSync(p)) return { ok: false, message: "路径不存在" };
|
|
38
|
+
const before = dirSizeMB(p);
|
|
39
|
+
try {
|
|
40
|
+
rmSync(p, { recursive: true, force: true });
|
|
41
|
+
return { ok: true, message: `已删除 ${p}`, before, after: 0 };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { ok: false, message: `删除失败(可能被占用): ${e.message}`, before };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* junction 搬家:物理 Move-Item 到 target,再在源位置建 junction。
|
|
49
|
+
* target 由调用方按 "D:\\AppCache\\<软件>" 拼好传入(action.target)。
|
|
50
|
+
*/
|
|
51
|
+
async function junctionMove(action, config) {
|
|
52
|
+
const src = action.path;
|
|
53
|
+
const dst = action.target; // 完整目标,如 D:\AppCache\Notion\Partitions
|
|
54
|
+
if (!src || !dst || !existsSync(src)) return { ok: false, message: "源路径不存在或未给目标" };
|
|
55
|
+
if (!/^[A-Za-z]:\\/.test(dst)) return { ok: false, message: "目标盘格式无效" };
|
|
56
|
+
const before = dirSizeMB(src);
|
|
57
|
+
try {
|
|
58
|
+
const script = `
|
|
59
|
+
$ErrorActionPreference='Stop'
|
|
60
|
+
$src='${src.replace(/'/g, "''")}'
|
|
61
|
+
$dst='${dst.replace(/'/g, "''")}'
|
|
62
|
+
New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null
|
|
63
|
+
if (Test-Path -LiteralPath $dst) { Remove-Item -LiteralPath $dst -Recurse -Force }
|
|
64
|
+
Move-Item -LiteralPath $src -Destination $dst -Force
|
|
65
|
+
New-Item -ItemType Junction -Path $src -Target $dst -Force | Out-Null
|
|
66
|
+
(Get-Item -LiteralPath $src -Force).LinkType
|
|
67
|
+
`;
|
|
68
|
+
const link = ps(script).trim();
|
|
69
|
+
if (link !== "Junction") {
|
|
70
|
+
return { ok: false, message: `junction 建立失败(link=${link})`, before };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, message: `已搬到 ${dst} 并建立 junction`, before, after: 0, target: dst };
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { ok: false, message: `junction 搬移失败: ${e.message}`, before };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** B 类:引导改址。这里不直接改软件,返回指引文案;同时可对支持环境变量的设好变量 */
|
|
79
|
+
async function redirectGuide(action, config) {
|
|
80
|
+
const name = action.name || action.path;
|
|
81
|
+
const drive = config.targetDrive || "D";
|
|
82
|
+
let hint = `请打开 ${name} 的设置,把数据/缓存位置改到 ${drive}: 盘,然后重启该软件。`;
|
|
83
|
+
if (/npm|node/i.test(name)) hint = `在终端执行: npm config set cache "${drive}:\\npm-cache"`;
|
|
84
|
+
else if (/pnpm/i.test(name)) hint = `在终端执行: pnpm config set store-dir "${drive}:\\pnpm-store"`;
|
|
85
|
+
else if (/pip/i.test(name)) hint = `设置环境变量 PIP_CACHE_DIR="${drive}:\\pip-cache",或 pip config set global.cache-dir`;
|
|
86
|
+
else if (/docker|wsl/i.test(name)) hint = `Docker Desktop → Settings → Resources → Advanced → Disk image location 改为 ${drive}:\\docker\\DockerDesktopWSL,Apply & Restart。`;
|
|
87
|
+
return { ok: true, message: `引导改址(未自动改)`, hint, before: dirSizeMB(action.path), after: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 冷废弃软件(D):只提示,不执行任何删除/搬迁 */
|
|
91
|
+
async function abandonHint(action) {
|
|
92
|
+
return { ok: true, message: `提示:你可能很久没用了。要不要卸载?`, hint: action.path };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const BASE = "D:\\AppCache";
|
|
96
|
+
|
|
97
|
+
/** 对一条 action 分派到具体执行器 */
|
|
98
|
+
export async function dispatch(action, config) {
|
|
99
|
+
const map = {
|
|
100
|
+
delete: deleteDir,
|
|
101
|
+
junction: junctionMove,
|
|
102
|
+
redirect: redirectGuide,
|
|
103
|
+
abandon: abandonHint,
|
|
104
|
+
};
|
|
105
|
+
const fn = map[action.action];
|
|
106
|
+
if (!fn) return { ok: false, message: `未知动作 ${action.action}` };
|
|
107
|
+
return fn(action, config);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { dirSizeMB, BASE };
|
package/core/riskmap.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* riskmap.mjs —— 静态规则库:把目录名/特征映射到 A/B/C/D/E 五类。
|
|
3
|
+
*
|
|
4
|
+
* 五类定义:
|
|
5
|
+
* A 无忧缓存 —— Cache/GPUCache/Code Cache/CachedData/Temp/log/*.updater,可再生,可删或junction搬
|
|
6
|
+
* B 官方可改址 —— 软件自己支持改存储位置(Docker/npm/pnpm/pip/浏览器profile/下载目录),走官方设置
|
|
7
|
+
* C 可junction —— 软件不支持改址但数据纯可再生(剪映Cache/Notion离线库/Trae/Cursor缓存),junction搬
|
|
8
|
+
* D 冷废弃软件 —— 大体积 + 超过 abandonDays 未使用,提示"你很久没用它了",只提醒不自动删
|
|
9
|
+
* E 配置/记忆 —— Config/Storage/IndexedDB/MEMORY/数据库/.openviking/.qclaw,红线,只展示不执行
|
|
10
|
+
*
|
|
11
|
+
* 分类引擎(safety.mjs/executor.mjs 依赖)优先级:
|
|
12
|
+
* 1) 红线特征(E)优先 —— 命中即判 E,即使 LLM 判错也不自动执行
|
|
13
|
+
* 2) 用户黑名单 -> E
|
|
14
|
+
* 3) 静态规则库
|
|
15
|
+
* 4) 本地分类缓存 classify-cache
|
|
16
|
+
* 5) LLM 兜底 + 人工确认
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 判断 feature 提取后是否命中"无法访问/受保护",这类直接跳过 */
|
|
20
|
+
const PROTECTED = ["AccessDenied", "NotFound"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 程序本体/系统目录:这些是软件主程序或系统组件,属于"保护",一律 E(只展示)。
|
|
24
|
+
* 不删不搬。即使叫 cache 之类也不动 —— 优先级高于一切。
|
|
25
|
+
*/
|
|
26
|
+
const RULE_PROTECTED = [
|
|
27
|
+
"programs",
|
|
28
|
+
"program files",
|
|
29
|
+
"package cache",
|
|
30
|
+
"packages",
|
|
31
|
+
"microsoft",
|
|
32
|
+
"windows",
|
|
33
|
+
"programdata",
|
|
34
|
+
"system32",
|
|
35
|
+
"brother", // 打印机驱动
|
|
36
|
+
"intel corporation",
|
|
37
|
+
"nvidia corporation",
|
|
38
|
+
"comms",
|
|
39
|
+
"connecteddevicesplatform",
|
|
40
|
+
"elevateddiagnostics",
|
|
41
|
+
"peerdistrepub",
|
|
42
|
+
"engine",
|
|
43
|
+
"unrealengine",
|
|
44
|
+
"godot",
|
|
45
|
+
"steam", // Steam 客户端本体+游戏数据,保护
|
|
46
|
+
"battle.net", // 暴雪客户端本体
|
|
47
|
+
"blizzard entertainment",
|
|
48
|
+
"neteaseui",
|
|
49
|
+
"unicom",
|
|
50
|
+
"tssgame",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** 目录名小写后 -> 类别 的精确规则 */
|
|
54
|
+
const RULE_EXACT = {
|
|
55
|
+
"programs": "E",
|
|
56
|
+
"packages": "E",
|
|
57
|
+
"microsoft": "E",
|
|
58
|
+
"windows": "E",
|
|
59
|
+
// ---- A 无忧缓存 ----
|
|
60
|
+
"cache": "A",
|
|
61
|
+
"gpu cache": "A", // Common: "GPUCache"
|
|
62
|
+
"code cache": "A",
|
|
63
|
+
"cacheddata": "A",
|
|
64
|
+
"cachedprofilesdata": "A",
|
|
65
|
+
"cachedextensionvsixs": "A",
|
|
66
|
+
"blob_storage": "A",
|
|
67
|
+
"session storage": "A",
|
|
68
|
+
"local storage": "A",
|
|
69
|
+
"shared dictionary": "A",
|
|
70
|
+
"dictionaries": "A",
|
|
71
|
+
"webstorage": "A",
|
|
72
|
+
"crashpad": "A",
|
|
73
|
+
"crashdumps": "A",
|
|
74
|
+
"d3dscache": "A",
|
|
75
|
+
"log": "A",
|
|
76
|
+
"logs": "A",
|
|
77
|
+
"network": "A",
|
|
78
|
+
"dawnwebgpucache": "A",
|
|
79
|
+
"dawngraphitecache": "A",
|
|
80
|
+
"videdecodestats": "A",
|
|
81
|
+
"temp": "A",
|
|
82
|
+
"cache_data": "A",
|
|
83
|
+
"gpucache": "A",
|
|
84
|
+
"updates": "A",
|
|
85
|
+
"patch": "A",
|
|
86
|
+
"update": "A", // 更新残留(非软件本体)
|
|
87
|
+
"nointerrupt": "A",
|
|
88
|
+
"logs_bytype": "A",
|
|
89
|
+
"cache_media": "A",
|
|
90
|
+
|
|
91
|
+
// ---- E 配置/记忆(红线) ----
|
|
92
|
+
"preferences": "E",
|
|
93
|
+
"prefs": "E",
|
|
94
|
+
"settings": "E",
|
|
95
|
+
"config": "E",
|
|
96
|
+
"configure": "E",
|
|
97
|
+
"indexeddb": "E",
|
|
98
|
+
"storage": "E",
|
|
99
|
+
"profile": "E",
|
|
100
|
+
"profiles": "E",
|
|
101
|
+
"user data": "E",
|
|
102
|
+
"default": "E", // Chrome 默认 profile 下,默认 E,但其中 Cache 子目录再被规则引擎细分
|
|
103
|
+
"mem": "E",
|
|
104
|
+
"memory": "E",
|
|
105
|
+
"databases": "E",
|
|
106
|
+
"database": "E",
|
|
107
|
+
"sqlite": "E",
|
|
108
|
+
"keystore": "E",
|
|
109
|
+
"cookies": "E",
|
|
110
|
+
"login data": "E",
|
|
111
|
+
"bookmarks": "E",
|
|
112
|
+
"history": "E",
|
|
113
|
+
"top sites": "E",
|
|
114
|
+
"extensions": "E",
|
|
115
|
+
"session": "E",
|
|
116
|
+
"sessions": "E",
|
|
117
|
+
"localdata": "E",
|
|
118
|
+
"local state": "E",
|
|
119
|
+
"rust_data": "E",
|
|
120
|
+
".qclaw": "E",
|
|
121
|
+
".openviking": "E",
|
|
122
|
+
".dsh": "E",
|
|
123
|
+
".agents": "E",
|
|
124
|
+
".config": "E",
|
|
125
|
+
".local": "E",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 目录名子串规则(包含即命中)。注意顺序:越具体越靠前。
|
|
130
|
+
* 键->值:[类别, 说明]
|
|
131
|
+
*/
|
|
132
|
+
const RULE_CONTAINS = [
|
|
133
|
+
// updater 家族 -> A(更新残留,可再生;注意与软件本体区分,通常目录本身即更新包)
|
|
134
|
+
["-updater", "A", "更新器下载的安装包残留,可删"],
|
|
135
|
+
["_updater", "A", "更新器残留,可删"],
|
|
136
|
+
["electron-updater", "A", "更新器残留,可删"],
|
|
137
|
+
["desktop-updater", "A", "更新器残留,可删"],
|
|
138
|
+
|
|
139
|
+
// 缓存家族 -> A
|
|
140
|
+
["cache", "A", "缓存目录,可再生"],
|
|
141
|
+
["gpucache", "A", "GPU 缓存"],
|
|
142
|
+
["codecache", "A", "代码缓存"],
|
|
143
|
+
["cacheddata", "A", "缓存数据"],
|
|
144
|
+
["cachedprofiles", "A", "缓存 profile"],
|
|
145
|
+
|
|
146
|
+
// npm/pnpm/pip/uv 包缓存 -> B(可用官方设置改址)
|
|
147
|
+
["npm-cache", "B", "npm 缓存,可 npm config set cache 改址"],
|
|
148
|
+
["npm_cache", "B", "npm 缓存,可改址"],
|
|
149
|
+
["pnpm-cache", "B", "pnpm 缓存,可改址"],
|
|
150
|
+
["pnpm-store", "B", "pnpm store,可改址"],
|
|
151
|
+
["pnpm-store", "B", "pnpm store"],
|
|
152
|
+
["uv", "B", "uv 包缓存"],
|
|
153
|
+
["pip", "B", "pip 缓存"],
|
|
154
|
+
["node-gyp", "B", "node-gyp 缓存"], // 偏可改址,但保底也算可再生
|
|
155
|
+
["_npx", "B", "npx 缓存,可改址"],
|
|
156
|
+
["_cacache", "B", "npm 内部缓存"],
|
|
157
|
+
["ms-playwright", "B", "Playwright 浏览器,可设 PLAYWRIGHT_BROWSERS_PATH"],
|
|
158
|
+
|
|
159
|
+
// 官方可改址的大户软件 -> B(它们有专属设置)
|
|
160
|
+
["docker", "B", "Docker,可在设置改 Disk image location"],
|
|
161
|
+
["wsl", "B", "WSL 发行版,可用 wsl --import 迁盘"],
|
|
162
|
+
|
|
163
|
+
// 浏览器 profile/data —— 主体 E(有书签/登录),但其 Cache 子目录规则引擎会再判 A
|
|
164
|
+
["user data", "E", "用户数据,含配置/登录,勿动非缓存部分"],
|
|
165
|
+
["profile", "E", "Profile 数据"],
|
|
166
|
+
["storage", "E", "存储数据"],
|
|
167
|
+
["indexeddb", "E", "IndexedDB,含应用数据"],
|
|
168
|
+
["memory", "E", "记忆库"],
|
|
169
|
+
["sqlite", "E", "SQLite 数据库"],
|
|
170
|
+
["localstorage", "E", "本地存储"],
|
|
171
|
+
["local storage", "E", "本地存储"],
|
|
172
|
+
["config", "E", "配置"],
|
|
173
|
+
["settings", "E", "设置"],
|
|
174
|
+
["preferences", "E", "偏好设置"],
|
|
175
|
+
|
|
176
|
+
// 游戏/语音类大客户端的缓存 -> A/C(可再生),如 YY/duowan
|
|
177
|
+
["duowan", "C", "多玩/YY 客户端数据,可junction搬"],
|
|
178
|
+
["yy", "C", "YY 客户端数据,可junction搬"],
|
|
179
|
+
["heybox", "C", "游戏盒缓存,可junction搬"],
|
|
180
|
+
["tencent", "C", "腾讯系客户端缓存,可junction搬(仅缓存部分)"],
|
|
181
|
+
["xwechat", "C", "微信缓存,可junction搬(仅缓存部分)"],
|
|
182
|
+
["wechat", "C", "微信"],
|
|
183
|
+
["qqmusic", "C", "QQ音乐缓存"],
|
|
184
|
+
["battle.net", "C", "暴雪客户端"],
|
|
185
|
+
["steam", "C", "Steam 数据"],
|
|
186
|
+
["doubao", "C", "豆包缓存"],
|
|
187
|
+
["notion", "C", "Notion 离线库,可junction搬"],
|
|
188
|
+
["trae", "C", "Trae 缓存"],
|
|
189
|
+
["cursor", "C", "Cursor 缓存"],
|
|
190
|
+
["bigfoot", "C", "bigfoot 缓存"],
|
|
191
|
+
["douyin", "C", "抖音缓存"],
|
|
192
|
+
["xmind", "C", "Xmind 数据"],
|
|
193
|
+
["chatglm", "C", "ChatGLM"],
|
|
194
|
+
["dingtalk", "C", "钉钉缓存"],
|
|
195
|
+
["obsidian", "C", "Obsidian 数据"],
|
|
196
|
+
["typora", "C", "Typora"],
|
|
197
|
+
|
|
198
|
+
// 剪映/视频剪辑 -> C
|
|
199
|
+
["jianyingpro", "C", "剪映缓存,可junction搬"],
|
|
200
|
+
["剪映", "C", "剪映"],
|
|
201
|
+
["wangxin", "C", "剪映"],
|
|
202
|
+
["capcut", "C", "剪映国际版"],
|
|
203
|
+
|
|
204
|
+
// Android/Arduino 等开发者工具 SDK(大,可再生但本机要用则保留,倾向 C 提示) —— 这类更适合"C 可junction/保留"
|
|
205
|
+
["android", "C", "Android SDK,可junction搬或仅提示"],
|
|
206
|
+
["arduino", "C", "Arduino 数据"],
|
|
207
|
+
|
|
208
|
+
// 数据库服务 -> B/E,倾向 E(服务数据别乱动)
|
|
209
|
+
["mongo", "E", "MongoDB 数据"],
|
|
210
|
+
["redis", "E", "Redis 数据"],
|
|
211
|
+
["postgres", "E", "PostgreSQL 数据"],
|
|
212
|
+
["mysql", "E", "MySQL 数据"],
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
/** 从目录名提取规范键(小写、去空格) */
|
|
216
|
+
function normKey(name) {
|
|
217
|
+
return name.toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 提取一个目录的特征包,供 classify/LLM 使用。
|
|
222
|
+
* 返回 { name, path, sizeMB, subdirs:[...], hasCache, hasConfig, hasStorage, hasDatabase }
|
|
223
|
+
*/
|
|
224
|
+
function extractFeature(entry) {
|
|
225
|
+
const lower = entry.name.toLowerCase();
|
|
226
|
+
return {
|
|
227
|
+
name: entry.name,
|
|
228
|
+
path: entry.path,
|
|
229
|
+
sizeMB: entry.sizeMB,
|
|
230
|
+
subdirs: entry.subdirs || [],
|
|
231
|
+
hasCache: /cache|gpucache|codecache|temp|log|updater/i.test(lower),
|
|
232
|
+
hasConfig: /config|setting|prefer|profile/i.test(lower),
|
|
233
|
+
hasStorage: /storage|indexeddb|localstorage|sqlite|database|memory/i.test(lower),
|
|
234
|
+
hasData: /user ?data|workspace|session|vault/i.test(lower),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 主分类函数:给定条目特征返回 { category, reason, confidence }。
|
|
240
|
+
* confidence: "high"(规则库/红线) | "medium" | "low"(需 LLM+人工)
|
|
241
|
+
*/
|
|
242
|
+
export function classify(entry) {
|
|
243
|
+
const f = extractFeature(entry);
|
|
244
|
+
const key = normKey(f.name);
|
|
245
|
+
const zone = entry.zone || "appdata";
|
|
246
|
+
|
|
247
|
+
// 0) 程序本体/系统目录 —— 保护,优先级最高,一律 E
|
|
248
|
+
if (RULE_PROTECTED.some((p) => key.includes(p))) {
|
|
249
|
+
return { category: "E", reason: "程序本体/系统目录,保护,只展示", confidence: "high" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 0.1) 用户 profile 个人文件区(Downloads/Documents/Desktop 等) —— 绝不自动删,只提醒
|
|
253
|
+
if (zone === "user") {
|
|
254
|
+
// 点目录(.xxx)、配置/存储/记忆特征一律优先红线 E(而非 P),因为这些常是工具配置/记忆库
|
|
255
|
+
if (f.name.toLowerCase().startsWith(".") || f.hasStorage || f.hasConfig || /memory|sqlite|database|indexeddb|config|storage/.test(key)) {
|
|
256
|
+
return { category: "E", reason: "用户区里的点目录/配置/记忆,红线保护", confidence: "high" };
|
|
257
|
+
}
|
|
258
|
+
// 排除已知纯缓存子目录(仍可删/可搬)
|
|
259
|
+
if (f.hasCache && !/download|document|desktop|picture|video|music/.test(key)) {
|
|
260
|
+
return { category: "C", reason: "用户区里的缓存子目录,可junction搬", confidence: "high" };
|
|
261
|
+
}
|
|
262
|
+
return { category: "P", reason: "个人文件(用户自己的数据),只提醒大文件,绝不自动删", confidence: "high" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 0.2) Program Files 里的软件 —— 程序本体判 E 保护,但 >180 天未用会再提示冷废弃(类 D)
|
|
266
|
+
if (zone === "program") {
|
|
267
|
+
// 更新残留/缓存子目录仍可处理
|
|
268
|
+
if (f.hasCache || /-updater|_updater|electron-updater/.test(key)) {
|
|
269
|
+
return { category: "A", reason: "软件更新残留/缓存,可删", confidence: "high" };
|
|
270
|
+
}
|
|
271
|
+
return { category: "E", reason: "已安装软件,保护不自动删", confidence: "high" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 1) 红线优先:E —— 任何 config/storage/memory/database/.openviking/.qclaw 特征都判 E
|
|
275
|
+
if (f.hasStorage || f.name.toLowerCase().startsWith(".") || /memory|sqlite|database|indexeddb/.test(f.name.toLowerCase())) {
|
|
276
|
+
// 注意:若子目录纯是 cache,仍可细分。这里对顶层这个"父目录"给 E 是保守策略。
|
|
277
|
+
return { category: "E", reason: "命中红线特征(存储/记忆/数据库/点目录)", confidence: "high" };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2) 精确规则
|
|
281
|
+
if (RULE_EXACT[key]) {
|
|
282
|
+
const cat = RULE_EXACT[key];
|
|
283
|
+
return { category: cat, reason: "精确规则命中", confidence: "high" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 3) 包含规则(先过 A 家族,再 E 家族等)
|
|
287
|
+
for (const [sub, cat, note] of RULE_CONTAINS) {
|
|
288
|
+
if (key.includes(sub)) {
|
|
289
|
+
return { category: cat, reason: note, confidence: "high" };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 4) 未命中 -> 触发 LLM 兜底(由 classify.mjs 处理),这里返回 low 让上层决定
|
|
294
|
+
return { category: null, reason: "未命中规则库,需 LLM 判断", confidence: "low" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function isCategory(cat) {
|
|
298
|
+
return ["A", "B", "C", "D", "E", "P"].includes(cat);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const CATEGORY_META = {
|
|
302
|
+
A: { label: "无忧缓存", color: "green", action: "删 或 junction 搬", auto: true },
|
|
303
|
+
B: { label: "官方可改址", color: "blue", action: "改软件设置到其他盘", auto: false },
|
|
304
|
+
C: { label: "可junction搬", color: "cyan", action: "junction 搬到其他盘", auto: true },
|
|
305
|
+
D: { label: "冷废弃软件", color: "orange", action: "提示你可能没用它了", auto: false },
|
|
306
|
+
E: { label: "配置/记忆·红线", color: "red", action: "只展示,不执行", auto: false },
|
|
307
|
+
P: { label: "个人文件", color: "purple", action: "只提醒大文件,可手动搬,绝不自动删", auto: false },
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export { PROTECTED, extractFeature };
|
package/core/safety.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { configDir } from "../config.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* safety.mjs —— 强红线 + dry-run 预览 + undo 日志。
|
|
8
|
+
* 负责: 1) 红线判定(E 类永不执行) 2) E 类降级需二次确认
|
|
9
|
+
* 3) 执行前 dry-run 生成"将做什么" 4) 写 undo 日志供回滚
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const REDLINE_CATEGORY = "E";
|
|
13
|
+
|
|
14
|
+
/** 单条待执行操作的 undo 记录结构 */
|
|
15
|
+
function undoPath() {
|
|
16
|
+
return join(configDir(), "undo.log.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readUndo() {
|
|
20
|
+
try {
|
|
21
|
+
if (existsSync(undoPath())) return JSON.parse(readFileSync(undoPath(), "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
/* ignore */
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function appendUndo(entry) {
|
|
29
|
+
const arr = readUndo();
|
|
30
|
+
arr.push({ ...entry, ts: new Date().toISOString() });
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(configDir(), { recursive: true });
|
|
33
|
+
writeFileSync(undoPath(), JSON.stringify(arr, null, 2), "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
/* ignore */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 校验一个条目能否执行。
|
|
41
|
+
* @returns { ok:boolean, reason?:string, downgradeRequired?:boolean }
|
|
42
|
+
*/
|
|
43
|
+
export function canExecute(item, config) {
|
|
44
|
+
const cat = item.category;
|
|
45
|
+
|
|
46
|
+
// 用户黑名单/配置黑名单 -> 禁止
|
|
47
|
+
if (item.path && config.blacklist?.some((b) => item.path.includes(b))) {
|
|
48
|
+
return { ok: false, reason: "命中用户黑名单" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// E 红线:默认禁止
|
|
52
|
+
if (cat === REDLINE_CATEGORY) {
|
|
53
|
+
return { ok: false, reason: "配置/记忆红线(E),默认不执行", downgradeRequired: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// P 个人文件:用户自己的数据,绝不自动删;只允许手动 junction 搬/提醒,删除一律禁止
|
|
57
|
+
if (cat === "P") {
|
|
58
|
+
if (item.action === "delete") return { ok: false, reason: "个人文件(P),绝不自动删除" };
|
|
59
|
+
if (item.action === "junction") {
|
|
60
|
+
if (!config.targetDrive) return { ok: false, reason: "搬移需先选择目标盘" };
|
|
61
|
+
return { ok: true };
|
|
62
|
+
}
|
|
63
|
+
// 其余(remind/提示)允许
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 未分类(null)-> 禁止
|
|
68
|
+
if (!cat || cat === "unclassified") {
|
|
69
|
+
return { ok: false, reason: "尚未分类,需先确认" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A/C 可自动;必须已选目标盘(针对搬)
|
|
73
|
+
if ((cat === "A" || cat === "C") && !config.targetDrive && !["delete"].includes(item.action)) {
|
|
74
|
+
return { ok: false, reason: "搬移需先选择目标盘" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 生成 dry-run 预览:列出"将对每个条目做什么",不真正执行。
|
|
82
|
+
* 对 E/未确认的条目标"NOP(红线/未分类)"。
|
|
83
|
+
*/
|
|
84
|
+
export function preview(actions, config) {
|
|
85
|
+
return actions.map((a) => {
|
|
86
|
+
const chk = canExecute(a, config);
|
|
87
|
+
if (!chk.ok) {
|
|
88
|
+
return { ...a, will: "NOP", status: chk.reason };
|
|
89
|
+
}
|
|
90
|
+
let will = "DELETE";
|
|
91
|
+
if (a.category === "B") will = "REDIRECT(改软件设置)";
|
|
92
|
+
if (a.category === "C") will = "JUNCTION(搬到磁盘)";
|
|
93
|
+
if (a.action === "delete") will = "DELETE";
|
|
94
|
+
if (a.action === "junction") will = "JUNCTION";
|
|
95
|
+
if (a.action === "redirect") will = "REDIRECT";
|
|
96
|
+
return { ...a, will, status: "OK" };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 执行一条操作。返回 { ok, message, undo? }。
|
|
102
|
+
* 由 executor.mjs 调用;这里只负责安全门 + undo 记录。
|
|
103
|
+
*/
|
|
104
|
+
export async function runAction(action, config, executorFn) {
|
|
105
|
+
const chk = canExecute(action, config);
|
|
106
|
+
if (!chk.ok) return { ok: false, message: chk.reason };
|
|
107
|
+
|
|
108
|
+
const result = await executorFn(action, config);
|
|
109
|
+
if (result.ok && config.undoLog) {
|
|
110
|
+
appendUndo({ action: action.action, category: action.category, path: action.path, target: action.target, before: result.before, after: result.after });
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readUndoLog() {
|
|
116
|
+
return readUndo();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { undoPath, appendUndo };
|