dsh-disk-cleaner 1.3.0 → 1.4.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/README.md +28 -14
- package/lib/catalog.js +139 -6
- package/lib/client.js +8 -0
- package/lib/index.js +5 -7
- package/package.json +1 -1
- package/smoke.mjs +7 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<!-- badges -->
|
|
6
6
|
| | |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| **version** | `1.
|
|
8
|
+
| **version** | `1.4.0` |
|
|
9
9
|
| **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
|
|
10
10
|
| **license** | [MIT](LICENSE) |
|
|
11
11
|
| **Node** | `>= 22`(仅运行冒烟测试需要) |
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
**dsh-disk-cleaner** is a disk-space plugin for [DeepSeek Harness](https://github.com/deepseek-ai) (DSH). It does two different jobs, and keeps them apart on purpose:
|
|
18
18
|
|
|
19
|
-
1. **Clean** — twenty-
|
|
19
|
+
1. **Clean** — twenty-eight known-safe locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers, per-app caches and logs, superseded plugin versions and applied update packages). Only these are ever deleted.
|
|
20
20
|
2. **Assess** — everything the plugin will *not* delete (installed programs, the WSL virtual disk, the Windows component store, chat-app data, your own folders). Measured and listed, never removed.
|
|
21
21
|
|
|
22
22
|
The assessment ends with a **plan**: the same numbers ordered by what it costs you to act, with the exact Windows command for the steps the plugin cannot perform itself.
|
|
@@ -27,7 +27,7 @@ Both the panel and the agent reach the *same* operations: the cleaning logic liv
|
|
|
27
27
|
|
|
28
28
|
| Feature | Description |
|
|
29
29
|
| --- | --- |
|
|
30
|
-
| Scan | Measures all twenty-
|
|
30
|
+
| Scan | Measures all twenty-eight targets concurrently and shows what each occupies |
|
|
31
31
|
| Per-drive filter | Chip row of mounted volumes; scanning or cleaning can be limited to one drive, and rows with nothing on that drive disappear |
|
|
32
32
|
| Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup`) and a permanent legend, so "caution" never means "unspecified" |
|
|
33
33
|
| Aged deletion | `temp` and `windows-temp` only remove files older than one day; files a running program still holds are skipped |
|
|
@@ -70,6 +70,10 @@ Every path is built from this table — a caller selects an **id**, never a path
|
|
|
70
70
|
| `toolchain-snapshots` | duplicate snapshots under `%USERPROFILE%\.dsh\dsh-ops\bash-ipc`: folders whose `.ops-ipc.json` carries the same `patchedSha256` collapse to the newest | purge | caution | a folder without that metadata is never touched |
|
|
71
71
|
| `editor-server-bundles` | every bundle but the newest under `%USERPROFILE%\vscode-remote-wsl\stable` | purge | caution | the editor re-downloads the one it needs |
|
|
72
72
|
| `database-backups` | `zotero.sqlite*.bak` under `%USERPROFILE%\Zotero` | purge | caution | the live `zotero.sqlite` sits beside the copies |
|
|
73
|
+
| `editor-builds` | complete build trees under `%LOCALAPPDATA%\Programs\Microsoft VS Code` that `bin\code.cmd` does not name, plus `old_*` files whose original still sits beside them | purge | caution | no launcher, a launcher naming a missing build, or a lone build means nothing is dropped |
|
|
74
|
+
| `agent-app-caches` | `Cache` / `Code Cache` / `GPUCache` under `%USERPROFILE%\.workbuddy\app` and `\.workbuddy-ai\app` | purge | caution | the sibling `cache` folder holds spilled conversation text and is never a target |
|
|
75
|
+
| `rewind-snapshots` | `session-*` folders under `%USERPROFILE%\.dsh\rewind-snapshots` | aged, 30 days | caution | a recent session stays restorable |
|
|
76
|
+
| `rotated-logs` | `*.old.log` and `*.log.N` files in the `logs` folders of the agent apps | aged, 3 days | safe | nothing reads a rotated file back |
|
|
73
77
|
|
|
74
78
|
`aged` targets skip anything modified within the retention window; `purge` targets remove every entry. Both keep the target directory itself and leave locked entries alone, counting them as skipped.
|
|
75
79
|
|
|
@@ -147,6 +151,7 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
|
|
|
147
151
|
- Rules that pick "the newest of a group" fail closed: a family with only one member, a folder
|
|
148
152
|
whose metadata file is missing or whose hash is not a string, and a `vscode-remote-wsl\stable`
|
|
149
153
|
that holds a single bundle all resolve to *nothing to delete*. The newest member is never returned.
|
|
154
|
+
- `editor-builds` drops a build only when `bin\code.cmd` exists and names a different build that is also on disk; `old_x` goes only while `x` is still beside it.
|
|
150
155
|
- `database-backups` matches `.sqlite*.bak` by name only; the live `zotero.sqlite` is never a candidate.
|
|
151
156
|
- The directory Windows refuses to let an unelevated process write (`SoftwareDistribution\Download`) is labelled `elevation` and excluded from "reclaimable now". `C:\Windows\Temp` is **not** one of them on a default ACL: it grants `BUILTIN\Users` create and delete, so only EBUSY on a live file stops a delete there, which the age limit covers.
|
|
152
157
|
|
|
@@ -184,7 +189,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
|
|
|
184
189
|
### Development
|
|
185
190
|
|
|
186
191
|
```bash
|
|
187
|
-
node smoke.mjs #
|
|
192
|
+
node smoke.mjs # 68 assertions, zero dependencies
|
|
188
193
|
node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
|
|
189
194
|
```
|
|
190
195
|
|
|
@@ -209,6 +214,8 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
209
214
|
| Drive filter rejects unmounted and malformed volumes | `C:extra`, `Z:`, `abc` → 400 |
|
|
210
215
|
| Plan excludes elevation bytes from `reclaimNow` | Live audit against this machine |
|
|
211
216
|
| "Keep only the newest of a group" fails closed | Throwaway fixture tree: a same-hash duplicate collapses to the newest, a folder with no metadata is left alone, a different hash is kept, the live database survives (9/9) |
|
|
217
|
+
| Old editor builds need a launcher that proves which one runs | Throwaway fixture tree: a build the launcher does not name goes, no launcher deletes nothing, a launcher naming a missing build deletes nothing, a lone build stays, `old_x` goes only while `x` exists (7/7) |
|
|
218
|
+
| A dry run reports what a real run frees | The 30-day rewind window reports 263 KB not 283 MB, and scan matches the dry run |
|
|
212
219
|
| Visual layout | Checked by hand in the running Web UI. No browser automation here, so this one is a human check, not a test. |
|
|
213
220
|
|
|
214
221
|
### Implementation notes
|
|
@@ -234,15 +241,15 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
234
241
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
235
242
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
236
243
|
├─ lib/index.js (411) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
|
|
237
|
-
├─ lib/catalog.js (
|
|
244
|
+
├─ lib/catalog.js (969) The 28 targets, measurePath / purgePath / resolveTargets / drives
|
|
238
245
|
├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
|
|
239
246
|
├─ lib/plan.js (150) Pure: measurements → ordered steps
|
|
240
|
-
├─ lib/client.js (
|
|
247
|
+
├─ lib/client.js (1379) Client half: slots, panel, preferences, zh/en dictionaries, CSS
|
|
241
248
|
├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
|
|
242
249
|
├─ icon.svg Plugin icon
|
|
243
250
|
├─ README.md This file
|
|
244
251
|
├─ LICENSE MIT
|
|
245
|
-
└─ smoke.mjs
|
|
252
|
+
└─ smoke.mjs 68 assertions, zero dependencies
|
|
246
253
|
```
|
|
247
254
|
|
|
248
255
|
Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode), `main` (keyed by the package name), `settings.section` (order 30, disposed in sidebar-only mode), `plugins.bundle.config` (keyed by the package name).
|
|
@@ -254,7 +261,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
254
261
|
|
|
255
262
|
**dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
|
|
256
263
|
|
|
257
|
-
1. **清理** ——
|
|
264
|
+
1. **清理** —— 二十八个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
|
|
258
265
|
2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
|
|
259
266
|
|
|
260
267
|
评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
|
|
@@ -265,7 +272,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
265
272
|
|
|
266
273
|
| 功能 | 说明 |
|
|
267
274
|
| --- | --- |
|
|
268
|
-
| 扫描 |
|
|
275
|
+
| 扫描 | 并发测量全部二十八个目标,显示各自占用 |
|
|
269
276
|
| 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
|
|
270
277
|
| 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup`),图例常驻,所以「注意」永远不是「说不清」 |
|
|
271
278
|
| 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
|
|
@@ -308,6 +315,10 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
308
315
|
| `toolchain-snapshots` | `%USERPROFILE%\.dsh\dsh-ops\bash-ipc` 下重复的快照:`.ops-ipc.json` 里 `patchedSha256` 相同的目录只留最新一份 | 全清 | 注意 | 没有该元数据的目录永不触碰 |
|
|
309
316
|
| `editor-server-bundles` | `%USERPROFILE%\vscode-remote-wsl\stable` 下除最新以外的服务包 | 全清 | 注意 | 编辑器需要时会重新下载 |
|
|
310
317
|
| `database-backups` | `%USERPROFILE%\Zotero` 下的 `zotero.sqlite*.bak` | 全清 | 注意 | 正在用的 `zotero.sqlite` 就在副本旁边 |
|
|
318
|
+
| `editor-builds` | `%LOCALAPPDATA%\Programs\Microsoft VS Code` 下 `bin\code.cmd` 未指向的完整构建目录,以及原件仍在旁边的 `old_*` 文件 | 全清 | 注意 | 没有启动器、启动器指向的构建不在磁盘上、或只有一份构建时,一律不删 |
|
|
319
|
+
| `agent-app-caches` | `%USERPROFILE%\.workbuddy\app` 与 `\.workbuddy-ai\app` 下的 `Cache` / `Code Cache` / `GPUCache` | 全清 | 注意 | 同级的 `cache` 目录存的是对话溢写内容,从不作为目标 |
|
|
320
|
+
| `rewind-snapshots` | `%USERPROFILE%\.dsh\rewind-snapshots` 下的 `session-*` 目录 | 保留期 30 天 | 注意 | 最近的会话仍然可以回滚 |
|
|
321
|
+
| `rotated-logs` | 各 agent 应用 `logs` 目录下的 `*.old.log` 与 `*.log.N` 文件 | 保留期 3 天 | 安全 | 轮转掉的日志不会被读回 |
|
|
311
322
|
|
|
312
323
|
保留期方式会跳过保留期内修改过的文件;全清方式清掉全部条目。两者都保留目标目录本身,并把锁定或无权限的条目跳过计数。
|
|
313
324
|
|
|
@@ -323,8 +334,8 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
323
334
|
| `reclaim-caution` | 注意目标共 ≥200 MB(不含需提权的) | 是,一个按钮 |
|
|
324
335
|
| `wsl-compact` | WSL 映像 ≥1 GB | 否 —— `wsl --shutdown` + `diskpart` 的 `compact vdisk` |
|
|
325
336
|
| `winsxs-cleanup` | WinSxS ≥1 GB | 否 —— `Dism.exe /Online /Cleanup-Image /StartComponentCleanup` |
|
|
326
|
-
| `elevation-clean` | 需提权目标 ≥200 MB | 否 —— 以管理员身份运行本插件 |
|
|
327
337
|
| `hibernate-off` | `system-files` ≥200 MB | 否 —— `powercfg.exe /hibernate off` |
|
|
338
|
+
| `elevation-clean` | 需提权目标 ≥200 MB | 否 —— 以管理员身份运行本插件 |
|
|
328
339
|
| `app-review` | 单个应用数据目录 ≥1 GB,取前 3 个且未被其它步骤覆盖 | 否 —— 走卸载,绝不删目录 |
|
|
329
340
|
| `move-folder` | 个人文件夹 ≥2 GB,且另有盘有空间 | 否 —— 属性 → 位置 |
|
|
330
341
|
| `pressure` | 系统盘可用 < 12% | 否 —— 作为上述步骤的背景 |
|
|
@@ -384,6 +395,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
384
395
|
- 保留目标目录本身,只清内容。
|
|
385
396
|
- 「只留最新一份」这类规则一律选保守的一侧:族内只有一个成员、元数据文件缺失或哈希不是字符串、
|
|
386
397
|
`vscode-remote-wsl\stable` 下只有一个服务包,结果都是*没有可删项*。最新那一份永远不会被返回。
|
|
398
|
+
- `editor-builds` 只有在 `bin\code.cmd` 存在且指向另一份确实在磁盘上的构建时才删;`old_x` 也只有在 `x` 仍在旁边时才删。
|
|
387
399
|
- `database-backups` 只按名字匹配 `.sqlite*.bak`,正在用的 `zotero.sqlite` 永不成为候选。
|
|
388
400
|
- 那个拒绝普通权限写入的 Windows 目录标为 `elevation`,并排除在「现在能回收」之外。
|
|
389
401
|
|
|
@@ -425,7 +437,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
|
|
|
425
437
|
### 开发
|
|
426
438
|
|
|
427
439
|
```bash
|
|
428
|
-
node smoke.mjs #
|
|
440
|
+
node smoke.mjs # 68 条断言,零依赖
|
|
429
441
|
node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
|
|
430
442
|
```
|
|
431
443
|
|
|
@@ -450,6 +462,8 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
450
462
|
| 盘过滤拒绝未挂载与畸形卷名 | `C:extra`、`Z:`、`abc` → 400 |
|
|
451
463
|
| 方案不把提权目标的字节算进「现在能回收」 | 在本机跑真实 audit |
|
|
452
464
|
| 「只留最新一份」一律选保守一侧 | 一次性 fixture 目录:同哈希的重复项收敛到最新、无元数据的目录不动、不同哈希保留、正在用的数据库完好(9/9) |
|
|
465
|
+
| 旧版编辑器构建要有启动器作证才删 | 一次性 fixture 目录:启动器未指向的构建会删、没有启动器一律不删、启动器指向的构建不在磁盘上也不删、只有一份构建时不删、`old_x` 只在 `x` 仍在时删(7/7) |
|
|
466
|
+
| 预演报告的字节数等于真删能释放的字节数 | 30 天的回滚窗口报 263 KB 而不是 283 MB,且扫描结果与预演一致 |
|
|
453
467
|
| 视觉排版 | 在运行中的 Web UI 里手工确认过。本环境没有浏览器自动化,所以这条是人眼检查,不是测试。 |
|
|
454
468
|
|
|
455
469
|
### 实现要点
|
|
@@ -475,15 +489,15 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
475
489
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
476
490
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
477
491
|
├─ lib/index.js (411) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
|
|
478
|
-
├─ lib/catalog.js (
|
|
492
|
+
├─ lib/catalog.js (969) 28 个目标,measurePath / purgePath / resolveTargets / drives
|
|
479
493
|
├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
|
|
480
494
|
├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
|
|
481
|
-
├─ lib/client.js (
|
|
495
|
+
├─ lib/client.js (1379) Client 半面:slot、面板、偏好、zh/en 字典、CSS
|
|
482
496
|
├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
|
|
483
497
|
├─ icon.svg 插件图标
|
|
484
498
|
├─ README.md 本文件
|
|
485
499
|
├─ LICENSE MIT
|
|
486
|
-
└─ smoke.mjs
|
|
500
|
+
└─ smoke.mjs 68 条断言,零依赖
|
|
487
501
|
```
|
|
488
502
|
|
|
489
503
|
Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
|
package/lib/catalog.js
CHANGED
|
@@ -369,6 +369,93 @@ const keepNewestChild = (parent) => {
|
|
|
369
369
|
return stamped.slice(1).map((item) => item.abs);
|
|
370
370
|
};
|
|
371
371
|
|
|
372
|
+
/**
|
|
373
|
+
* An editor that ships a whole runtime per commit leaves the previous one behind after every
|
|
374
|
+
* update: VS Code kept two complete 764 MB trees plus `old_Code.exe`. Unlike a version holder,
|
|
375
|
+
* the tree names are commit hashes, so the newest mtime proves nothing — the launcher does.
|
|
376
|
+
* `bin\code.cmd` names the live hash, and a build is only dropped when that launcher exists,
|
|
377
|
+
* parses, and points at a different build that is also present. No launcher, no deletion.
|
|
378
|
+
*
|
|
379
|
+
* The renamed `old_*` copies are dead weight only while the file they duplicate still sits
|
|
380
|
+
* beside them; a lone `old_x` may be the only copy left, so it stays.
|
|
381
|
+
*/
|
|
382
|
+
const vscodeSuperseded = () => {
|
|
383
|
+
const appDir = path.join(env('LOCALAPPDATA') ?? '', 'Programs', 'Microsoft VS Code');
|
|
384
|
+
if (!isDir(appDir)) return [];
|
|
385
|
+
const out = [];
|
|
386
|
+
let live = '';
|
|
387
|
+
try {
|
|
388
|
+
const launcher = fs.readFileSync(path.join(appDir, 'bin', 'code.cmd'), 'utf8');
|
|
389
|
+
const match = /[\\/]([0-9a-f]{10,})[\\/]/.exec(launcher);
|
|
390
|
+
if (match !== null) live = match[1];
|
|
391
|
+
} catch {
|
|
392
|
+
/* no launcher means no evidence about which build runs, so nothing is dropped */
|
|
393
|
+
}
|
|
394
|
+
if (live !== '') {
|
|
395
|
+
const builds = listDir(appDir)
|
|
396
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
397
|
+
.map((entry) => entry.name)
|
|
398
|
+
.filter((name) => /^[0-9a-f]{10,}$/.test(name));
|
|
399
|
+
if (builds.length >= 2 && builds.includes(live)) {
|
|
400
|
+
for (const name of builds) if (name !== live) out.push(path.join(appDir, name));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const dir of [appDir, path.join(appDir, 'bin')]) {
|
|
404
|
+
for (const entry of listDir(dir)) {
|
|
405
|
+
if (!entry.isFile() || entry.isSymbolicLink()) continue;
|
|
406
|
+
const match = /^old_(.+)$/.exec(entry.name);
|
|
407
|
+
if (match === null) continue;
|
|
408
|
+
if (!isFile(path.join(dir, match[1]))) continue;
|
|
409
|
+
out.push(path.join(dir, entry.name));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The Chromium caches an Electron-based agent app writes under its own app folder. The root
|
|
417
|
+
* is spelled out per app because the sibling `cache` next to it (`~/.workbuddy/cache`) holds
|
|
418
|
+
* spilled conversation text rather than anything regenerable — it stays.
|
|
419
|
+
*/
|
|
420
|
+
const agentAppCaches = () => {
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const name of ['.workbuddy', '.workbuddy-ai']) {
|
|
423
|
+
const app = path.join(home(), name, 'app');
|
|
424
|
+
out.push(...subdirs(path.join(app, 'session'), ['Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'ShaderCache']));
|
|
425
|
+
out.push(...subdirs(app, ['cache', 'CodeCache']));
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Rewind snapshots this harness writes one folder per session, never pruning. The folders are
|
|
432
|
+
* named by session id, so age is the only signal; the target's 30-day floor keeps a recent
|
|
433
|
+
* session restorable.
|
|
434
|
+
*/
|
|
435
|
+
const rewindSnapshots = () => {
|
|
436
|
+
const root = path.join(home(), '.dsh', 'rewind-snapshots');
|
|
437
|
+
return listDir(root)
|
|
438
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
439
|
+
.map((entry) => path.join(root, entry.name));
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* A log file rotated out of the way — `daemon.old.log`, `main.log.1` — as opposed to the
|
|
444
|
+
* day-named folders the `agent-logs` target covers. Nothing reads a rotated file at runtime.
|
|
445
|
+
*/
|
|
446
|
+
const rotatedLogs = () => {
|
|
447
|
+
const out = [];
|
|
448
|
+
for (const name of ['.workbuddy', '.workbuddy-ai', '.qoder', '.zcode', '.codebuddy']) {
|
|
449
|
+
const logs = path.join(home(), name, 'logs');
|
|
450
|
+
for (const entry of listDir(logs)) {
|
|
451
|
+
if (!entry.isFile() || entry.isSymbolicLink()) continue;
|
|
452
|
+
if (!/\.(old\.log|log\.[0-9]+)$/i.test(entry.name)) continue;
|
|
453
|
+
out.push(path.join(logs, entry.name));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
};
|
|
458
|
+
|
|
372
459
|
/** The one-generation database copies Zotero writes beside the library it is using. */
|
|
373
460
|
const zoteroBackups = () => {
|
|
374
461
|
const data = path.join(home(), 'Zotero');
|
|
@@ -585,6 +672,39 @@ const CATALOG = [
|
|
|
585
672
|
roots: ['USERPROFILE'],
|
|
586
673
|
paths: () => zoteroBackups(),
|
|
587
674
|
},
|
|
675
|
+
{
|
|
676
|
+
id: 'editor-builds',
|
|
677
|
+
kind: 'purge',
|
|
678
|
+
risk: 'caution',
|
|
679
|
+
note: 'superseded',
|
|
680
|
+
roots: ['LOCALAPPDATA'],
|
|
681
|
+
paths: () => vscodeSuperseded(),
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
id: 'agent-app-caches',
|
|
685
|
+
kind: 'purge',
|
|
686
|
+
risk: 'caution',
|
|
687
|
+
note: 'rebuild',
|
|
688
|
+
roots: ['USERPROFILE'],
|
|
689
|
+
paths: () => agentAppCaches(),
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
id: 'rewind-snapshots',
|
|
693
|
+
kind: 'aged',
|
|
694
|
+
minAgeDays: 30,
|
|
695
|
+
risk: 'caution',
|
|
696
|
+
note: 'history',
|
|
697
|
+
roots: ['USERPROFILE'],
|
|
698
|
+
paths: () => rewindSnapshots(),
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
id: 'rotated-logs',
|
|
702
|
+
kind: 'aged',
|
|
703
|
+
minAgeDays: 3,
|
|
704
|
+
risk: 'safe',
|
|
705
|
+
roots: ['USERPROFILE'],
|
|
706
|
+
paths: () => rotatedLogs(),
|
|
707
|
+
},
|
|
588
708
|
];
|
|
589
709
|
|
|
590
710
|
const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
|
|
@@ -654,10 +774,16 @@ export function resolveTargets(only, drive) {
|
|
|
654
774
|
});
|
|
655
775
|
}
|
|
656
776
|
|
|
657
|
-
|
|
777
|
+
/**
|
|
778
|
+
* Total the files under `abs`. With a `cutoff`, only files older than it count, so a dry run
|
|
779
|
+
* reports what a real run would actually delete instead of the whole folder.
|
|
780
|
+
*/
|
|
781
|
+
export async function measurePath(abs, cutoff) {
|
|
658
782
|
if (isFile(abs)) {
|
|
659
783
|
try {
|
|
660
|
-
|
|
784
|
+
const stat = fs.statSync(abs);
|
|
785
|
+
if (cutoff !== undefined && stat.mtimeMs > cutoff) return { bytes: 0, files: 0 };
|
|
786
|
+
return { bytes: stat.size, files: 1 };
|
|
661
787
|
} catch {
|
|
662
788
|
return { bytes: 0, files: 0 };
|
|
663
789
|
}
|
|
@@ -684,7 +810,9 @@ export async function measurePath(abs) {
|
|
|
684
810
|
}
|
|
685
811
|
if (!entry.isFile()) continue;
|
|
686
812
|
try {
|
|
687
|
-
|
|
813
|
+
const stat = await fsp.stat(path.join(dir, entry.name));
|
|
814
|
+
if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
|
|
815
|
+
bytes += stat.size;
|
|
688
816
|
files += 1;
|
|
689
817
|
} catch {
|
|
690
818
|
/* a file that vanished or is locked contributes nothing */
|
|
@@ -697,15 +825,20 @@ export async function measurePath(abs) {
|
|
|
697
825
|
return { bytes, files };
|
|
698
826
|
}
|
|
699
827
|
|
|
700
|
-
/**
|
|
828
|
+
/**
|
|
829
|
+
* Measure every resolved target concurrently, keeping the host event loop free. Each target's
|
|
830
|
+
* own age limit is applied, so the numbers answer "what would a clean free right now" rather
|
|
831
|
+
* than "how much is in the folder" — the two differ for every `aged` target.
|
|
832
|
+
*/
|
|
701
833
|
export async function scan(only, drive) {
|
|
702
834
|
const targets = resolveTargets(only, drive);
|
|
703
835
|
const measured = await Promise.all(
|
|
704
836
|
targets.map(async (target) => {
|
|
837
|
+
const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
|
|
705
838
|
let bytes = 0;
|
|
706
839
|
let files = 0;
|
|
707
840
|
for (const abs of target.paths) {
|
|
708
|
-
const size = await measurePath(abs);
|
|
841
|
+
const size = await measurePath(abs, cutoff);
|
|
709
842
|
bytes += size.bytes;
|
|
710
843
|
files += size.files;
|
|
711
844
|
}
|
|
@@ -776,7 +909,7 @@ export async function clean(ids, options = {}) {
|
|
|
776
909
|
let bytes = 0;
|
|
777
910
|
let files = 0;
|
|
778
911
|
for (const abs of target.paths) {
|
|
779
|
-
const size = await measurePath(abs);
|
|
912
|
+
const size = await measurePath(abs, cutoff);
|
|
780
913
|
bytes += size.bytes;
|
|
781
914
|
files += size.files;
|
|
782
915
|
}
|
package/lib/client.js
CHANGED
|
@@ -74,6 +74,10 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
'target_toolchain-snapshots': '重复的工具链快照',
|
|
75
75
|
'target_editor-server-bundles': '多余的编辑器服务包',
|
|
76
76
|
'target_database-backups': '数据库备份副本',
|
|
77
|
+
'target_editor-builds': '编辑器的旧版构建',
|
|
78
|
+
'target_agent-app-caches': 'AI 应用的浏览器缓存',
|
|
79
|
+
'target_rewind-snapshots': '过期的回滚快照',
|
|
80
|
+
'target_rotated-logs': '轮转掉的日志',
|
|
77
81
|
auditTitle: '空间评估(本插件不会删除)',
|
|
78
82
|
auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
|
|
79
83
|
auditRun: '开始评估',
|
|
@@ -226,6 +230,10 @@ window.__ModuleLoader__.load({
|
|
|
226
230
|
'target_toolchain-snapshots': 'Duplicate toolchain snapshots',
|
|
227
231
|
'target_editor-server-bundles': 'Extra editor server bundles',
|
|
228
232
|
'target_database-backups': 'Database backup copies',
|
|
233
|
+
'target_editor-builds': 'Old editor builds',
|
|
234
|
+
'target_agent-app-caches': 'Browser caches of the AI apps',
|
|
235
|
+
'target_rewind-snapshots': 'Expired rewind snapshots',
|
|
236
|
+
'target_rotated-logs': 'Rotated-out log files',
|
|
229
237
|
auditTitle: 'Space assessment (this plugin deletes none of it)',
|
|
230
238
|
auditHint: 'What actually takes up room outside caches: installed programs, WSL disk images, the Windows component store, and your own files. None of it is removed here; it is listed so you can decide.',
|
|
231
239
|
auditRun: 'Run assessment',
|
package/lib/index.js
CHANGED
|
@@ -279,8 +279,10 @@ export function apply(ctx) {
|
|
|
279
279
|
'Inspect and reclaim disk space on this Windows machine from known-safe cache locations only: ' +
|
|
280
280
|
'temp directories, package manager caches (npm, pnpm, pip, uv), crash dumps, browser and editor ' +
|
|
281
281
|
'caches, the Windows internet cache, per-app caches, logs and scratch folders under %APPDATA%, ' +
|
|
282
|
-
'superseded plugin versions and applied update packages,
|
|
283
|
-
'duplicate toolchain copies and database backups under the user profile
|
|
282
|
+
'superseded plugin versions and applied update packages, the backup snapshots, dated logs, ' +
|
|
283
|
+
'rotated-out log files, duplicate toolchain copies and database backups under the user profile, ' +
|
|
284
|
+
'editor builds their launcher no longer names, and the Chromium caches of the agent apps. ' +
|
|
285
|
+
'Call with action "overview" to list drives and target ids, ' +
|
|
284
286
|
'"scan" to measure them, and "clean" to delete. clean without confirm: true is a dry run that only ' +
|
|
285
287
|
'reports what it would free, so the user can approve the amount before anything is removed. ' +
|
|
286
288
|
'Action "audit" instead assesses the space that CANNOT be cleaned automatically — installed programs, ' +
|
|
@@ -364,11 +366,7 @@ export function apply(ctx) {
|
|
|
364
366
|
}
|
|
365
367
|
if (action === 'scan') {
|
|
366
368
|
const measured = await scan(readIds(args.ids), readDrive(args.drive));
|
|
367
|
-
const lines = measured.map((target) => {
|
|
368
|
-
// scan measures everything present; a retention limit means clean frees less.
|
|
369
|
-
const limit = target.minAgeDays > 0 ? ` (only files older than ${target.minAgeDays} day(s) go)` : '';
|
|
370
|
-
return `${target.id}: ${formatBytes(target.bytes)} in ${target.files} file(s)${limit}`;
|
|
371
|
-
});
|
|
369
|
+
const lines = measured.map((target) => `${target.id}: ${formatBytes(target.bytes)} in ${target.files} file(s)`);
|
|
372
370
|
const total = measured.reduce((sum, target) => sum + target.bytes, 0);
|
|
373
371
|
const notes = [...new Set(measured.map((target) => target.note).filter(Boolean))];
|
|
374
372
|
const meaning = notes.map((note) => `${note}: ${NOTE_MEANING[note] ?? note}`).join('\n');
|
package/package.json
CHANGED
package/smoke.mjs
CHANGED
|
@@ -262,13 +262,19 @@ check('tool description warns audit is read-only', /read-only: it never removes
|
|
|
262
262
|
check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
|
|
263
263
|
|
|
264
264
|
const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
|
|
265
|
-
check('catalog has the documented
|
|
265
|
+
check('catalog has the documented 28 targets', ids.length === 28, `got ${ids.length}`);
|
|
266
266
|
check('every catalog id has a client label', ids.every((id) => zhKeys.includes(`target_${id}`) && enKeys.includes(`target_${id}`)));
|
|
267
267
|
check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded', 'why_history', 'why_duplicate', 'why_database-backup'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
|
|
268
268
|
const areaIds = [...auditSrc.matchAll(/^\s{6}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
|
|
269
269
|
check('every assessment area has a translated label and reason', areaIds.length > 0 && areaIds.every((id) => zhKeys.includes(`area_${id}`) && zhKeys.includes(`area_${id}_why`) && enKeys.includes(`area_${id}`) && enKeys.includes(`area_${id}_why`)), `areas: ${areaIds.join(',')}`);
|
|
270
270
|
check('a reveal request names an area, never a path', index.includes("action === 'reveal'") && auditSrc.includes('export function areaPaths') && !/revealPath[\s\S]{0,200}payload\.path/.test(index));
|
|
271
271
|
check('no caller may pass a raw path', !/parameters[\s\S]{0,400}paths/.test(index) && catalog.includes('Callers select catalog ids'));
|
|
272
|
+
check(
|
|
273
|
+
'a dry run reports what a real run would delete, not the folder size',
|
|
274
|
+
catalog.includes('export async function measurePath(abs, cutoff)') &&
|
|
275
|
+
(catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length === 2,
|
|
276
|
+
`${(catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length} cutoff-aware call sites`,
|
|
277
|
+
);
|
|
272
278
|
|
|
273
279
|
const codes = [...new Set([...plan.matchAll(/code: '([a-z-]+)'/g)].map((m) => m[1]))];
|
|
274
280
|
check('plan emits the documented 9 step codes', codes.length === 9, `got ${codes.length}`);
|