# dsh-disk-doctor > **DSH 从不删东西,所以这个数字只会涨。** `dsh-disk-doctor` 把 DeepSeek Harness 的空间构成摊开给你看:**三个区并排**、每一类占多少、哪些是可再生的、哪些是你自己的会话内容。 **v0.1 从不删除任何东西。** 它只统计、只报告,唯一的「改动」是一次 **rename(改名移动)**:当你在设置里打开清理、并二次确认之后,🟢「可以安全清理」的文件夹会被移进插件自己的隔离目录 `~/.dsh/disk-doctor/quarantine/` 下——不是复制,不是删除,一键就能撤回。除此之外它做的一切都是只读的。 --- ## 能力声明 装之前先看这一段,它就是全部契约。 | 问题 | 回答 | |---|---| | 读什么? | 只读目录元数据(`stat` 大小):`$DSH_HOME`(默认 `~/.dsh`)下、系统临时目录里以 `dsh-` 开头的目录,以及 DSH 的两份安装与两个系统缓存(见下)。**不打开、不解析、不上传任何文件内容**;唯一的例外是 `storages/workspace.json`,只用来给每个会话文件夹标上真实工作区标题。 | | 写什么? | 每份报告正好两个文件:`disk-report-<时间戳>.md` 与 `.json`,都写在同一个目录(默认 `/disk-doctor/reports`)。**被确认执行的清理**会额外写出一个隔离批次:被移动的文件夹本身(一次 rename,字节没变,只是换了位置),以及一份 `manifest.json`——它在**每移动一项之后都会重写**,所以哪怕中途被打断,盘上的记录仍然和现实一致。全程不删除、不复制。 | | 会删除或修改吗? | **它从不删除。** 代码里没有任何删除路径:有一条测试禁止整个包出现 `unlink`、`unlinkSync`、`rmdir`、`rmdirSync`、`rm(`、`rmSync`、`trashItem`、`Remove-Item` 与 `shutil.rmtree`。被确认的清理做的事是**把绿色档文件夹 rename 进插件自己的隔离目录**——整个清理模块只有这一个改动调用(`renameSync`),并且有测试断言它只出现在一个文件里(`lib/cleanup.js`)。清空隔离目录始终是你自己的事,在本插件之外完成。 | | 联网吗? | **不联网。** 没有 `fetch`、没有 `http`/`https`、没有 socket;同样有测试断言。 | | 会出界吗? | 会——为了那些它只统计、但并不拥有的目录:DSH 的两份安装(打包应用与 npm 全局那份)、桌面端用户数据目录、pnpm 内容仓库。这些都是**探测**出来的而不是猜的(见「三个区」一节),只读测量,并且在报告里写明位置。默认仍然不跟随符号链接与 junction,所以只有链接才能到达、且在树外的字节不会被计入。 | | 有遥测吗? | 没有,代码里没有任何统计上报。 | | 会打印我的真实路径吗? | 看这段文字去哪儿。**面板上显示的是真实路径**——因为你要在那里复制路径、在那里打开文件位置。凡是会离开这台机器的输出——工具返回值、Markdown 报告、JSON 报告——都会把主目录替换成 `~`,于是 `C:\Users\<你>\.dsh\…` 变成 `~\.dsh\…`。这个区分是刻意的,用户在 2026-10-07 确认接受。 | | 会开端口或对外提供什么吗? | 它在 DSH **自己的本机 web 服务**上注册了**三条路由**,自己不监听任何端口。`GET /dsh-disk-doctor/report` 返回的内容和工具返回的 JSON 完全一样,供右侧栏面板取数,且**从不写文件**;`GET /dsh-disk-doctor/cleanup` 返回清理**预演**清单以及已经在隔离目录里的批次;`POST /dsh-disk-doctor/cleanup?action=move` 执行移动、`POST …?action=undo&id=<批次>` 把一个批次移回去——两者在清理开关没打开时都会被 403 `disabled` 拒绝,而且都会重新读一遍当前设置,所以开关不需要重启。`POST /dsh-disk-doctor/open` 让宿主在系统文件管理器里打开一个文件夹——只接受 POST,并且只允许扫描真正覆盖到的根目录之内的路径。🟢「可以安全清理」的位置**开箱即可打开**;🟠「要谨慎处理」与 ⚪「不要动」两档需要另一个默认关闭的开关。没有 web 服务时三条路由都不注册。三条路由在做任何事之前都会先问宿主自己的**浏览器信任栅栏**——和官方路由同一套 Host/Origin 校验与浏览器身份认证——所以你只是访问过的一个网页无法对它们发起 POST 去移动文件或弹出文件管理器窗口。 | 这些声明同时是**机器可读**的:清单里 `dshhub.permissions.network` 声明为**空数组**(一个来源都不访问),另有 `dshhub.surfaces: ["host", "web"]` 与 `schemaVersion: 1`。测试会把该声明与代码绑定——两个半边里只要出现**对外**网络调用就失败;而浏览器半边对本机自己的相对路由做 `fetch` 是允许的。 --- ## 你会得到什么 **右侧栏的一个面板。** 点开 **磁盘空间**,看到**三个区**上下排开——**DSH 数据**、**DSH 程序**、**系统缓存**,每个区有自己的数字、每个分类一条横条,点开某条能看到里面最大的几个文件夹,外加一句「删了会怎样」。最上面那个数字属于**数据区**,所以它写的是「DSH 的数据占了…」,而不是暗示它统计了整个 DSH。每行还有一个 **打开** 按钮,点了会在文件管理器里定位到那个文件夹:🟢「可以安全清理」的行**直接就能点**,🟠 和 ⚪ 的行在没打开「敏感位置」开关之前是灰的。面板底部有一份**颜色说明**,把三个小圆点的含义逐条写清楚。三个区下面还有两个方框:一个是**清理方框(标题为「清理」)**,先列出「如果清理会移动什么」,然后——只有在你打开清理开关并二次确认之后——才把这些文件夹移进插件自己的隔离目录,并给出 **撤销** 按钮和仍在隔离目录里的其他批次(见下面的「清理:预演、移动与撤销」);另一个是「想省更多空间?这样做才对」的指路说明。链接安装的插件单独列一份——它们的真实文件在所有被扫描的文件夹之外。第一次会自动打开。**要给不懂技术的人看,就是这个界面。** **一个 `disk_report` 工具。** 同样的数字,可以让模型按需跑一次,输出 Markdown 摘要并写出 `.md` / `.json` 报告文件。返回值里多了 `areas` 与 `linkInstalls` 两块,每个分类也会写明自己属于哪个区、有多少字节与别的区重叠。 **数据区里的三个视图。** **总览**每个分类一片;**按插件**与**按聊天记录**则直接写出条目的名字。后两个视图的图例右上角有一个小小的 **「显示完整路径 / 显示名称」** 按钮,在名称与完整路径之间切换——它改的就是 ⚙ 里那个「显示完整路径」偏好,所以两个控件不可能各说各话。聊天记录用的是**真实工作区标题**加一小段会话编号(`disk-doctor · 64875ca0`,而不是 `E\disk-doctor · session-`),可展开的列表行用的是同一套名字。插件则保持包名(`dsh-image-gen`、`@scope/pkg`):更好看的 `displayName` 存在每个插件自己的 `package.json` 里,去读它就破坏了「只打开过 `storages/workspace.json` 一个文件」这个承诺。 **插件页面上的 8 个设置项**(**插件 / Plugins → Installed** 里那张卡片):三个明面字段、五个收在**高级选项**里,其中两个是开关。此外还有第 9 个字段 `allowCleanup`,它住在**面板自己的 ⚙ 齿轮**里——因为它管的就是面板上那个按钮。它同样是宿主设置,存进同一份 profile patch。每一项的大白话解释、以及**怎么把插件交给别人**,见 [USAGE.md](./USAGE.md)。 > **v0.1 从不删除。** 被确认的清理只会把 🟢「可以安全清理」的文件夹移进插件自己的隔离目录,再点一次就能移回去,清空那个目录始终是你自己的事。插件与对话永远不会被碰——要卸插件请照常去插件页卸载。 --- ## 三个区,以及为什么没有「总计」 报告把整台机器分成三个区,**每个区各自给出总数,并排显示**: | 区 | 是什么 | 在这台机器上 | |---|---|---| | `data` | DSH 的**数据文件夹**——`$DSH_HOME`,默认 `~/.dsh`:会话内容、存储与投影缓存、插件 `node_modules`、自带运行时,以及 `~/.dsh/electron` 那份解包后的 Electron 运行时。 | 735 MB | | `app` | **DSH 程序**,一台机器上它可能有两份:打包后的 Electron 应用本体(本机是 `E:\dsh1`),以及 npm 单独装的那份全局 DSH(本机是 `%APPDATA%\npm\node_modules\@deepseek-ai\dsh`,254.8 MB)——profile 里那个共享 `node_modules` 基本就是指向它的 junction 农场。只读:这些目录归卸载程序管,不归本插件管。 | 1270 MB(1.2 GB) | | `cache` | DSH 使用但并不拥有的**系统缓存**:桌面端的 Chromium 用户数据目录(`%APPDATA%\@deepseek-ai\dsh-desktop`)与 pnpm 内容仓库(`%LOCALAPPDATA%\pnpm`)。 | 126 MB | **三个区永远不相加,也不打印任何「总计」。** pnpm 内容仓库里放的是插件 `node_modules` 硬链接过去的**真实文件**,把三个区加起来会把同一批物理字节算两遍。所以 `pnpm-store` 这个类别会报 `overlapBytes`——按 inode 量的——缓存区也会用自己的话把这件事说清楚。本机上 112 MB 的 pnpm 仓库里有约 109 MB 与数据区重叠。 面板与摘要最前面显示的数字是**数据区**——真正属于你的那部分 DSH——所以面板写的是「DSH 的数据占了…」,而不是暗示它统计了一切。 *本机实测于 2026-10-07。* --- ## 环境要求 - DeepSeek Harness `>=0.1.0-rc.2`(清单里就是这么声明的)——但真正跑过的只有 `0.2.0-rc.2`,更新的版本请当作**未经验证** - Node.js `^22.19.0 || >=24.0.0` - 零运行时依赖、零构建,**也不需要打包器**——浏览器半边是用 web 客户端自己的模块格式手写的 ## 安装 > **还没发布到 npm。** 下面这个包名目前是空的,所以这条命令要等发布之后才能用。在那之前请从**本地文件夹**安装——开发工作副本见 [TRIAL-INSTALL.md](./TRIAL-INSTALL.md),完全不打命令的普通用户路线见 [USAGE.md](./USAGE.md)(**插件 → 添加插件**)。 ``` dsh plugin --profile add dsh-disk-doctor ``` `dsh plugin` 会在 profile 目录里转发给 pnpm;插件管理器默认会把新装的 bundle 选入,所以插件声明的 `dsh.bundle.patch` 会自动插入 profile 的分层栈。把包名换成**本地绝对路径**同样可用。 这条命令要求 `pnpm` 在 `PATH` 上;如果不在,见 [TRIAL-INSTALL.md](./TRIAL-INSTALL.md) 里那行使用 DSH 运行时自带 pnpm 的转接写法。 装好并**重启**后:右侧栏出现 **磁盘空间** 面板,`disk_report` 工具可用,插件自己的页面上(**插件 / Plugins → Installed** 里那张卡片)出现 **8 个**配置项——第 9 个「允许清理(移动到回收站)」刻意放在面板自己的 ⚙ 齿轮里,紧挨着它管的那个按钮。 ## 使用 直接用自然语言问即可——「DSH 占了多少磁盘?」——或者显式调用工具。 ### `disk_report` | 参数 | 类型 | 默认 | 含义 | |---|---|---|---| | `scope` | `"summary"` \| `"full"` | `"summary"` | `summary` 只看分类汇总;`full` 额外在每类下面列出 Top N 明细。 | | `topN` | 整数 `1..100` | 取插件配置 | 每个类别列出的条目数。超范围会直接报错,不会静默截断。 | | `write` | 布尔 | `true` | 是否写入 Markdown 与 JSON 报告文件。 | | `categories` | 字符串数组 | 全部 | 只统计这些类别——合计也随之变化。未知 id 会报错。 | 工具返回:进入对话的 Markdown 摘要、写出的报告文件绝对路径、以及 JSON 结构。 ### 输出示例 以下是一次真实运行的输出(路径与逐项数字做了缩写): ``` ### DSH 空间体检 — DSH 数据 735 MB / 8,903 个文件 > 数据文件夹:~\.dsh ├─ 会话日志 2.73 MB (3 项,最大 1.15 MB) ├─ 存储与投影缓存 0.13 MB (5 项,最大 91.5 KB) ├─ 插件 node_modules 109 MB (511 个链接未跟随,23 项,最大 62.3 MB) ├─ 插件安装日志 0.00 MB (最大 3.70 KB) ├─ 市场数据 0.02 MB (最大 17.8 KB) ├─ 运行时依赖 272 MB (4 项,最大 165 MB) ├─ 临时目录与溢出 3.82 MB (10 项,最大 3.24 MB) ├─ 附件 0.27 MB (最大 279 KB) ├─ Electron 运行文件 347 MB (21 项,最大 215 MB) └─ 其他 0.10 MB (19 项,最大 22.8 KB) 一共 **735 MB**:**3.84 MB** 可以安全清理(来自 临时目录与溢出 3.82 MB、市场数据 17.8 KB、插件安装日志 3.70 KB),**109 MB** 需要正确操作才能省下(例如卸载不用的插件),剩下 **622 MB** 请不要动。三部分相加就是总数。 **DSH 程序 — 1.24 GB** > 安装位置:E:\dsh1(自动找到);npm 全局安装:~\AppData\Roaming\npm\node_modules\@deepseek-ai\dsh;只读展示,不参与清理 ├─ 程序安装目录 0.99 GB (22 项,最大 649 MB) └─ 全局安装的 DSH(npm) 0.25 GB (8 项,最大 255 MB) **系统缓存 — 126 MB** > 这些是 DSH 之外的位置,与上面的区不能相加:桌面端用户数据 ~\AppData\Roaming\@deepseek-ai\dsh-desktop、pnpm 内容仓库 ~\AppData\Local\pnpm > 注意:其中约 109 MB 与「DSH 数据」里的插件文件是同一批物理文件(硬链接),所以两个区的数字不能相加,也不是删掉就能省下这么多。 ├─ 桌面端用户数据 13.9 MB (24 项,最大 9.30 MB) └─ pnpm 内容仓库 112 MB (最大 112 MB) **链接安装(真实文件在扫描范围之外,不计入上面的数字)** - ~\.dsh\profiles\desktop\node_modules\dsh-disk-doctor → 真实位置 `<你的插件目录>\dsh-disk-doctor`,420 KB 扫描用时 7.2 秒 报告已写入 `~\.dsh\disk-doctor\reports\disk-report-20261007-094500.md` ``` 这份输出里有三点值得点出来:数据区的数字已经在最上面那行标题里了,所以紧跟其后的是它的位置说明,不再重复一个标题;**另外两个区各自带自己的标题和自己的数字**,而且谁都不与别人相加——缓存区会用文字说明为什么。**被截断的测量会写成 `至少 `**:如果统计链接目标时撞到自己的上限,那一行会写「至少 12.4 MB」,而不是假装精确。**链接列表在渲染文档里最多 10 行**,超出时最后一行写「…另有 N 个未列出」。 遍历是并行的——同时走 16 个子目录、并发 64 个 `lstat`——所以哪怕 npm 那份安装有约 33,000 个文件,三个区完整跑一遍也只要几秒。 `scope: "full"` 时,数据区下面还会列出最大的若干条目,每条都带绝对路径——所以任何一个数字都能下钻到构成它的文件。 ## 配置项 这 9 个字段里,8 个在插件自己的页面上改,第 9 个(`allowCleanup`)在面板的 **⚙** 齿轮里改;任何一个也都可以写进 profile 的 Cordis patch。 ### 表单在哪里 侧栏 → **插件 / Plugins** → **Installed** → `dsh-disk-doctor` 卡片 → 它自己的页面上就是配置表单。表单通过 `plugins.bundle.config` 插槽贡献(以 bundle 的包名为键),所以只有这个 bundle 自己的页面会渲染它。如果宿主还没提供该插件的设置命名空间,表单会**如实说明**,而不是整个消失。 如果你自己写 DSH 插件,这一点值得知道:**光有 schema 不会产生页面。** `dsh-settings` 只从声明了 `.volatile()` 的字段投影表单(本插件 9 个字段都声明了),而**没有任何已发布的客户端会按 schema 生成页面**——`autoGenerate` 就是为此存在的,但没有东西消费它。页面必须由插件**自己的浏览器半边**提供,所以本包除了 `lib/` 还带了一个 `client/client.js`。插件页面渲染其中 **8 个**字段——先是三个明面字段,另外五个收在**高级选项**里;第 9 个(`allowCleanup`)由面板的 **⚙** 齿轮渲染,走的是**同一套共享配置表单**,所以两个界面不可能各说各话,齿轮里的保存也和设置页保存一样会落到 profile patch。齿轮显示的是**宿主生效值**;如果这个值被 home patch 或命令行覆盖锁住,它会直接说明改不了。 ### 同样这 9 项写在 YAML 里 ```yaml - id: disk-doctor name: 'dsh-disk-doctor' config: topN: 20 followSymlinks: true allowOpenFolderSensitive: true allowCleanup: true ``` 两条路等价:表单写入的也是同一份 profile patch。无头部署、或需要在首次渲染前就定好的值,直接写 patch 更合适。表单里仍然**文本编码**的字段只剩两个,因为共享表单控件就是一个文本输入框:`excludeGlobs` 用英文逗号分隔,`followSymlinks` 填字面量 `true` 或 `false`。三个权限项都是**开关(switch)**而不是让你手打 `true`/`false` 的输入框——`allowOpenFolder` 与 `allowOpenFolderSensitive` 在插件页面上,`allowCleanup` 在面板的 **⚙** 齿轮里;它暂存的值走的仍是同一套文本编码,所以保存时写进 profile patch 的内容完全没变。任何字段清空即表示移除覆盖、回落到默认值。 | 字段 | 类型 | 默认 | 含义 | |---|---|---|---| | `dshHome` | 字符串 | `$DSH_HOME`,否则 `~/.dsh` | 要统计的数据文件夹。它完全跟着宿主走:设了 `DSH_HOME` 就用它,否则用 `~/.dsh`——所以在别人机器上默认值就已经是对的。 | | `topN` | 整数 `1..100` | `10` | 每个类别列出的条目数。 | | `excludeGlobs` | 字符串数组 | `[]` | 额外排除的绝对路径通配符,例如 `C:\Users\me\.dsh\big-*`。`*` 不跨路径分隔符,`**` 跨。 | | `reportDir` | 字符串 | `/disk-doctor/reports` | 报告输出目录,支持 `~`;路径会用 `realpath` 规范化,因此符号链接无法悄悄把写入重定向到别处。这是唯一的可写位置;指到 `$DSH_HOME` 之外是允许的,但每份报告都会明确标注这一点。 | | `followSymlinks` | 布尔 | `false` | 是否跟随符号链接与 junction。开之前请先读下面的「符号链接」一节。 | | `appDir` | 字符串 | 留空 = **自动探测** | DSH 程序安装目录。留空就按运行中的程序位置探测;填了就以你填的为准,报告里那一区会标成「来自设置」而不是「自动找到」。只有探测不到时才需要填。 | | `allowOpenFolder` | 布尔 | `true` | 页面上是一个开/关开关。面板 **打开** 按钮的总开关,**默认开着**,所以 🟢「可以安全清理」的行开箱就能点开文件夹。把它**关掉**则会灰掉所有按钮,宿主也会直接拒绝。 | | `allowOpenFolderSensitive` | 布尔 | `false` | 页面上是一个开/关开关,**默认关着**。在总开关之外**另一道独立的同意**:打开后,🟠「要谨慎处理」与 ⚪「不要动」两类位置也能点开——那可能是你的对话目录,也可能是程序安装目录。 | | `allowCleanup` | 布尔 | `false` | 一个开/关开关,**默认关着**;它是唯一一个在**面板的 ⚙ 齿轮**里改的字段(齿轮里的第 4 个控件,在分隔线下面、三个外观偏好之后)。关着时移动按钮是灰的,宿主对 `POST …?action=move` 回 403 `disabled`。打开它本身不会移动任何东西:第一次点击只会把按钮改成「确定,移动」并弹出询问行,第二次点击才真的移动。设置改动在下一次请求就生效,不需要重启。 | ### 关于「打开」按钮 面板上的每一行都有一个 **打开 / Open** 按钮,由两个开关共同把关,而这两个开关**故意不是同一个**: - `allowOpenFolder` 是总开关,而且**默认开着**:🟢「可以安全清理」的行直接就能点开文件夹,什么都不用配。它同时是「急停」开关——关掉后每个按钮都变灰,宿主也会以 `disabled` 直接拒绝。 - `allowOpenFolderSensitive` **默认关着**,是针对 🟠「要谨慎处理」与 ⚪「不要动」两行的**第二道、独立的同意**。在人家的对话目录或程序文件旁边打开文件夹,比打开一个临时目录要重得多,所以它不跟着总开关一起生效。 两者在设置页上都是**开关行**——一行标签、一行提示,外加一个只有被改过之后才出现的「恢复默认」小控件。它暂存的值走的仍是同一套文本编码,所以保存时写进 profile patch 的内容没变。 宿主会拿路径去跟**扫描真正覆盖到的根目录**做包含判断(取覆盖它的最具体的那个根),不在其中就拒绝;这条路由只接受 POST,所以误点一次导航或一次预取都不会弹出窗口;文件只会定位到它所在的文件夹,永远不会被当成文件交给别的程序打开。链接安装的插件不给按钮——它们的目标按定义就在所有被扫描的根之外——只给一个复制按钮。 **按插件**与**按聊天记录**两张图里的每一条图例行同样带「打开」按钮,而那些行是 🟠 与 ⚪——所以两张图一开始**所有**按钮都是灰的。这种情况下,图自己的右上角会写清楚原因和该去打开哪个开关:只差敏感开关时写「要在设置里打开『允许打开需谨慎处理的位置』才能点开」,总开关被关掉时写「要打开文件夹,得先去设置里把『允许打开文件夹』打开」;按钮一旦能用,这句话就消失。**总览**画的是分类而不是文件夹,所以它刻意不提供名称/路径切换。 ### 颜色说明 同一份说明出现在两个地方:面板底部、范围说明的上方(紧凑模式下隐藏),以及设置页**高级选项**里两个开关的正下方——因为那里正是读者被要求判断「橙色和灰色那些要不要开」的地方。 | 圆点 | 档位 | 含义 | |---|---|---| | 🟢 | 可以安全清理 / Safe to clean up | DSH 自己能重新生成的,清理不影响使用。 | | 🟠 | 要谨慎处理 / Handle with care | 能用正确方法省下的,比如去卸载不用的插件,而不是手动删文件。 | | ⚪ | 不要动 / Leave alone | DSH 运行需要的,以及你自己的对话和附件,删了会出问题。 | ## 扫描范围 每个类别都会先探测存在性;路径不存在时跳过并在报告的 `warnings` 里记一行,而不是悄悄消失。每个类别都写明自己属于哪个区,所以三个区的合计不会互相串味:「其他」只是**数据文件夹**的兜底类别,永远不兜整台机器。 | 区 | id | 路径 | 可再生 | 处理建议 | 说明 | |---|---|---|---|---|---| | data | `sessions` | `/sessions/**` | 否 | 不要动 | 会话内容。按会话排行,并标注真实工作区标题与日期。 | | data | `storages` | `/storages/**` | 否 | 要谨慎 | 存储与投影缓存;`session_projcache/sessions/*.json` 逐个列出。 | | data | `profile-deps` | `/profiles/*/node_modules/**` | 是 | 要谨慎 | 第三方插件依赖,按包排行。 | | data | `profile-logs` | `/profiles/*/.plugin-manager/logs/**` | 是 | 可以清理 | 插件安装日志。 | | data | `profile-market` | `/profiles/*/.dsh-market/**` | 是 | 可以清理 | 市场状态与日志。 | | data | `plugin-cache` | `/plugin-packages/**` | 是 | 可以清理 | 下载的插件包缓存。 | | data | `runtimes` | `/dsh-runtimes/**` | 否 | 不要动 | 内置 node/python/pnpm,按组件排行。 | | data | `temp-spill` | `%TEMP%/dsh-*` | 是 | 可以清理 | 溢出目录与会话私有临时目录。 | | data | `attachments` | `` 下任意 `*attach*` 目录 | 否 | 不要动 | 探测式;找不到会记 warning 并归入「其他」。 | | data | `runtime-app` | `/electron/**` | 否 | 不要动 | DSH 自己运行所依赖的那整套解包 Electron 运行时。它原来的名字是「DSH 程序本体」,现改名为 **Electron 运行文件**,免得与下面新的「程序安装目录」区撞名。 | | data | `other` | `` 的剩余部分 | 否 | 要谨慎 | 兜底类别,保证数据文件夹不漏账;排除以上所有 data 根。 | | app | `app-install` | DSH 程序安装目录(探测得出,或用 `appDir`) | 否 | 不要动 | 打包后的 Electron 应用本体。只读;这个目录归卸载程序管。探测不到时会用大白话写「没找到」。 | | app | `npm-global-dsh` | `%APPDATA%/npm/node_modules/@deepseek-ai/dsh`,或 npm prefix 下的位置 | 否 | 不要动 | 第二份安装:全局的 `dsh` CLI——桌面应用与它甚至读不到对方的 profile。只有该目录确实是个含 `package.json` 的目录时才会被采纳。 | | cache | `chromium-data` | `%APPDATA%/@deepseek-ai/dsh-desktop` | 否 | 要谨慎 | 桌面窗口的用户数据:登录状态与界面缓存。 | | cache | `pnpm-store` | `%LOCALAPPDATA%/pnpm` | 否 | 要谨慎 | pnpm 内容仓库。带**重叠探针**:它的 `overlapBytes` 是已经被数据区算过一遍的那部分字节,按 inode 量。 | 在数据区内,`$DSH_HOME`(加上临时目录)下的每一个字节都恰好落进一个类别,所以**数据区各部分之和等于数据区的总数**。程序区与缓存区各有各的总数;**三个总数永远不相加**(见「三个区,以及为什么没有『总计』」)。 「其他」的定义是**数据区那些根的补集**,而不是一串固定路径。所以用 `categories` 收窄范围时,合计会跟着变;只选「其他」时,它报告的是未被选中的那些类别没有覆盖到的部分。 ### 数据区之外那些目录是怎么找到的 不猜,能算出来的也绝不问用户: - **数据文件夹**完全按宿主读取 `DSH_HOME` 的方式跟随它,取不到时才回落到 `~/.dsh`。所以有人把数据目录搬走了,这里也能自动统计对,不需要任何配置。 - **程序安装目录**从正在运行的可执行文件推出来——`process.execPath`,并以 `process.resourcesPath` 与 `DSH_DESKTOP_NODE_EXECUTABLE` 作为后备——而且只有那个目录里**真的**存在 `resources/app.asar` 才会被采纳。所以无头的 `node` 部署会老老实实报「没找到」,而不是随便挑一个旁边的目录。填了 `appDir` 就覆盖探测,此时该区的说明会写「来自设置」而不是「自动找到」。 - **第二份安装**是 npm 那份。桌面应用与全局 `dsh` CLI 在同一台机器上是两套安装(彼此连对方的 profile 都读不了),而 profile 里那个共享 `node_modules` 基本就是指向 npm 那份的 junction。它会从 `%APPDATA%\npm\node_modules`、`npm_config_prefix` 下的两个位置(`lib/node_modules` 与 `node_modules`)、`~/.npm-global/lib/node_modules` 以及 Unix 上惯用的全局根目录去探测,并且只有候选目录里确实有 `package.json` 才算数。漏掉它,就会有四分之一 GB 藏在一份看起来很完整的报告旁边——这正是这个区要防的那种失败。 - **两个缓存目录**来自 `%APPDATA%` 与 `%LOCALAPPDATA%`,在其他平台上回落到 `.config` 与 `Library/Application Support`。 ### 链接安装的插件 用链接方式装的插件——`dsh plugin add <本地路径>`,开发中的 `dsh-disk-doctor` 自己就是这么装的:它住在 `<你的工作副本路径>`,却出现在 `~/.dsh/profiles//node_modules/` 下——它的字节没有一个真的落在被扫描的树里,所以本来会从报告里整个消失:装了个东西却什么也看不到的人,会开始怀疑其他每一个数字。因此它单列一节:链接本身、真实指向哪里、以及那个目标的真实大小。**这些字节永远不会被加进任何一个区的总数。** 判断的依据是**目标**,不是链接本身。如果链接的目标就在某个已覆盖的根**里面**,那它就**不再单列**——那些字节已经算在所在的区里了,再说它们「不在任何数字里」就是假话。本机上 512 个链接里有 510 个指向上面那份 npm 安装,报告会用一行说明这件事,只列出真正指向外面的那一个。 这项测量在单个根与整体两个层级上都有上限,渲染出来的文档最多列 10 行;撞到上限的那次测量会写成 `至少 `(「至少」),而不是报一个它并没有量准的数字。 ## 报告产物 - `disk-report-.md` —— 完整人类可读报告:每个区各一节,各自带构成树与自己的总数,然后是链接安装清单、注意事项、以及每个类别的 Top N 表格。 - `disk-report-.json` —— 稳定的机器契约,`"schema": "dsh-disk-doctor/report@1"`,本次是**增量**扩展(additive),没有破坏 `@1`。它带 `totals`(照旧是数据区)、新增的 `areas`、`linkInstalls` 与 `capabilities`(`openFolder`、`openFolderSensitive`、`cleanup`),以及 `warnings`;`categories[]` 里的每一项仍带 `topItems`,并新增了 `area` 与 `overlapBytes`。 工具自身的返回值也多了同样的东西:顶层的 `areas` 与 `linkInstalls`,以及每个类别上的 `area`/`overlapBytes`。每个 `topItems` 条目现在还会带 `mtimeMs`——那一条里最新的文件修改时间——而且它是工具输出 schema 里的**必填**字段,因为清理判断「最近有没有在用」就靠它。 ## 清理:预演、移动与撤销 清理方框分三个阶段,每一步都是一次独立的、需要你主动点的动作。 ### 1. 预演:列出会移动什么 点 **「看看会移动什么」**,宿主会从 `GET /dsh-disk-doctor/cleanup` 画出一份清单(`"schema": "dsh-disk-doctor/cleanup@1"`,第一个字段是 `dryRun: true`);同一个响应还会带回隔离目录里已有的批次。清单背后的规则: - **只有 🟢「可以安全清理」的类别参与**——`profile-logs`、`profile-market`、`plugin-cache`、`temp-spill`。其他每个类别都会出现在 `refused[]` 里并附上它的安全等级,所以「没参与」是明说的而不是悄悄漏掉:⚪ 里是程序本体、自带运行环境和你的对话;🟠 里是装好的插件,而插件「移走再重启看看」是**必然失败**的。 - **只动已经安静了一小时的东西。** 最新文件在一小时内被碰过的条目不会进清单,而是记进 `skipped[]`,`reason: "recent"`(`RECENT_USE_MS = 60 * 60 * 1000`)。判断用的是**条目内部最新的 mtime**,不是文件夹自身的时间戳——进程一直在往目录里写文件时,目录自己的 mtime 是不动的。一台在干活的机器上,绿色档里最大的那一项通常就是当前会话自己的溢出目录,把它从活着的进程脚下搬走不叫清理。完全无法检查的条目同样跳过。 - **是全部,不是 Top N。** 报告每个类别只带 10 条——那是清单最不该有的形状:四十个文件夹里只列十五个,等于在「会移动什么」这件事上说谎。所以预演用 `PREVIEW_ITEM_LIMIT = 500` 的上限把安全类别重新枚举一遍,某一类超过上限就置 `truncated: true` 并附一条提示。 - **落点只是算出来的。** 清单会写出 `/disk-doctor/quarantine/`,但不会创建它。 ### 2. 移动:只 rename,不复制、不删除 `allowCleanup`(**默认关着**)才是让移动变成真的那个开关,而它由**面板自己的 ⚙ 齿轮**控制——齿轮里的第 4 个控件,在分隔线下面、三个外观偏好之后。关着时 **移动到回收站** 按钮是灰的并写明原因,宿主对移动请求直接回 403 `disabled`。打开之后,那个按钮上就是字面意义上的两次点击:第一次**把按钮文字改成「确定,移动」**并弹出询问行——「确定移动这 N 项(共 X)吗?」加「确定,移动」与「取消」——第二次点击(改过字的按钮,或询问行里的「确定,移动」)才真的移动。询问期间按钮**保持可点**:禁用按钮根本收不到点击,这正是这一版最初发出一个「看着能点、点了没反应」的按钮的原因。然后,逐项: - **动手之前会把计划重新画一遍**,所以永远不会按一份过期的清单行动:你在看预演之后才被写过的条目会直接掉出这次移动,并记进 `skippedRecent`。 - **每一项都会重新校验归属。** `lib/targets.js` 的 `ownsCleanupPath()` 回答的是「绿色档清理是否有权拥有这个路径」,而且**与它此刻是否存在无关**——这份独立性正是关键:整目录条目(`.dsh-market`、某个 `dsh-spill-*` 目录)**自己就是根**,一移动它就不存在了。每个绿色类别各自声明规则:两种 profile 叶子形状(`//.dsh-market` 与 `…/.plugin-manager/logs`)、一个目录前缀(`/plugin-packages`),以及临时溢出的形状规则(`/dsh-*`)。改用扫描的根列表来判断,会把用户自己的文件判成「不能放回去」——这是真机探针抓出来的一个真 bug。 - **条目被 rename** 到 `<隔离目录>/<时间戳>/<分类 id>/<它在自己根下的原始布局>`,所以打开隔离目录仍然看得出东西原来在哪。两个 profile 都有 `.dsh-market`,因此名字已被占用时会加 `-2`、`-3`……后缀。 - **每移动一项都会重写清单**,所以中途被打断也留着与现实一致的记录。批次文档是 `dsh-disk-doctor/quarantine@1`,状态从 `moving` 走到 `moved` / `moved-with-failures`;计划为空时**一个文件夹都不会创建**(`nothing-to-move`)。 移动失败时,它会说明三种原因之一,而不是让你看着什么都没发生:权限没开(`disabled`)、**宿主还是旧版本**(这个 POST 接口还不存在——重启 DSH 再试一次),或者根本连不上宿主。 `renameSync` 是清理模块里唯一的改动调用。清空隔离目录始终是你自己的事,在本插件之外完成。 ### 3. 撤销:把每一项放回去,或者拒绝 方框会为刚移动的那个批次、以及仍在隔离目录里的每个批次提供 **撤销**(`listQuarantine()` 按新的在前列出)。在发生任何一次 rename 之前,`undoCleanup()` 会重新校验一切:批次 id 必须是 `YYYYMMDD-HHMMSS` 时间戳,记录里的每个 `target` 必须在这个批次内,每个 `original` 必须是清理有权拥有的路径。所以手改过的清单没法把文件挪到别处去。 原位置如果已经重新出现,会被**拒绝、绝不覆盖**,并记进 `failures`,`reason: 'occupied'`;恢复按倒序进行,所以嵌套条目先回去,包含它们的文件夹后回去。批次会留在 `status: 'undone'`(或 `partly-undone`),面板会说明哪些没能回去、为什么。 另一个方框 **「想省更多空间?这样做才对」** 之所以存在,是因为在这里删文件夹几乎不省空间(pnpm 仓库与插件目录是同一批字节的硬链接),或者会把 DSH 弄坏(运行环境与程序本体):它会点出最大的那个插件并指向插件页、写出 pnpm 仓库及其测得的重叠量并给出 `pnpm store prune`,再列出绝对不能动的东西。 ### 真机实测(2026-10-07) 预演耗时约 **45 ms**;02:21 那次列出 **8 项 / 3,854.8 KB**,同时把 **466,243 字节**因为「太新」而跳过。随后对本机已经死掉的临时溢出目录做了一次**真实移动**:**6 项 / 3,833.3 KB**,每一个原位置都空了,每一个目标都出现在 `quarantine/<时间戳>/temp-spill/…` 下,**0 失败**。撤销把 6 项全部移回,同样 **0 失败**,事后对所有「清理可涉及路径」做指纹比对**逐字节一致**,清单留在 `status: 'undone'`。隔离目录里剩下的批次(`20261007-030514` 及其后几个)只留着清单和一个空的分类目录,你自己删掉是安全的。 ## 设计不变量 1. **绝不破坏数据**:v0.1 只写自己的报告,唯一的改动操作是**把绿色档文件夹 rename 进插件自己的隔离目录**——只限 🟢 档、只在开关打开时、只在二次确认之后,而且一路写清单,所以随时可撤。 2. **可解释**:每个数字都能下钻到构成它的文件,没有不可追溯的汇总值。 3. **可回滚,而且是真的**:每移动一项都会重写清单,撤销会先拿清单与磁盘重新校验:目标不在批次内、原位置不在安全根内、或原位置已经重新出现,一律拒绝而不是照做——绝不覆盖。这里不删任何东西。 4. **不出界**:默认只扫描数据文件夹与会话临时目录;DSH 的两份安装、桌面端用户数据目录与 pnpm 内容仓库是**探测**出来的、只读测量,并且在报告里写明位置。所有路径先经 `realpath` 规范化再比对,报告目录仍是唯一的可写位置,而唯一能触达文件管理器的那条路由会拒绝扫描没覆盖到的任何路径。 5. **默认保守**:不跟随符号链接;扫描有上限(50 万文件或 30 秒),到顶就标注 `truncated: true`,而不是把宿主跑死。 ### 符号链接与 junction 默认情况下,链接会被计数、记录,但**不会被穿过**。这是刻意的:在 pnpm 布局的 profile 里,`profiles/node_modules/*` 是指向全局 npm 目录的 junction,穿过去既出了 `$DSH_HOME`,又会把同一批字节算两遍(一遍经链接、一遍经真实目录)。开启 `followSymlinks: true` 后会跟随,但按真实路径去重,每个真实目录仍然只算一次。 ### 准确度 大小全部来自 `stat`,同一个文件不会被算两次。准确度有两条互相独立的验证: - 测试套件断言:扫描器的总计与对同一份 fixture 独立递归求和的结果**逐字节相等**,且数据区各类别之和等于数据区的总数。 - 在真实安装上与 PowerShell 自己的测量交叉比对——`Get-ChildItem -Recurse -File | Measure-Object -Sum Length`(排除 reparse point)。扫描器的**文件集合与字节总数完全一致**:双方都是 8,903 个文件、767,049,761 字节。 ## v0.1 的边界 **做**:只读统计、三个区并排、分类构成、Top N 明细、链接安装插件单列、Markdown + JSON 报告、9 个配置项、带颜色说明与「省空间指路」的右侧栏面板、能在文件管理器里定位文件夹的**打开**按钮(🟢 安全档默认可用,🟠 与 ⚪ 两档另需一道独立开关),以及**清理**——逐条预演、把绿色档移进自己的隔离目录、再一键撤销。 **不做**:任何删除,任何网络请求。清理一个字节都不会删——它只 rename、只拒绝覆盖、只碰 🟢 档;清空隔离目录是你自己在插件之外做的事。破坏性删除(`L3`)属于 v0.2+,会作为独立且需显式确认的层引入。 ## 开发 ``` npm test # 四套一起跑:locate、scan、plugin、client npm run test:locate # DSH 把东西放在哪:数据文件夹、安装目录、两个缓存 npm run test:scan # 只跑扫描器与报告器 npm run test:plugin # 入口模块,用桩替换 peer 依赖驱动 npm run test:client # 浏览器半边,同样用桩驱动 ``` `test/plugin.test.mjs` 会把 `@deepseek-ai/dsh-tools` 与 `@deepseek-ai/schemastery` 换成 `test/stubs/` 里的桩来加载 `lib/index.js`,因此**不需要装好的 DSH 宿主**就能跑。`test/locate.test.mjs` 则把环境变量、可执行文件路径与文件系统探针都做成可注入的,所以那些探测规则不依赖它恰好跑在哪台机器上。 除了这套测试,插件还对着真实的 `0.2.0-rc.2` 实现验证过:定义能被真实的 `defineTool` 编译;真实参数校验器会拒绝非法输入,真实输出校验器接受返回值;并且在真实 cordis 上下文里用真实的 `ToolRuntime` 服务端到端派发过——注册、投影 schema、走完整条策略流水线执行、并写出报告文件。装入 profile 仍是在某台具体宿主上的最后一道确认。 ### 目录结构 ``` lib/index.js 插件入口:Config、apply、disk_report 工具 lib/locate.js 这台机器上 DSH 把东西放在哪:数据文件夹、安装目录、两个系统缓存 lib/targets.js 区与类别表、条目枚举、工作区索引、清理归属规则 lib/scan.js 遍历、聚合、上限保护、inode 重叠探针 lib/cleanup.js 预演、移入隔离目录、清单与撤销 lib/report.js Markdown 与 JSON 渲染 lib/serve.js 报告路由、清理路由与打开文件夹的路由 ``` > `lib/targets.js` 里的 `projectKey()` 精确复刻了 DSH 0.2.0-rc.2 的会话目录命名算法,所以会话能显示出真实的工作区标题。 ## 许可证 MIT —— 见 [LICENSE](./LICENSE)。