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.
@@ -0,0 +1,140 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * scanner.mjs —— C 盘扫描引擎(Windows / PowerShell 后端)。
7
+ * 只读,不删不搬。产出扫描条目:[{ name, path, sizeMB, subdirs, lastAccess, zone }]。
8
+ *
9
+ * zone 标明条目来自哪个扫描区,供分类器决定语义优先级:
10
+ * appdata —— AppData 用户缓存/数据区(Local/Roaming)
11
+ * program —— Program Files 里的软件(大类,多为软件本体,判 E 保护 + 冷废弃提示)
12
+ * user —— 用户 profile 顶层(Downloads/Documents/Desktop 等个人文件,只提醒不自动删)
13
+ */
14
+
15
+ /** 用 PowerShell 批量递归统计体积(比 Node 快),返回 { path -> sizeMB } 一次性拿回 */
16
+ function batchSizes(paths) {
17
+ const results = {};
18
+ if (!paths.length) return results;
19
+ try {
20
+ const ps = `
21
+ $ErrorActionPreference='SilentlyContinue'
22
+ $paths = @(${paths.map((p) => `"${p.replace(/"/g, '""')}"`).join(",")})
23
+ $items = @()
24
+ foreach ($p in $paths) {
25
+ if (Test-Path -LiteralPath $p) {
26
+ $s = (Get-ChildItem -LiteralPath $p -Recurse -Force -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum
27
+ $items += [pscustomobject]@{ p=$p; mb=[math]::Round($s/1MB,1) }
28
+ }
29
+ }
30
+ $items | ConvertTo-Json -Compress`;
31
+ const raw = execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps], {
32
+ maxBuffer: 512 * 1024 * 1024,
33
+ timeout: 600000,
34
+ }).toString();
35
+ const parsed = JSON.parse(raw || "[]");
36
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
37
+ for (const item of arr) if (item && item.p) results[item.p] = item.mb;
38
+ } catch {
39
+ /* 某个目录扫描失败,跳过;由调用方逐目录 fallback */
40
+ }
41
+ return results;
42
+ }
43
+
44
+ /** 读取一个目录的一级子目录名(用于特征) */
45
+ function listSubdirs(dir) {
46
+ try {
47
+ return readdirSync(dir, { withFileTypes: true })
48
+ .filter((d) => d.isDirectory())
49
+ .map((d) => d.name)
50
+ .slice(0, 50);
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /** 读取最后访问时间(近似取目录下最新文件 mtime,网络/cache 不一定可靠,仅作 D 类参考) */
57
+ function lastAccess(dir) {
58
+ try {
59
+ let max = 0;
60
+ const files = readdirSync(dir, { withFileTypes: true }).slice(0, 200);
61
+ for (const d of files) {
62
+ const p = join(dir, d.name);
63
+ try {
64
+ const st = statSync(p);
65
+ const t = st.mtimeMs;
66
+ if (t > max) max = t;
67
+ if (d.isDirectory() && max === 0) {
68
+ const sub = lastAccess(p);
69
+ if (sub > max) max = sub;
70
+ }
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ }
75
+ return max;
76
+ } catch {
77
+ return Date.now();
78
+ }
79
+ }
80
+
81
+ /** 扩展 scanRoots 为 [{ root, zone }] 形式:兼容字符串数组 */
82
+ function normalizeRoots(scanRoots) {
83
+ return (scanRoots || []).map((r) =>
84
+ typeof r === "string"
85
+ ? { root: r, zone: zoneForPath(r) }
86
+ : { root: r.root, zone: r.zone || "appdata" },
87
+ );
88
+ }
89
+
90
+ /** 按路径推断 zone(兼容旧的字符串形式) */
91
+ function zoneForPath(p) {
92
+ if (/[Pp]rogram [Ff]iles/.test(p)) return "program";
93
+ if (/[Aa]pp[Dd]ata/.test(p)) return "appdata";
94
+ if (/[Uu]sers/.test(p)) return "user";
95
+ return "appdata";
96
+ }
97
+
98
+ /**
99
+ * 顶层扫描:对每个 scanRoot,列出其一级子目录,量体积,建条目(带 zone)。
100
+ * 返回 [{ name, path, sizeMB, subdirs, lastAccess, zone }] 按体积降序。
101
+ */
102
+ export async function scan(config, onProgress = () => {}) {
103
+ const output = [];
104
+ const roots = normalizeRoots(config.scanRoots || []);
105
+ for (const { root, zone } of roots) {
106
+ onProgress(`扫描 ${root} ...`);
107
+ let names = [];
108
+ try {
109
+ names = readdirSync(root, { withFileTypes: true })
110
+ .filter((d) => d.isDirectory())
111
+ .map((d) => d.name);
112
+ } catch {
113
+ continue;
114
+ }
115
+ if (!names.length) continue;
116
+
117
+ const fullPaths = names.map((n) => join(root, n));
118
+ const sizeMap = batchSizes(fullPaths);
119
+
120
+ for (const n of names) {
121
+ // user 区跳过 AppData(已由 appdata 根单独扫,避免重复)
122
+ if (zone === "user" && /^[Aa]pp[Dd]ata$/.test(n)) continue;
123
+ const p = join(root, n);
124
+ const sizeMB = sizeMap[p] ?? null;
125
+ output.push({
126
+ name: n,
127
+ path: p,
128
+ sizeMB,
129
+ subdirs: listSubdirs(p),
130
+ lastAccess: lastAccess(p),
131
+ zone,
132
+ });
133
+ onProgress(` ${n}: ${sizeMB ?? "?"} MB`);
134
+ }
135
+ }
136
+ output.sort((a, b) => (b.sizeMB ?? 0) - (a.sizeMB ?? 0));
137
+ return output;
138
+ }
139
+
140
+ export { lastAccess };
package/index.mjs ADDED
@@ -0,0 +1,287 @@
1
+ import { defineTool } from "@deepseek-ai/dsh-tools";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { DEFAULT_CONFIG, resolveConfig, saveConfig } from "./config.mjs";
4
+ import { scan } from "./core/scanner.mjs";
5
+ import { classifyEntry, recordUserDecision, CATEGORY_META } from "./core/classify.mjs";
6
+ import { snapshotProcessNames, isActive } from "./core/active.mjs";
7
+ import { preview, canExecute, runAction, readUndoLog } from "./core/safety.mjs";
8
+ import { dispatch, dirSizeMB, BASE } from "./core/executor.mjs";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ export const name = "dsh-disk-manager";
12
+ export const inject = ["tools", "llm", "commands"];
13
+
14
+ const SETTINGS_NS = "dsh-disk-manager";
15
+
16
+ /** 检测可用盘符(只读),供下拉框使用 */
17
+ function listDrives() {
18
+ try {
19
+ const raw = execFileSync("powershell", ["-NoProfile", "-Command",
20
+ "Get-PSDrive -PSProvider FileSystem | Where-Object {$_.Free -ne $null} | Select-Object -ExpandProperty Name | Sort-Object"], {
21
+ encoding: "utf8",
22
+ timeout: 15000,
23
+ }).toString();
24
+ const arr = raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).map((s) => s.toUpperCase());
25
+ return [...new Set(arr.filter((d) => /^[A-Z]$/.test(d)))]; // C,D,E...
26
+ } catch {
27
+ return ["C", "D", "E", "F", "G", "H"];
28
+ }
29
+ }
30
+
31
+ const settingsSchema = z.object({
32
+ enabled: z.boolean().default(true),
33
+ targetDrive: z.string().default(""),
34
+ abandonDays: z.number().min(1).max(3650).default(DEFAULT_CONFIG.abandonDays),
35
+ minSizeMB: z.number().min(0).max(10240).default(DEFAULT_CONFIG.minSizeMB),
36
+ undoLog: z.boolean().default(true),
37
+ drives: z.array(z.string()).default(["C", "D", "E", "F", "G", "H"]),
38
+ });
39
+
40
+ function toFlat(config, drives) {
41
+ return {
42
+ enabled: config.enabled,
43
+ targetDrive: config.targetDrive,
44
+ abandonDays: config.abandonDays,
45
+ minSizeMB: config.minSizeMB,
46
+ undoLog: config.undoLog,
47
+ drives: drives || [],
48
+ };
49
+ }
50
+
51
+ function fromFlat(flat) {
52
+ return {
53
+ enabled: flat.enabled,
54
+ targetDrive: flat.targetDrive,
55
+ abandonDays: flat.abandonDays,
56
+ scanRoots: DEFAULT_CONFIG.scanRoots,
57
+ minSizeMB: flat.minSizeMB,
58
+ undoLog: flat.undoLog,
59
+ blacklist: DEFAULT_CONFIG.blacklist,
60
+ drives: Array.isArray(flat.drives) ? flat.drives : [],
61
+ };
62
+ }
63
+
64
+ function textTool(definition) {
65
+ return defineTool({
66
+ ...definition,
67
+ output: {
68
+ schema: { type: "string" },
69
+ render: (_args, value) => [{ type: "text", text: value }],
70
+ },
71
+ presentCall: (args) => ({ card: "generic", kind: "text", title: definition.name, rawInput: args }),
72
+ });
73
+ }
74
+
75
+ export function apply(ctx, input = {}) {
76
+ let liveConfig = resolveConfig(input);
77
+
78
+ // 设置命名空间:Web 配置界面读写
79
+ const drives = listDrives();
80
+ ctx.inject(["settings"], (settingsCtx) => {
81
+ try {
82
+ const scope = settingsCtx.settings.register(SETTINGS_NS, settingsSchema, { base: toFlat(liveConfig, drives) });
83
+ const resolved = scope.get();
84
+ if (resolved) liveConfig = { ...liveConfig, ...fromFlat(resolved) };
85
+ scope.watch((next) => {
86
+ if (!next) return;
87
+ liveConfig = { ...liveConfig, ...fromFlat(next) };
88
+ });
89
+ } catch (err) {
90
+ ctx.logger.warn(`dsh-disk-manager: 设置命名空间注册失败:${err.message}`);
91
+ }
92
+ });
93
+
94
+ // ---------- 工具 0:查询可用盘符 ----------
95
+ ctx.tools.register(textTool({
96
+ name: "disk_drives",
97
+ description: "列出当前机器上的可用盘符(C/D/E...),供选择迁移目标盘。",
98
+ parameters: {},
99
+ async execute() {
100
+ return `可用盘符: ${drives.join(", ")}`;
101
+ },
102
+ }));
103
+
104
+ // ---------- 工具 1:扫描 + 分类 ----------
105
+ ctx.tools.register(textTool({
106
+ name: "disk_scan",
107
+ description:
108
+ "扫描整 C 盘(用户区 AppData + 用户 profile + Program Files,不扫 Windows),量出各大目录体积,并按 A/B/C/D/E/P 分类(无忧缓存/官方可改址/可junction搬/冷废弃软件/配置记忆红线/个人文件)。返回分组清单。",
109
+ parameters: {
110
+ minSizeMB: { type: "number", description: "可选:最小展示体积阈值(MB),默认用配置值" },
111
+ },
112
+ async execute(args) {
113
+ const cfg = { ...liveConfig, minSizeMB: args.minSizeMB ?? liveConfig.minSizeMB };
114
+ return runScan(ctx, cfg);
115
+ },
116
+ }));
117
+
118
+ // ---------- 工具 2:dry-run 预览------------
119
+ ctx.tools.register(textTool({
120
+ name: "disk_preview",
121
+ description: "对选中的条目做 dry-run 预览:列出每个条目将执行什么,不真正执行。actions 传 {path, category, action} 数组。",
122
+ parameters: {
123
+ actions: { type: "array", items: { type: "string" }, description: "JSON 字符串数组,每项形如 {\"path\":\"...\",\"category\":\"A\",\"action\":\"junction\",\"name\":\"...\"}" },
124
+ },
125
+ async execute(args) {
126
+ const acts = (args.actions || []).map((a) => {
127
+ const o = typeof a === "string" ? JSON.parse(a) : a;
128
+ return { ...o, target: buildTarget(o, liveConfig) };
129
+ });
130
+ const prev = preview(acts, liveConfig);
131
+ return prev
132
+ .map((p) => `[${p.will}] status=${p.status} path=${p.path}`)
133
+ .join("\n");
134
+ },
135
+ }));
136
+
137
+ // ---------- 工具 3:执行 ----------
138
+ ctx.tools.register(textTool({
139
+ name: "disk_execute",
140
+ description:
141
+ "真正执行选中的动作(删/junction搬/引导改址)。前提:必须已经 disk_preview 确认。E 红线/未分类条目不会被执行。",
142
+ parameters: {
143
+ actions: { type: "array", items: { type: "string" }, description: "JSON 字符串数组,每项形如 {\"path\":\"...\",\"category\":\"A\",\"action\":\"junction\",\"name\":\"...\"}" },
144
+ },
145
+ async execute(args) {
146
+ const acts = (args.actions || []).map((a) => {
147
+ const o = typeof a === "string" ? JSON.parse(a) : a;
148
+ return { ...o, target: buildTarget(o, liveConfig) };
149
+ });
150
+ const results = [];
151
+ for (const act of acts) {
152
+ const outcome = await runAction(act, liveConfig, (a, c) => dispatch(a, c));
153
+ results.push(`[${outcome.ok ? "OK" : "FAIL"}] ${act.path} :: ${outcome.message}`);
154
+ }
155
+ return results.join("\n");
156
+ },
157
+ }));
158
+
159
+ // ---------- 工具 4:确认未知分类(写入本地缓存) ----------
160
+ ctx.tools.register(textTool({
161
+ name: "disk_classify_confirm",
162
+ description:
163
+ "用户对 LLM 判断的条目做最终确认,写入本地分类缓存。items 传 {path, name, category} 数组。category 必须 A/B/C/D/E。",
164
+ parameters: {
165
+ items: { type: "array", items: { type: "string" }, description: "JSON 字符串数组,每项形如 {\"path\":\"...\",\"name\":\"...\",\"category\":\"A\"}" },
166
+ },
167
+ async execute(args) {
168
+ const lines = [];
169
+ for (const raw of args.items || []) {
170
+ const it = typeof raw === "string" ? JSON.parse(raw) : raw;
171
+ recordUserDecision({ name: it.name, path: it.path }, it.category, "用户确认");
172
+ lines.push(`确认 ${it.name} -> ${it.category}`);
173
+ }
174
+ return lines.join("\n");
175
+ },
176
+ }));
177
+
178
+ // ---------- 工具 5:查看 undo 日志 ----------
179
+ ctx.tools.register(textTool({
180
+ name: "disk_undo",
181
+ description: "列出本插件的 undo 日志(已执行的可逆操作),便于回滚。",
182
+ parameters: {},
183
+ async execute() {
184
+ const log = readUndoLog();
185
+ if (!log.length) return "暂无 undo 记录";
186
+ return log.map((l, i) => `${i + 1}. [${l.ts}] ${l.action} ${l.path} -> ${l.target ?? "-"}`).join("\n");
187
+ },
188
+ }));
189
+
190
+ // ---------- 工具 6:斜杠命令 /disk_scan(host 直跑,不经模型) ----------
191
+ // 供「设置页「开始扫描C盘」按钮」及用户直接在对话里输入 /disk_scan 使用。
192
+ if (ctx.commands) {
193
+ ctx.commands.register({
194
+ name: "disk_scan",
195
+ description: "扫描整 C 盘,量体积,按 A/B/C/D/E/P 分类返回清单。",
196
+ input: {
197
+ hint: "扫描整 C 盘占用并按类别返回清单。可选参数 minSizeMB=<MB> 覆盖最小展示体积阈值。",
198
+ images: false,
199
+ },
200
+ handler: async ({ rawInput }) => {
201
+ try {
202
+ const cfg = { ...liveConfig, minSizeMB: parseScanMinSize(rawInput) ?? liveConfig.minSizeMB };
203
+ const text = await runScan(ctx, cfg);
204
+ return { kind: "success", text };
205
+ } catch (e) {
206
+ return { kind: "error", text: `扫描失败: ${e && e.message ? e.message : String(e)}` };
207
+ }
208
+ },
209
+ });
210
+ }
211
+
212
+ ctx.logger.info("dsh-disk-manager: C盘空间规划整理大师已加载");
213
+ }
214
+
215
+ /** 抽取共享的扫描+分类+分组+报告逻辑,供工具与 /disk_scan 命令复用 */
216
+ async function runScan(ctx, cfg) {
217
+ const entries = await scan(cfg, (m) => ctx.logger.info(`disk_scan: ${m}`));
218
+ const classified = [];
219
+ for (const e of entries) {
220
+ if (e.sizeMB !== null && e.sizeMB < cfg.minSizeMB) continue;
221
+ const cls = await classifyEntry(e, { config: cfg, ctx });
222
+ classified.push({ ...e, ...cls });
223
+ }
224
+ const byCat = group(classified, cfg, snapshotProcessNames());
225
+ return renderReport(byCat, cfg);
226
+ }
227
+
228
+ /** 解析命令参数里的 minSizeMB,如 "minSizeMB=200" 或 "200" */
229
+ function parseScanMinSize(raw) {
230
+ const s = String(raw || "").trim();
231
+ if (!s) return null;
232
+ const m = s.match(/(minSizeMB\s*=\s*)?(\d+)/i);
233
+ if (!m) return null;
234
+ const n = Number(m[2]);
235
+ return Number.isFinite(n) && n >= 0 ? n : null;
236
+ }
237
+
238
+ /** 按类别分组 + D 冷废弃判断(process/custom 信号修正误判) */
239
+ function group(entries, cfg, procNames = []) {
240
+ const now = Date.now();
241
+ const byCat = { A: [], B: [], C: [], D: [], E: [], P: [], UNKNOWN: [] };
242
+ for (const e of entries) {
243
+ let cat = e.category;
244
+ const stale = e.lastAccess && now - e.lastAccess > cfg.abandonDays * 86400000;
245
+ const big = (e.sizeMB ?? 0) > 200;
246
+ // 冷废弃判断:大体积 + 超期未用;但"软件正在跑/常用"则跳过,避免误判
247
+ const active = isActive(e.name, procNames);
248
+ if (big && stale && !active) {
249
+ if (cat === "C") cat = "D";
250
+ else if (cat === "E" && e.zone === "program") cat = "D"; // 软件本体静置 -> 提示可能废弃
251
+ }
252
+ byCat[cat] = byCat[cat] || [];
253
+ byCat[cat].push(e);
254
+ }
255
+ return byCat;
256
+ }
257
+
258
+ function buildTarget(action, cfg) {
259
+ const drive = cfg.targetDrive || "D";
260
+ if (action.action === "junction") {
261
+ const name = action.name || action.path.split("\\").pop() || "cache";
262
+ const safe = name.replace(/[^\w.-]/g, "_");
263
+ return `${drive}:\\AppCache\\${safe}`;
264
+ }
265
+ return null;
266
+ }
267
+
268
+ function renderReport(byCat, cfg) {
269
+ const order = ["A", "B", "C", "D", "E", "P", "UNKNOWN"];
270
+ const lines = [`C盘空间规划整理大师 · 整 C 盘扫描完成(target=${cfg.targetDrive || "未选盘"}, 废弃阈值=${cfg.abandonDays}天, 最小展示=${cfg.minSizeMB}MB)`, ""];
271
+ let total = 0;
272
+ for (const cat of order) {
273
+ const arr = byCat[cat] || [];
274
+ if (!arr.length) continue;
275
+ const meta = CATEGORY_META[cat] || { label: cat, color: "gray" };
276
+ const sum = arr.reduce((s, e) => s + (e.sizeMB ?? 0), 0);
277
+ total += sum;
278
+ lines.push(`【${cat} ${meta.label}】合计 ${sum.toFixed(1)} MB,${arr.length} 项 ${meta.action}`);
279
+ for (const e of arr) {
280
+ const confirm = e.needUser ? "(需人工确认)" : e.source === "llm" ? "(LLM判断)" : "";
281
+ lines.push(` - ${e.sizeMB ?? "?"} MB [${e.confidence ?? "?"}] ${e.name} ${confirm} :: ${e.reason || ""}`);
282
+ }
283
+ lines.push("");
284
+ }
285
+ lines.push(`共识别约 ${total.toFixed(1)} MB`);
286
+ return lines.join("\n");
287
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "dsh-disk-manager",
3
+ "version": "0.1.0",
4
+ "description": "C-drive planner for DeepSeek Harness: scan the whole C drive, classify every big item into A/B/C/D/E/P (safe cache / relocatable / junction-movable / abandoned software / config-memory redline / personal files), LLM fallback + human confirm, then delete/move/redirect on demand with strong redline + dry-run/undo safeguards. C盘空间规划整理大师:扫描整 C 盘,分门别类给出正确动作,动手前先预览,红线配置/记忆永不自动执行。",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wangzhanchao883/dsh-disk-manager.git"
10
+ },
11
+ "homepage": "https://github.com/wangzhanchao883/dsh-disk-manager",
12
+ "bugs": {
13
+ "url": "https://github.com/wangzhanchao883/dsh-disk-manager/issues"
14
+ },
15
+ "author": "wangzhanchao883",
16
+ "exports": {
17
+ ".": "./index.mjs",
18
+ "./client": "./client.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "index.mjs",
23
+ "client.js",
24
+ "config.mjs",
25
+ "core/",
26
+ "cordis.patch.yml",
27
+ "config.example.json",
28
+ "README.md",
29
+ "screenshots.json",
30
+ "assets/screenshots/"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-locale",
39
+ "@deepseek-ai/dsh-client-runtime",
40
+ "@deepseek-ai/dsh-client-ui-settings"
41
+ ],
42
+ "platform": "web"
43
+ }
44
+ },
45
+ "scripts": {
46
+ "dev": "node dev-run.mjs",
47
+ "dev:auto": "node dev-run.mjs --auto doc"
48
+ },
49
+ "dependencies": {
50
+ "@deepseek-ai/schemastery": "^3.18.1"
51
+ },
52
+ "peerDependencies": {
53
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0"
54
+ },
55
+ "engines": {
56
+ "node": "^22.0.0 || >=24"
57
+ },
58
+ "keywords": [
59
+ "deepseek-harness",
60
+ "dsh",
61
+ "dsh-plugin",
62
+ "plugin",
63
+ "disk-cleanup",
64
+ "disk-manager",
65
+ "storage",
66
+ "junction",
67
+ "c-clean",
68
+ "windows",
69
+ "powershell",
70
+ "cache-cleaner"
71
+ ],
72
+ "license": "MIT"
73
+ }
@@ -0,0 +1,5 @@
1
+ [
2
+ "assets/screenshots/settings.png",
3
+ "assets/screenshots/scan-result.png",
4
+ "assets/screenshots/scan-result-2.png"
5
+ ]