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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wangzhanchao883
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # dsh-disk-manager · C盘空间规划整理大师
2
+
3
+ > 一个 **DSH(DeepSeek Harness)Web UI 插件**,把 C 盘占用分门别类,针对每类给出**不同的正确动作**(删缓存 / 引导改址 / junction 搬家 / 提示废弃 / 个人文件只提醒),而不是像传统清理工具那样只会删缓存。**配置/记忆类(红线)永不自动执行。**
4
+
5
+ <p align="center">
6
+ <img src="assets/screenshots/settings.png" alt="设置页" width="70%" />
7
+ <br/>
8
+ <em>设置页:一句话总结 + 「开始扫描C盘」按钮 + 通用设置</em>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="assets/screenshots/scan-result.png" alt="扫描分类结果" width="70%" />
13
+ <br/>
14
+ <img src="assets/screenshots/scan-result-2.png" alt="推荐档位" width="70%" />
15
+ </p>
16
+
17
+ ## 定位
18
+
19
+ 现有磁盘清理工具普遍只做一件事:**删缓存**。但你真正的问题往往是:
20
+ 1. **软件忘了删**(很久没用的大软件还霸着 C 盘);
21
+ 2. **数据放错了盘**(Docker 的 WSL、大缓存本来都可以搬到 D 盘);
22
+ 3. 缓存可再生但**清一次就长回来**。
23
+
24
+ `C盘空间规划整理大师` 是**统筹型参谋**:扫描整 C 盘 → 把每个大户分门别类 → 针对不同类别给不同动作 → 你点选确认后才执行。
25
+
26
+ ## 扫描范围
27
+
28
+ 整 C 盘(页面统一显示),**对内扫**:用户区 `AppData`(Local+Roaming) + 整个用户 profile(Downloads/Documents/Desktop 等) + `Program Files`;**不扫 Windows**(系统目录,扫了全是红线刷屏)。
29
+
30
+ ## 六类模型(核心)
31
+
32
+ | 类别 | 含义 | 动作 | 风险 |
33
+ |---|---|---|---|
34
+ | **A 无忧缓存** | Cache/GPUCache/Code Cache/Temp/log/*.updater | 删 或 junction 搬 | 无(可再生) |
35
+ | **B 官方可改址** | Docker/npm/pnpm/pip/浏览器 profile | 走官方设置改到别的盘 | 低(官方支持) |
36
+ | **C 可junction搬** | 剪映/Notion/Trae/Cursor 等纯可再生缓存 | junction 搬到别的盘 | 低(可再生) |
37
+ | **D 冷废弃软件** | 大体积 + 超过 N 天(默认180)没用 | 提示「你可能很久没用它了」 | 只提示 |
38
+ | **E 配置/记忆(红线)** | Config/Storage/IndexedDB/MEMORY/.openviking/.qclaw | **只展示,绝不执行** | 高(绝不碰) |
39
+ | **P 个人文件** | Downloads/Documents/Desktop 等用户自己的数据 | 只提醒大文件,可手动搬,绝不自动删 | 高(用户数据) |
40
+
41
+ > **冷废弃(D)已修正误判**:已装软件(Docker、Typora、NVIDIA 等)的*安装目录*只在安装当天写一次 mtime,之后天天运行也不会更新,旧逻辑会按"lastAccess 超 180 天"误判成废弃。现在扫描时会快照一次运行中进程 + 命中 Docker/WSL 等常用白名单 → **在跑/常用的软件永不进入「冷废弃」**。
42
+
43
+ ## 环境要求
44
+
45
+ - **Windows**(扫描与执行依赖 PowerShell;junction/删除用系统原生能力)。
46
+ - **Node.js** ≥ 22(DSH 运行时)。
47
+ - **DeepSeek Harness** web profile(插件安装进 web 使用)。
48
+ - 可选:DSH 自带 LLM(用于未知目录的 LLM 兜底分类)。
49
+
50
+ ## 分类引擎:静态规则 + LLM 兜底 + 人工确认
51
+
52
+ 不同电脑软件千差万别,不能靠死规则硬吃。分类引擎是**三级**:
53
+
54
+ 1. **静态规则库 `core/riskmap.mjs`** —— 覆盖常见几十种软件/目录特征 + 扫描区(zone)判定,零成本、确定性。
55
+ 2. **本地分类缓存** —— 用户确认过一次的分类写入 `~/.dsh-disk-manager/classify-cache.json`,下次同类直接命中,越用越聪明。
56
+ 3. **LLM 兜底** —— 未命中的未知目录,打包特征(目录名/大小/子目录名)送 DSH 自带 LLM 判断类别,再让**用户确认**后才生效。
57
+
58
+ **强红线**:任何 `config/storage/indexeddb/memory/database/.openviking/.qclaw` 特征、以及所有点目录(`.xxx`),即使 LLM 判错,也会被回退为 E(只展示,不执行)。
59
+
60
+ ## 架构速览
61
+
62
+ ```
63
+ ┌─────────────── client.js (Web 设置页, React.createElement 无构建) ───────────────┐
64
+ │ 一句话总结 · 「开始扫描C盘」按钮 → connection.api.sessions.prompt("/disk_scan") │
65
+ │ 通用设置: 启用/目标盘/冷废弃天数/扫描范围 → settingsScope.set() │
66
+ └───────────────────────────────┬──────────────────────────────────────────────────┘
67
+ │ host 端工具 + 斜杠命令
68
+ ┌───────────────────────────────▼──────────────────────────────────────────────────┐
69
+ │ index.mjs 工具注册(disk_scan/drives/preview/execute/classify/undo) │
70
+ │ + /disk_scan 命令(host 直跑,复用 runScan) │
71
+ │ config.mjs 配置(~/.dsh-disk-manager/config.json) + 分类缓存 │
72
+ │ core/ │
73
+ │ scanner.mjs PowerShell 批量量体积 → {name,path,sizeMB,subdirs,lastAccess} │
74
+ │ active.mjs 进程快照 + 常用白名单(修冷废弃误判) │
75
+ │ riskmap.mjs 静态规则库:目录名→A/B/C/D/E 特征映射 │
76
+ │ classify.mjs 规则→缓存→LLM 三级分类 │
77
+ │ safety.mjs 红线判定 + dry-run + undo │
78
+ │ executor.mjs 删/junction/改址执行 │
79
+ └──────────────────────────────────────────────────────────────────────────────────┘
80
+ ```
81
+
82
+ ## 安全防线
83
+
84
+ - E 红线/程序本体/系统目录/点目录 **永远只展示**,无执行入口;
85
+ - P 个人文件**只提醒,绝不自动删**(仅允许手动 junction 搬);
86
+ - 执行前 `disk_preview` 生成 dry-run 预览;
87
+ - 可生成 undo 日志(`disk_undo` 查看);
88
+ - junction 迁移用原生 `New-Item -ItemType Junction`,已验证可回读。
89
+
90
+ ## 工具(对话里驱动)
91
+
92
+ | 工具 | 作用 |
93
+ |---|---|
94
+ | `disk_drives` | 列出当前机器可用盘符(C/D/E...),供选择迁移目标盘 |
95
+ | `disk_scan` | 扫描整 C 盘,量体积,按 A/B/C/D/E/P 分组返回报告 |
96
+ | `disk_preview` | 对选中项 dry-run 预览(列出"将做什么") |
97
+ | `disk_execute` | 真正执行(删/junction/引导改址);E/未分类不执行,P 不可删 |
98
+ | `disk_classify_confirm` | 用户确认 LLM 判断的类别,写入本地缓存 |
99
+ | `disk_undo` | 查看 undo 日志,便于回滚 |
100
+ | `/disk_scan` | 斜杠命令:host 直跑扫描,不经模型;等价于 `disk_scan`,供设置页按钮调用 |
101
+
102
+ ## 安装
103
+
104
+ ```bash
105
+ dsh plugin --profile web add dsh-disk-manager
106
+ ```
107
+
108
+ 安装后重启 DSH。设置页出现「C盘空间规划整理大师」标签;点「开始扫描C盘」一键扫描,或在对话里直接运行 `/disk_scan` / 调用 `disk_scan` 工具。
109
+
110
+ ## 配置文件
111
+
112
+ `~/.dsh-disk-manager/config.json`,首次使用需在设置页或工具里选好**目标盘**。参考 [`config.example.json`](./config.example.json)。
113
+
114
+ ## 目录结构
115
+
116
+ ```
117
+ dsh-disk-manager/
118
+ ├── package.json # dsh.client.web 注入 + npm 元数据
119
+ ├── index.mjs # 主入口 + 工具注册 + /disk_scan 命令
120
+ ├── client.js # Web 设置页
121
+ ├── config.mjs # 用户配置 + 分类缓存读写
122
+ ├── cordis.patch.yml
123
+ ├── screenshots.json # 注册表截图声明
124
+ ├── assets/screenshots/ # 产品截图
125
+ └── core/
126
+ ├── scanner.mjs # 扫描引擎(PowerShell 后端)
127
+ ├── active.mjs # 进程在跑检测 + 常用软件白名单(修冷废弃误判)
128
+ ├── riskmap.mjs # 静态规则库(五类特征映射)
129
+ ├── classify.mjs # 分类引擎(规则→缓存→LLM)
130
+ ├── safety.mjs # 红线判定 + dry-run + undo
131
+ └── executor.mjs # 执行引擎(删/junction/改址)
132
+ ```
133
+
134
+ ## 开发
135
+
136
+ ```bash
137
+ npm install
138
+ node dev-run.mjs # 只读自测扫描+分类
139
+ node dev-tool.mjs # 模拟 ctx 走通 apply + disk_scan
140
+ ```
141
+
142
+ ## License
143
+
144
+ [MIT](./LICENSE)
Binary file
package/client.js ADDED
@@ -0,0 +1,244 @@
1
+ /* dsh-disk-manager client bundle — Web 设置页。
2
+ * 以经典脚本形式注册到 window.__ModuleLoader__;工厂内用 require 取 React,
3
+ * 不能用 JSX(无构建步骤),一律 React.createElement。
4
+ * 说明:扫描/分类/执行由模型驱动的工具(disk_scan/disk_preview/disk_execute)
5
+ * 在对话里呈现与确认;本页负责用户配置(目标盘/启用)。 */
6
+ window.__ModuleLoader__.load({
7
+ id: "dsh-disk-manager",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ var exports = module.exports;
11
+
12
+ const React = require("react");
13
+ const h = React.createElement;
14
+ const { useEffect, useRef, useState } = React;
15
+
16
+ const NS = "settings.diskManager";
17
+ const SETTINGS_NAMESPACE = "dsh-disk-manager";
18
+
19
+ const zh = {
20
+ nav: "C盘空间规划整理大师",
21
+ loading: "正在读取配置…",
22
+ unavailable: "配置面板不可用(设置服务未挂载)。请直接编辑配置文件 ~/.dsh-disk-manager/config.json。",
23
+ saved: "已保存",
24
+ error: "保存失败:",
25
+ general: "通用设置",
26
+ enabled: "启用C盘空间规划整理大师",
27
+ enabledHint: "关闭后不再提供C盘扫描/清理工具",
28
+ targetDrive: "将C盘缓存数据搬迁到的新盘符",
29
+ targetDriveHint: "选择要把缓存/数据搬到的盘(如 D)。下拉框只列出检测到的盘符,避免乱填",
30
+ targetDrivePlaceholder: "请选择盘符",
31
+ abandon: "连续未运行的不常用软件扫描",
32
+ abandonMark: "180 天",
33
+ abandonHint: "连续 180 天未运行的软件会被提示「你可能很久没用它了」(默认 180 天,无需填写)",
34
+ scanRootDesc: "整 C 盘",
35
+ scanRootHint: "扫描整 C 盘用户区 + Program Files,不扫 Windows。",
36
+ use: "使用说明",
37
+ useText: "对话里调用 disk_scan 扫描分类、disk_preview 预览、disk_execute 确认执行。配置/记忆类(红线)永远只展示;个人文件只提醒、绝不自动删。",
38
+ summary: "扫描整 C 盘占用,把每个大空间按「可删缓存 / 可改址 / 可搬盘 / 冷废弃 / 红线保护」分类,动手前先预览、做完可回滚。扫描过程中耗时较长,一般需要几分钟,请耐心等待。",
39
+ startScan: "开始扫描C盘",
40
+ startScanHint: "点击后在对话里运行扫描并按类别返回清单;扫描耗时约几分钟。",
41
+ scanning: "扫描中…",
42
+ scanRunning: ">>> 已发起扫描,请到当前对话查看结果。",
43
+ scanNoSession: ">>> 无会话,请先在对话新建一个会话。",
44
+ scanUnknown: ">>> 未知命令: /disk_scan(插件未加载该命令)。",
45
+ scanFail: "发起失败:",
46
+ };
47
+
48
+ const en = {
49
+ nav: "C Drive Planner",
50
+ loading: "Reading configuration…",
51
+ unavailable: "Configuration panel unavailable. Edit ~/.dsh-disk-manager/config.json instead.",
52
+ saved: "Saved",
53
+ error: "Save failed:",
54
+ general: "General",
55
+ enabled: "Enable C Drive Planner",
56
+ enabledHint: "Disables C-drive scan/cleanup tools when off",
57
+ targetDrive: "Target drive for C-drive cache",
58
+ targetDriveHint: "Pick the drive to move caches/data to (e.g. D). Only detected drives are listed.",
59
+ targetDrivePlaceholder: "Select a drive",
60
+ abandon: "Rarely-used software scan",
61
+ abandonMark: "180 days",
62
+ abandonHint: "Software not run for 180 days is flagged as possibly abandoned (default, no input needed)",
63
+ scanRootDesc: "Entire C drive",
64
+ scanRootHint: "Scans C-drive user area + Program Files, not Windows.",
65
+ use: "Usage",
66
+ useText: "Call disk_scan in chat to scan/classify, disk_preview to dry-run, disk_execute to confirm. Config/memory (redline) is display-only; personal files are remind-only.",
67
+ summary: "Scan the whole C drive, categorize each large space into cache / relocatable / movable / abandoned / redline, preview before acting and undoable after. Scanning takes a few minutes — please wait.",
68
+ startScan: "Scan C Drive",
69
+ startScanHint: "Runs the scan in the conversation and returns categorized results; takes a few minutes.",
70
+ scanning: "Scanning…",
71
+ scanRunning: ">>> Scan started, check the current conversation for results.",
72
+ scanNoSession: ">>> No session, create one in the chat first.",
73
+ scanUnknown: ">>> Unknown command: /disk_scan (plugin not loaded).",
74
+ scanFail: "Failed to start:",
75
+ };
76
+
77
+ const STYLES = [
78
+ ".dsk-config{max-width:640px;display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary)}",
79
+ ".dsk-group{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;background:var(--dsw-alias-bg-layer-2)}",
80
+ ".dsk-group h3{margin:0 0 12px;font-size:13px;font-weight:600}",
81
+ ".dsk-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}",
82
+ ".dsk-field label{font-size:12px;font-weight:500}",
83
+ ".dsk-field select,.dsk-field input[type=text],.dsk-field input[type=number]{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font:inherit;font-size:13px;box-sizing:border-box}",
84
+ ".dsk-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
85
+ ".dsk-switch{display:flex;align-items:center;gap:8px;margin-bottom:6px}",
86
+ ".dsk-switch input{accent-color:var(--dsw-alias-state-business-primary)}",
87
+ ".dsk-status{font-size:12px;color:var(--dsw-alias-label-tertiary);min-height:16px}",
88
+ ".dsk-row{display:flex;gap:12px}",
89
+ ".dsk-note{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);white-space:pre-wrap}",
90
+ ".dsk-static{font-size:13px;color:var(--dsw-alias-label-primary);padding:6px 0}",
91
+ ".dsk-summary{font-size:13px;line-height:1.7;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-left:4px solid var(--dsw-alias-state-business-primary);border-radius:8px;padding:12px 14px;white-space:pre-wrap}",
92
+ ".dsk-btn{height:32px;padding:0 16px;border:1px solid var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-primary);color:#fff;border-radius:6px;font:inherit;font-size:13px;cursor:pointer}",
93
+ ".dsk-btn:disabled{opacity:.6;cursor:not-allowed}",
94
+ ].join("");
95
+
96
+ function Field({ label, hint, children }) {
97
+ return h("div", { className: "dsk-field" }, h("label", null, label), children, hint ? h("div", { className: "dsk-hint" }, hint) : null);
98
+ }
99
+
100
+ /** 下拉选择(盘符) */
101
+ function SelectInput({ value, onCommit, options, placeholder }) {
102
+ return h("select", {
103
+ value: value || "",
104
+ onChange: (e) => onCommit(e.target.value),
105
+ }, [
106
+ h("option", { value: "", disabled: true }, placeholder || ""),
107
+ ...(options || []).map((opt) => h("option", { key: opt.value, value: opt.value }, opt.label)),
108
+ ]);
109
+ }
110
+
111
+ function ConfigSection({ scope, t, drives, runScan }) {
112
+ const [snap, setSnap] = useState(() => scope.getSnapshot());
113
+ const [status, setStatus] = useState("");
114
+ const [scanMsg, setScanMsg] = useState("");
115
+ const [scanning, setScanning] = useState(false);
116
+ const statusTimer = useRef(null);
117
+ useEffect(() => scope.subscribe(() => setSnap(scope.getSnapshot())), [scope]);
118
+ useEffect(() => () => { if (statusTimer.current) clearTimeout(statusTimer.current); }, []);
119
+ const flash = (msg) => {
120
+ setStatus(msg);
121
+ if (statusTimer.current) clearTimeout(statusTimer.current);
122
+ statusTimer.current = setTimeout(() => setStatus(""), 2500);
123
+ };
124
+ const save = (field, v) => {
125
+ Promise.resolve(scope.set(field, v))
126
+ .then(() => flash(t("saved")))
127
+ .catch((err) => flash(`${t("error")} ${err && err.message ? err.message : String(err)}`));
128
+ };
129
+ const doScan = () => {
130
+ if (!runScan || scanning) return;
131
+ setScanning(true);
132
+ setScanMsg("");
133
+ Promise.resolve(runScan())
134
+ .then((res) => setScanMsg(res || t("scanRunning")))
135
+ .catch((err) => setScanMsg(`${t("scanFail")} ${err && err.message ? err.message : String(err)}`))
136
+ .finally(() => setScanning(false));
137
+ };
138
+
139
+ if (snap.status === "loading") return h("p", { className: "dsk-status" }, t("loading"));
140
+ if (snap.status === "unavailable") return h("p", { className: "dsk-status" }, t("unavailable"));
141
+ const v = snap.value || {};
142
+ const driveOptions = (drives || []).map((d) => ({ value: d, label: d + ": 盘" }));
143
+
144
+ return h("div", { className: "dsk-config" }, [
145
+ h("div", { className: "dsk-summary" }, t("summary")),
146
+ h("div", { className: "dsk-group" }, [
147
+ h("button", { className: "dsk-btn", disabled: scanning, onClick: doScan }, scanning ? t("scanning") : t("startScan")),
148
+ h("div", { className: "dsk-hint" }, t("startScanHint")),
149
+ h("div", { className: "dsk-status" }, scanMsg),
150
+ ]),
151
+ h("div", { className: "dsk-group" }, [
152
+ h("h3", null, t("general")),
153
+ h("div", { className: "dsk-switch" }, [
154
+ h("input", { type: "checkbox", id: "dsk-enabled", checked: !!v.enabled, onChange: (e) => save("enabled", e.target.checked) }),
155
+ h("label", { htmlFor: "dsk-enabled" }, t("enabled")),
156
+ ]),
157
+ h("div", { className: "dsk-hint" }, t("enabledHint")),
158
+ Field({
159
+ label: t("targetDrive"),
160
+ hint: t("targetDriveHint"),
161
+ children: h(SelectInput, { value: v.targetDrive || "", onCommit: (val) => save("targetDrive", val), options: driveOptions, placeholder: t("targetDrivePlaceholder") }),
162
+ }),
163
+ Field({
164
+ label: t("abandon"),
165
+ hint: t("abandonHint"),
166
+ children: h("div", { className: "dsk-static" }, t("abandonMark")),
167
+ }),
168
+ Field({
169
+ label: t("scanRootDesc"),
170
+ hint: t("scanRootHint"),
171
+ children: h("div", { className: "dsk-static" }, t("scanRootDesc")),
172
+ }),
173
+ h("div", { className: "dsk-note" }, t("useText")),
174
+ ]),
175
+ h("div", { className: "dsk-status" }, status),
176
+ ]);
177
+ }
178
+
179
+ function apply(ctx) {
180
+ ctx.effect(() => {
181
+ const tag = document.createElement("style");
182
+ tag.setAttribute("data-plugin", "dsh-disk-manager");
183
+ tag.textContent = STYLES;
184
+ document.head.appendChild(tag);
185
+ return () => { if (tag.parentNode) tag.parentNode.removeChild(tag); };
186
+ }, "dsh-disk-manager: styles");
187
+
188
+ ctx.effect(() => {
189
+ return ctx.locale.register(NS, { zh, en });
190
+ }, "dsh-disk-manager: dictionaries");
191
+
192
+ const t = ctx.locale.bind(NS);
193
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE });
194
+ const drives = ["C", "D", "E", "F", "G", "H"]; // 兜底;实际以配置/扫描检测为准
195
+ const connection = ctx.get("connection");
196
+ const remote = ctx.get("remote");
197
+ const sessions = ctx.get("sessions");
198
+ const runScan = async () => {
199
+ try {
200
+ if (!connection) return `${t("scanFail")} connection 不可用`;
201
+ if (!sessions || !sessions.list) return `${t("scanFail")} sessions 不可用`;
202
+ // 1) 取当前会话(就是你正在看的这个对话);为空则回退到列表里最后一个
203
+ const cur = sessions.list.getSnapshot();
204
+ const targetId = cur.current || (cur.ids && cur.ids.length ? cur.ids[cur.ids.length - 1] : void 0);
205
+ if (!targetId) return t("scanNoSession");
206
+ // 2) 确保它在前台(已选中则无副作用)
207
+ try { if (sessions.open) sessions.open(targetId); } catch { /* 已在前台则忽略 */ }
208
+ // 3) 给该会话发一条用户消息 "/disk_scan",host 会走命令注册表执行、结果渲染进对话。
209
+ // 用 session.prompt(入队、立即返回),避免像 commands.execute 那样长时间等待/挂住。
210
+ const tz = (Intl.DateTimeFormat && Intl.DateTimeFormat().resolvedOptions().timeZone) || "Asia/Shanghai";
211
+ const { result } = await connection.api.sessions.prompt({
212
+ sessionId: targetId,
213
+ mode: "queue",
214
+ content: [{ type: "text", text: "/disk_scan" }],
215
+ clientTimeZone: tz,
216
+ });
217
+ if (result && result.ok === false) {
218
+ return `${t("scanFail")}${result.error && result.error.message ? " " + result.error.message : ""}`;
219
+ }
220
+ return t("scanRunning");
221
+ } catch (e) {
222
+ return `${t("scanFail")}${e && e.message ? " " + e.message : ""}`;
223
+ }
224
+ };
225
+ const injected = () => ({ scope, drives, runScan });
226
+
227
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
228
+ name: "settings.section",
229
+ id: "dsh-disk-manager",
230
+ order: 210,
231
+ label: () => t("nav"),
232
+ locale: NS,
233
+ inject: injected,
234
+ }, ConfigSection));
235
+ }
236
+
237
+ module.exports = {
238
+ name: "dsh-disk-manager",
239
+ inject: ["slots", "locale", "settingsScope", "connection", "remote", "remote.commands", "sessions"],
240
+ apply,
241
+ };
242
+ return module.exports;
243
+ },
244
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "enabled": true,
3
+ "targetDrive": "D",
4
+ "abandonDays": 180,
5
+ "minSizeMB": 50,
6
+ "undoLog": true
7
+ }
package/config.mjs ADDED
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ /**
6
+ * C盘空间规划整理大师 DSH 插件配置。
7
+ * 持久化位置 ~/.dsh-disk-manager/config.json。
8
+ * 默认目标盘为空(首次使用让用户选),180 天为"冷废弃软件"阈值。
9
+ */
10
+ export const DEFAULT_CONFIG = Object.freeze({
11
+ enabled: true,
12
+ // 目标盘:首次使用由用户在 Web UI 下拉选择,如 "D"。为空则提示先选盘。
13
+ targetDrive: "",
14
+ // 冷废弃软件判定:超过该天数未使用视为"可能废弃"(类 D)
15
+ abandonDays: 180,
16
+ // 扫描根目录:整 C 盘用户区 + Program Files(不扫 Windows)。
17
+ // 页面统一显示为"整 C 盘";内部聚焦这些能发现真实大户的区。
18
+ scanRoots: [
19
+ { root: "C:\\Users\\Administrator\\AppData\\Local", zone: "appdata" },
20
+ { root: "C:\\Users\\Administrator\\AppData\\Roaming", zone: "appdata" },
21
+ { root: "C:\\Users\\Administrator", zone: "user" },
22
+ { root: "C:\\Program Files", zone: "program" },
23
+ { root: "C:\\Program Files (x86)", zone: "program" },
24
+ ],
25
+ // 最小展示体积阈值(MB),低于此不列,避免刷屏
26
+ minSizeMB: 50,
27
+ // 用户黑名单:这些路径标签永远只展示不执行
28
+ blacklist: [
29
+ "\\Programs\\",
30
+ "\\Packages\\",
31
+ "\\Microsoft\\Windows",
32
+ "\\Windows\\",
33
+ ],
34
+ // 是否生成 undo 日志(junction 迁移/删除可再生缓存时)
35
+ undoLog: true,
36
+ });
37
+
38
+ export function configDir() {
39
+ return join(homedir(), ".dsh-disk-manager");
40
+ }
41
+
42
+ export function configPath() {
43
+ return join(configDir(), "config.json");
44
+ }
45
+
46
+ /** 合并默认 + 配置文件 + 运行时入参(插件行 config 覆盖最高) */
47
+ export function resolveConfig(input = {}) {
48
+ let file = {};
49
+ try {
50
+ if (existsSync(configPath())) {
51
+ file = JSON.parse(readFileSync(configPath(), "utf8"));
52
+ }
53
+ } catch {
54
+ file = {};
55
+ }
56
+ const merged = mergeDeep(structuredClone(DEFAULT_CONFIG), file);
57
+ return mergeDeep(merged, input);
58
+ }
59
+
60
+ export function saveConfig(config) {
61
+ mkdirSync(configDir(), { recursive: true });
62
+ writeFileSync(configPath(), JSON.stringify(config, null, 2), "utf8");
63
+ }
64
+
65
+ /** 分类缓存:记录"目录特征 -> 用户确认的类别",命中则跳过 LLM/人工 */
66
+ export function classifyCachePath() {
67
+ return join(configDir(), "classify-cache.json");
68
+ }
69
+
70
+ export function loadClassifyCache() {
71
+ try {
72
+ if (existsSync(classifyCachePath())) {
73
+ return JSON.parse(readFileSync(classifyCachePath(), "utf8"));
74
+ }
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ return {};
79
+ }
80
+
81
+ export function saveClassifyCache(cache) {
82
+ mkdirSync(configDir(), { recursive: true });
83
+ writeFileSync(classifyCachePath(), JSON.stringify(cache, null, 2), "utf8");
84
+ }
85
+
86
+ function mergeDeep(base, patch) {
87
+ if (patch === undefined || patch === null) return base;
88
+ if (typeof patch !== "object" || Array.isArray(patch)) return patch;
89
+ const out = { ...base };
90
+ for (const [k, v] of Object.entries(patch)) {
91
+ out[k] =
92
+ typeof v === "object" && v !== null && !Array.isArray(v) && typeof out[k] === "object" && out[k] !== null
93
+ ? mergeDeep(out[k], v)
94
+ : v;
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1,5 @@
1
+ # dsh-disk-manager bundle patch layer.
2
+ # 安装本包到 profile 后,host 端加载主入口(扫描/分类/执行引擎)。
3
+ - insert:
4
+ - id: dsh-disk-manager
5
+ name: dsh-disk-manager
@@ -0,0 +1,70 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * active.mjs —— 判断目录对应的软件「是否正在使用/常用」。
5
+ *
6
+ * 用于修复:冷废弃(D)误判 —— 已装软件(如 Docker/Typora)的程序安装目录
7
+ * 只在安装当天写一次 mtime,之后每天运行也不会再改动该目录,导致
8
+ * "lastAccess 超 180 天"被误判为"废弃"。这里用"进程是否在跑 + 常用白名单"
9
+ * 作为更可靠的"是否还在用"信号,命中则不判冷废弃。
10
+ */
11
+
12
+ /**
13
+ * 常用/常驻服务白名单:这些软件的目录名命中即视为"仍在用",永不判冷废弃。
14
+ * 即使扫描那一刻恰好没有前台进程(如开机自启、后台服务),也不误报。
15
+ */
16
+ export const ALWAYS_ACTIVE = [
17
+ "docker", "com.docker", "wsl", "vmcompute", "lxss",
18
+ "dotnet", "nodejs",
19
+ "clash", "netease",
20
+ ];
21
+
22
+ /** 快照当前所有运行进程的 ProcessName(小写、去重)。一次扫描只需调一次。 */
23
+ export function snapshotProcessNames() {
24
+ try {
25
+ const raw = execFileSync("powershell", ["-NoProfile", "-Command",
26
+ "Get-Process -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName | Sort-Object -Unique"], {
27
+ encoding: "utf8",
28
+ timeout: 15000,
29
+ maxBuffer: 8 * 1024 * 1024,
30
+ }).toString();
31
+ return raw
32
+ .split(/\r?\n/)
33
+ .map((s) => s.trim())
34
+ .filter(Boolean)
35
+ .map((s) => s.toLowerCase());
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 目录对应软件是否仍在用。
43
+ * @param {string} name 目录名(可能带空格,如 "Docker"/"TRAE SOLO CN")
44
+ * @param {string[]} procNames 运行中进程名快照(小写)
45
+ * @returns {boolean} true=仍在用(不判废弃)/ false=可视为未用
46
+ */
47
+ export function isActive(name, procNames) {
48
+ const n = normalize(name);
49
+ if (!n) return false;
50
+
51
+ // 1) 常用白名单:命中即视为在用
52
+ for (const a of ALWAYS_ACTIVE) {
53
+ if (n.includes(normalize(a))) return true;
54
+ }
55
+
56
+ // 2) 进程匹配:任一运行进程名包含目录名(归一化后),视为在用。
57
+ // 注:进程名是"运行实例"的最直接证据,例如 Docker 运行时会有
58
+ // docker / com.docker.backend / dockerdesktop 等进程。
59
+ for (const p of procNames) {
60
+ const pn = normalize(p);
61
+ if (pn.includes(n)) return true;
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ /** 归一化:小写,去除非字母数字(用于宽松匹配) */
68
+ function normalize(s) {
69
+ return String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
70
+ }