dsh-recall-plugin 2.4.12 → 2.4.14

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/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.4.14] - 2026-10-18
6
+
7
+ ### 修复
8
+
9
+ - **启动后单核 CPU 打满、进程表出现数十个 pwsh(issue #24)——三重根因一并修复**:① 启动预热对每个工作区并发跑一条命令链(7 工作区 × 6~8 条命令 ≈ 40~56 个 pwsh 冷启动),而每个恢复会话的 `init` 端点还会再跑一遍同款链;② 快照只有单文件上限(原 100MB),挡不住「大量小文件」——报告者的下载目录工作区 321 个 ≤100MB 文件合计 1.05GB 全部进快照;③ 快照的 `add -A` 无总量上限、无扫描时间预算。
10
+ - **预热与 init 共用根就绪链(in-flight 去重)+ 预热并发限流 2 路**:同一工作区在本进程内只跑一次 `resolveStore/ensureGit/loadIndex/rebuildOrphans/recover`,多工作区预热不再同时起数十条 pwsh。
11
+ - **快照前置护栏:总量上限 + 扫描时间预算**:脚本在 `add -A` **之前**遍历工作区统计「实际会进快照的文件总量」(只计 ≤`maxFileBytes` 的文件,防单个大视频误判)与耗时,超过 `maxWorkspaceBytes`(默认 256MB)或 `scanBudgetMs`(默认 10s)即输出 `SNAP_LIMIT` 并**停用本次快照**——只停用不截断:截断快照回退时会按 index 清单误删未纳入的文件。Host 侧解析标记后短路该工作区后续快照(配置变化或 10 分钟 TTL 自动重试),并在最近消息上提示可行动的恢复方式(调大上限 / 补充排除表)。
12
+ - **单文件上限默认 100MB → 8MB**(`maxFileBytes`):与总量护栏配合,避免大文件首拍把对象库撑到 GB 级;含素材/模型的正常项目可按需调高。
13
+
14
+ ### 新增
15
+
16
+ - 配置项 `maxWorkspaceBytes`(默认 256MB)与 `scanBudgetMs`(默认 10s),0 = 不限制;设置页「插件配置」卡片可直接修改(热生效)。负数按非法回退默认——显式关闭只认 0。
17
+ - `snapFeedback.kind` 新增 `workspaceLimit`:`snapshot-info` 对停用的工作区下发可行动文案(客户端 toast 直显)。
18
+
19
+ ### 文档
20
+
21
+ - README 双语配置表补两项新字段与 8MB 默认值;AGENTS.md 核心机制补护栏语义;`cordis.patch.yml` 默认配置同步。
22
+
23
+ ## [2.4.13] - 2026-10-17
24
+
25
+ ### 兼容
26
+
27
+ - **dsh 0.2.1-rc.2 兼容(零破坏核验,无代码改动)**(升级核查见 [docs/compat-audit.md](docs/compat-audit.md) 头部 0.2.1-rc.2 段):rc.2 自身只有模型适配(DeepSeek-V4.1-Flash-1017)与 DeepSeek Search/Fetch API 两条新特性;跨 alpha.5 / rc.1 的累计变更中,rc.1 汇总版 Breaking(输入区 stats 拆分、移除 `invariant`、移除工具展示 `both`、Agent Team inbox 直投、API Key 优先级、子路径 `package.json`)均不作用于本插件。34 个关键包 `lib/` 逐字节 diff 仅 3 处非功能漂移(`dsh-client-modules` commit 字面量、`dsh-cordis-client-runner` slot 教学目录、`dsh-api-remotes` 账号 bonus schema);`sessions.fork`、slot 定义方、设置面、兼容门禁全部零漂移。
28
+ - **实装核验**:全局升级 `@deepseek-ai/dsh@0.2.1-rc.2` 后 `test:probe` **54/54**、`verify:host` 装配断言全过(inject=shell,sessions,agents、端点 13 项)、`check:dsh` 全绿(六条 `@deepseek-ai/dsh-*` peer 对 rc.2 全满足);`dsh.compatibility.dshReleases` 补 `0.2.1-alpha.5` / `0.2.1-rc.1` / `0.2.1-rc.2` 三项。
29
+
30
+ ### 文档
31
+
32
+ - `docs/reference/` 镜像按 tag `dsh-v0.2.1-rc.2` 九次重拉:13 源全部逐字节相同(首个零差异轮次),仅更新头部「归档日期 / 归档 dsh 版本」两字段。
33
+ - `docs/dsh-contract.md`(版本段 + 沿革 ⑩)/ `docs/compat-audit.md`(新增 0.2.1-rc.2 核验段)/ 新增 [docs/upgrade-assessments/dsh-0.2.1-rc.2.md](docs/upgrade-assessments/dsh-0.2.1-rc.2.md) 与 [docs/upgrade-assessments/dsh-0.2.1-rc.1.md](docs/upgrade-assessments/dsh-0.2.1-rc.1.md) / README 双语兼容范围与徽章同步至 0.2.1-rc.2。
34
+
5
35
  ## [2.4.12] - 2026-10-16
6
36
 
7
37
  ### 修复
package/README.en.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue)
8
8
  ![Build](https://img.shields.io/badge/pure%20JS-green)
9
9
 
10
- [![DSH](https://img.shields.io/badge/DSH-0.2.1--alpha.4-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-alpha.4)
10
+ [![DSH](https://img.shields.io/badge/DSH-0.2.1--rc.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-rc.2)
11
11
  ![DSH](https://img.shields.io/badge/DSH-Desktop-blue)
12
12
 
13
13
  Under any message you've sent, click "↶ Recall" — **your workspace files and the conversation history roll back to just before that message was sent**.
@@ -27,7 +27,7 @@ As each message is sent, the workspace is first snapshotted into an independent
27
27
  - [Snapshot Maintenance & Cleanup](#snapshot-maintenance--cleanup)
28
28
  - [How It Works](#how-it-works)
29
29
  - [Event Contract (for same-host plugins)](#event-contract-for-same-host-plugins)
30
- - [Local Development (without publishing)](#local-development-without-publishing)
30
+ - [Contributing](#contributing)
31
31
  - [License](#license)
32
32
 
33
33
  ## UI Preview
@@ -73,7 +73,7 @@ Prerequisites:
73
73
 
74
74
  - git CLI: without it the recall button won't appear (a notice shows at the top of the page); DSH itself keeps running.
75
75
  - Shell: PowerShell 5.1 / 7 on Windows; bash on Linux/macOS.
76
- - DSH version: `0.1.2-alpha.1` through `0.2.1-alpha.4`; later versions within a verified minor line are admitted automatically, while unverified new minor lines are blocked by the startup compatibility gate (window mechanics in the fold below).
76
+ - DSH version: `0.1.2-alpha.1` through `0.2.1-rc.2`; later versions within a verified minor line are admitted automatically, while unverified new minor lines are blocked by the startup compatibility gate (window mechanics in the fold below).
77
77
  - **0.1.7-alpha.1 is a breaking release** (the shell execution and settings interfaces changed); the plugin ships both seams side by side — the same release works on every 0.1.2–0.1.6 line and on 0.1.7+, with unchanged behaviour on older DSH versions.
78
78
  - `0.1.1-rc.2` and earlier are not supported: their client runtime lacks the `sessions`/`workspaces`/`uiWorkspace` services, so the plugin UI silently fails to render.
79
79
 
@@ -137,7 +137,9 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
137
137
  | --- | --- | --- |
138
138
  | `gcSnaps` | 50 | Run `git gc` after this many snapshots accumulate (env `DSH_RECALL_GC_SNAPS` force-overrides) |
139
139
  | `gcHours` | 24 | Run gc when this many hours have passed since the last one (whichever trigger fires first; env `DSH_RECALL_GC_HOURS`) |
140
- | `maxFileBytes` | 104857600 (100MB) | Files larger than this are neither snapshotted nor touched by recalls |
140
+ | `maxFileBytes` | 8388608 (8MB) | Files larger than this are neither snapshotted nor touched by recalls |
141
+ | `maxWorkspaceBytes` | 268435456 (256MB) | Total snapshot-eligible file size per workspace. Exceeding it **disables snapshots for that workspace** (no truncation — a truncated snapshot would make rollback delete the skipped files) and shows a notice on recent messages; raise the limit or add exclusions to recover automatically. 0 = unlimited |
142
+ | `scanBudgetMs` | 10000 (10s) | Time budget for the pre-snapshot file scan. Exceeding it skips the snapshot with a notice (guards against pathological directories stalling startup). 0 = unlimited |
141
143
  | `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
142
144
  | `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
143
145
  | `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/`, `target/`, `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.nuxt/`, `.output/`, `.cache/`, `.gradle/`, `*.exe`, `*.dll`, `*.pdb`, `*.so`, `*.dylib`, `*.msi`, `*.zip`, `*.7z`, `*.rar`, `*.tar`, `*.tar.gz`, `*.iso` | Base exclusion list (gitignore syntax, lower priority than exclude.txt); excluded large directories are skipped as whole subtrees during snapshot scans. Directory-form entries (e.g. `target/`) mean one more thing: **when a path segment of the workspace root itself matches, snapshots are disabled for that workspace** — patterns are relative to the root, so they can never exclude the root itself (i.e. a session opened inside a build-output directory), and such directories have no rollback value anyway; remove the entry to re-enable |
@@ -245,9 +247,26 @@ Versioning: shipped as a stable public contract from day one (not experimental).
245
247
 
246
248
  Version skew: an old plugin frontend (<2.4.8) never reports, so events never fire; a new frontend with an old host (<2.4.8) gets a missing endpoint and silently ignores it — recall itself is unaffected.
247
249
 
248
- ## Local Development (without publishing)
250
+ ## Contributing
249
251
 
250
- Point the profile's dependency for this package at your clone via `link:`; DSH loads the built `lib/` artifacts from the workspace (source lives in `src/`), so run `npm run build` after editing `src/`, then restart DSH — no copying or publishing needed:
252
+ ### Prerequisites
253
+
254
+ - Node.js ≥ 20 (develop on Windows, Linux or macOS), git CLI;
255
+ - A local DSH installation (≥ 0.1.2-alpha.1): `npm run test:probe` and `npm run verify:host` need a real dsh; without one the probes skip themselves, which does not block the rest of development and testing.
256
+
257
+ ### Local iteration (no publishing needed)
258
+
259
+ Clone and build once first (peer dependencies are private `@deepseek-ai/*` packages; `--legacy-peer-deps` skips them and installs only devDependencies — the build and test toolchain):
260
+
261
+ ```powershell
262
+ git clone https://github.com/limbo947/dsh-recall-plugin.git
263
+ cd dsh-recall-plugin
264
+ npm install --legacy-peer-deps # install the build/test toolchain (esbuild, vitest, typescript, ...)
265
+ npm run build # produce the lib/ artifacts (what link mode loads)
266
+ npm test # optional: run the unit suite once to verify the baseline
267
+ ```
268
+
269
+ Then point the profile's dependency for this package at your clone via `link:`; DSH loads the built `lib/` artifacts from the workspace (source lives in `src/`), so run `npm run build` after editing `src/`, then restart DSH — no copying or publishing needed:
251
270
 
252
271
  ```powershell
253
272
  # 1. Edit $env:USERPROFILE\.dsh\profiles\web\package.json:
@@ -259,17 +278,27 @@ pnpm install
259
278
  # 3. Restart DSH and hard-refresh the page (Ctrl+Shift+R)
260
279
  ```
261
280
 
262
- Note: all source lives in `src/` (host in `src/host/`, browser side in `src/client/`, shared types in `src/types/`); `lib/` is a pure build-output directory — `npm run build` generates it via esbuild (per-file host transpilation plus the `lib/client.js` bundle), and the artifacts are committed with the source. **You must run `npm run build` after changing any `src/` file**, otherwise the stale artifacts keep running (CI enforces artifact freshness).
281
+ ### Source layout & build
282
+
283
+ All source lives in `src/`: the host (Node ESM TypeScript) in `src/host/`, the browser side in `src/client/`, cross-domain shared types in `src/types/`; `lib/` is a pure build-output directory — `npm run build` generates it via esbuild (per-file host transpilation plus the `lib/client.js` bundle), and the artifacts are committed with the source. **You must run `npm run build` after changing any `src/` file**, otherwise the stale artifacts keep running (CI enforces artifact freshness).
263
284
 
264
285
  ### Tests
265
286
 
266
- - `npm test`: pure-logic unit tests (vitest, 39 files / 497 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, the disk-format guard, the recall intent journal, recall terminal-event payload assembly, i18n dictionaries plus a missing-key scan, script-template same-name-export contract, shell execution dual-channel split and failure grading, settings bridge across both generations, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
267
- - `npm run test:client`: client component tests (vitest + jsdom, 6 files / 90 cases, runs in CI) — the recall node main chain (preview→execute→fork→refill) and terminal-event reporting, snapshot-manager tree and delete flows, config/exclude card error paths, the logger switch matrix, and the zh/en rendering chains; assertions pin behaviour and structure only (className / aria / request payloads), never literal copy;
268
- - `npm run test:probe`: official-API field probes (requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` and the fork-cut anchors of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, the shell execution seam (`execute`/`ShellExecution.result`), the settings surface (`SettingsForms`/profile entry id/volatile gate) plus `loader/volatile-update` and the `Fiber.entry` shape, and goes red on violation;
287
+ - `npm test`: pure-logic unit tests (vitest, 39 files / 505 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, the disk-format guard, the recall intent journal, recall terminal-event payload assembly, i18n dictionaries plus a missing-key scan, script-template same-name-export contract, shell execution dual-channel split and failure grading, settings bridge across both generations, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
288
+ - `npm run test:client`: client component tests (vitest + jsdom, 6 files / 96 cases, runs in CI) — the recall node main chain (preview→execute→fork→refill) and terminal-event reporting, snapshot-manager tree and delete flows, config/exclude card error paths, the logger switch matrix, and the zh/en rendering chains; assertions pin behaviour and structure only (className / aria / request payloads), never literal copy;
289
+ - `npm run test:probe`: official-API field probes (2 files / 54 cases, requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` and the fork-cut anchors of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, the shell execution seam (`execute`/`ShellExecution.result`), the settings surface (`SettingsForms`/profile entry id/volatile gate) plus `loader/volatile-update` and the `Fiber.entry` shape, and goes red on violation;
269
290
  - `npm run verify:host`: assembly gate (requires a local dsh installation) — boots the plugin with a real cordis context in two passes (legacy settings stub and a modern-face-only stub), asserting inject declarations, endpoint registration, Config schema, teardown cleanliness and settings-face dispatch, catching assembly regressions before release;
270
- - `npm run build`: full host+client build (mandatory after any `src/` change); `npm run check:dsh`: dsh version inspection (pre-release).
291
+ - `npm run typecheck`: full `tsc --noEmit` type check (includes the `tests/types` compile-time contract assertions; CI runs it before the unit suite);
292
+ - `npm run build`: full host+client build (mandatory after any `src/` change); `npm run check:dsh`: dsh version inspection (pre-release); `npm run check:upgrade`: one-shot dsh-upgrade verification gate (chains check:dsh + test:probe + verify:host; mandatory after a dsh upgrade).
271
293
  - CI (GitHub Actions) runs `npm ci --legacy-peer-deps` + `npm run typecheck` + `npm test` + `npm run test:client` + unified artifact-freshness check (`npm run build && git diff --exit-code lib/`; probes and the assembly gate only run on machines with dsh).
272
294
 
295
+ ### Commit conventions (summary)
296
+
297
+ - Commit messages follow Conventional Commits (`feat:` / `fix:` / `docs:` / `test:` / `ci:` / `chore:` prefixes with a concise subject); fixes bump patch, new features bump minor;
298
+ - Behaviour changes must be reflected in [CHANGELOG.md](CHANGELOG.md) (Keep a Changelog format) and in both README languages ([中文](README.md) / [English](README.en.md));
299
+ - When assumptions about dsh's official API fields change, update the [docs/compat-audit.md](docs/compat-audit.md) ledger;
300
+ - For the full architecture map, development workflow, official-docs compliance checklist and known pitfalls see [AGENTS.md](AGENTS.md) — the project operations manual for AI coding agents and deep maintainers.
301
+
273
302
  ## License
274
303
 
275
304
  MIT
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-recall-plugin?style=flat-square&label=npm&color=3178C6)](https://www.npmjs.com/package/dsh-recall-plugin)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-recall-plugin?style=flat-square&label=downloads&color=1F883D)](https://www.npmjs.com/package/dsh-recall-plugin)
7
7
  ![License](https://img.shields.io/badge/license-MIT-blue)
8
- [![DSH](https://img.shields.io/badge/DSH-0.2.1--alpha.4-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-alpha.4)
8
+ [![DSH](https://img.shields.io/badge/DSH-0.2.1--rc.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.1-rc.2)
9
9
 
10
10
  在任意一条你发过的消息下方点「↶ 撤回」——**工作区文件和对话历史一起回到那条消息发出之前的状态**。
11
11
 
@@ -24,7 +24,7 @@
24
24
  - [快照维护与清理](#快照维护与清理)
25
25
  - [工作原理](#工作原理)
26
26
  - [事件契约(供同宿主插件监听)](#事件契约供同宿主插件监听)
27
- - [本地开发(无需发布)](#本地开发无需发布)
27
+ - [参与贡献](#参与贡献)
28
28
  - [License](#license)
29
29
 
30
30
  ## 界面预览
@@ -70,7 +70,7 @@
70
70
 
71
71
  - git CLI:未安装时撤回按钮不出现(页面顶部会提示安装 git),不影响 DSH 运行。
72
72
  - shell:Windows 上 PowerShell 5.1 / 7 均可;Linux/macOS 需 bash。
73
- - DSH 版本:`0.1.2-alpha.1` 至 `0.2.1-alpha.4`;已核验 minor 线内的后续版本自动放行,未核验的新 minor 线会被启动期兼容门禁拦截(开窗机制见下方折叠块)。
73
+ - DSH 版本:`0.1.2-alpha.1` 至 `0.2.1-rc.2`;已核验 minor 线内的后续版本自动放行,未核验的新 minor 线会被启动期兼容门禁拦截(开窗机制见下方折叠块)。
74
74
  - **0.1.7-alpha.1 是破坏性版本**(shell 执行与 settings 面接口更换),插件内置双分支共存适配:同一发布同时兼容 0.1.2–0.1.6 各线与 0.1.7+,老版本 DSH 上行为不变。
75
75
  - `0.1.1-rc.2` 及更早不支持:那条线的客户端运行时没有 `sessions`/`workspaces`/`uiWorkspace` 服务,插件 UI 会静默不渲染。
76
76
 
@@ -134,7 +134,9 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
134
134
  | --- | --- | --- |
135
135
  | `gcSnaps` | 50 | 每积累多少条快照触发一次 `git gc`(env `DSH_RECALL_GC_SNAPS` 可强制覆盖) |
136
136
  | `gcHours` | 24 | 距上次 gc 超过多少小时触发(与条数先到先触发;env `DSH_RECALL_GC_HOURS`) |
137
- | `maxFileBytes` | 104857600(100MB) | 超过该大小的文件不进快照、不被回退触碰 |
137
+ | `maxFileBytes` | 8388608(8MB) | 超过该大小的文件不进快照、不被回退触碰 |
138
+ | `maxWorkspaceBytes` | 268435456(256MB) | 单工作区可纳入快照的文件总量上限。超过时**停用该工作区快照**(不截断——截断快照回退时会误删未纳入的文件),并在最近消息上提示;调大该值或补充排除表后自动恢复;0 = 不限制 |
139
+ | `scanBudgetMs` | 10000(10 秒) | 快照前的文件扫描时间预算。超过时停用本次快照并提示(防病态大目录拖死启动);0 = 不限制 |
138
140
  | `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
139
141
  | `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
140
142
  | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/`、`target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/`、`*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso` | 基础排除表(gitignore 语法,优先级低于 exclude.txt);被排除的大目录整棵子树不进快照扫描。其中「目录形态」项(如 `target/`)另有一层含义:**工作区根自身的路径段命中时,该工作区不启用快照**——在构建产物目录里开会话时排除表本来就管不到它自己(模式是相对该 root 的),这类目录也没有回退价值;删掉对应项即恢复 |
@@ -242,9 +244,26 @@ ctx.on('dsh-recall/failed', (e) => {
242
244
 
243
245
  版本错位:旧版插件前端(<2.4.8)不上报、事件永不触发;新版前端 + 旧版 host(<2.4.8)时上报端点不存在,前端静默忽略,撤回功能不受任何影响。
244
246
 
245
- ## 本地开发(无需发布)
247
+ ## 参与贡献
246
248
 
247
- 把 profile 对本包的依赖改成 `link:` 指向克隆目录;DSH 加载的是工作区 `lib/` 构建产物(源码在 `src/`),改 `src/` 后先 `npm run build` 再重启 DSH 生效,无需复制或发布:
249
+ ### 环境要求
250
+
251
+ - Node.js ≥ 20(Windows / Linux / macOS 均可开发),git CLI;
252
+ - 本机安装 DSH(≥ 0.1.2-alpha.1):`npm run test:probe` 与 `npm run verify:host` 依赖真实 dsh 安装;没有 dsh 时探针自动跳过,不阻塞其余开发与测试。
253
+
254
+ ### 本地联调(无需发布)
255
+
256
+ 先克隆并构建一次(peer 依赖是私有 `@deepseek-ai/*` 包,`--legacy-peer-deps` 跳过、只装 devDependencies——构建与测试工具链齐备):
257
+
258
+ ```powershell
259
+ git clone https://github.com/limbo947/dsh-recall-plugin.git
260
+ cd dsh-recall-plugin
261
+ npm install --legacy-peer-deps # 安装构建/测试工具链(esbuild、vitest、typescript 等)
262
+ npm run build # 生成 lib/ 产物(link 模式加载的就是它)
263
+ npm test # 可选:跑一遍单测验证基线
264
+ ```
265
+
266
+ 再把 profile 对本包的依赖改成 `link:` 指向克隆目录;DSH 加载的是工作区 `lib/` 构建产物(源码在 `src/`),改 `src/` 后先 `npm run build` 再重启 DSH 生效,无需复制或发布:
248
267
 
249
268
  ```powershell
250
269
  # 1. 编辑 $env:USERPROFILE\.dsh\profiles\web\package.json:
@@ -256,17 +275,27 @@ pnpm install
256
275
  # 3. 重启 DSH + 硬刷新页面(Ctrl+Shift+R)
257
276
  ```
258
277
 
259
- 注意:全部源码在 `src/`(Host 在 `src/host/`、浏览器端在 `src/client/`、共享类型在 `src/types/`),`lib/` 是纯构建产物目录——`npm run build` 经 esbuild 生成(逐文件转译 host 产物 + 打包 `lib/client.js`),产物随源码提交。**改任何 `src/` 后必须跑 `npm run build`**,否则运行的是旧产物(CI 有产物新鲜度统一校验)。
278
+ ### 源码结构与构建
279
+
280
+ 全部源码在 `src/`:Host(Node ESM TypeScript)在 `src/host/`、浏览器端在 `src/client/`、跨域共享类型在 `src/types/`;`lib/` 是纯构建产物目录——`npm run build` 经 esbuild 生成(逐文件转译 host 产物 + 打包 `lib/client.js`),产物随源码提交。**改任何 `src/` 后必须跑 `npm run build`**,否则运行的是旧产物(CI 有产物新鲜度统一校验)。
260
281
 
261
282
  ### 测试
262
283
 
263
- - `npm test`:纯逻辑单测(vitest,39 个文件 497 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、磁盘格式守卫、操作意图 journal、撤回终态事件上报组装、i18n 词典与 key 漏配扫描、脚本模板同名导出契约、执行通道双分支与失败分级、settings 双代桥接、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
264
- - `npm run test:client`:client 组件测试(vitest + jsdom,6 个文件 90 例,CI 同跑)——撤回节点主链(preview→execute→fork→回填)与终态事件上报、快照管理树与删除流、配置/排除卡片错误路径、logger 开关矩阵、zh/en 双语渲染链;断言只锁行为与结构(className / aria / 请求载荷),不锁文案字面量;
265
- - `npm run test:probe`:官方 API 字段探针(依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle` 与切点锚点、`listSessions` 记录结构、`AgentRegistry`、shell 执行接缝(`execute`/`ShellExecution.result`)、settings 面(`SettingsForms`/profile 条目 id/volatile 门槛)与 `loader/volatile-update`/`Fiber.entry` 形状等字段,违反即红;
284
+ - `npm test`:纯逻辑单测(vitest,39 个文件 505 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、磁盘格式守卫、操作意图 journal、撤回终态事件上报组装、i18n 词典与 key 漏配扫描、脚本模板同名导出契约、执行通道双分支与失败分级、settings 双代桥接、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
285
+ - `npm run test:client`:client 组件测试(vitest + jsdom,6 个文件 96 例,CI 同跑)——撤回节点主链(preview→execute→fork→回填)与终态事件上报、快照管理树与删除流、配置/排除卡片错误路径、logger 开关矩阵、zh/en 双语渲染链;断言只锁行为与结构(className / aria / 请求载荷),不锁文案字面量;
286
+ - `npm run test:probe`:官方 API 字段探针(2 个文件 54 例,依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle` 与切点锚点、`listSessions` 记录结构、`AgentRegistry`、shell 执行接缝(`execute`/`ShellExecution.result`)、settings 面(`SettingsForms`/profile 条目 id/volatile 门槛)与 `loader/volatile-update`/`Fiber.entry` 形状等字段,违反即红;
266
287
  - `npm run verify:host`:装配门禁(依赖本机 dsh 安装)——用真实 cordis 起插件跑两个 pass(旧面桩 + 只给新面的桩),断言 inject 声明、端点注册、Config schema、卸载清理与 settings 面分派,装配回归发版前即可拦截;
267
- - `npm run build`:host+client 全量打包(改任何 `src/` 后必跑);`npm run check:dsh`:dsh 版本巡检(发布前)。
288
+ - `npm run typecheck`:`tsc --noEmit` 全量类型检查(含 `tests/types` 编译期契约断言,CI 中置于单测之前);
289
+ - `npm run build`:host+client 全量打包(改任何 `src/` 后必跑);`npm run check:dsh`:dsh 版本巡检(发布前);`npm run check:upgrade`:dsh 升级一键核验门禁(串联 check:dsh + test:probe + verify:host,dsh 升级后必跑)。
268
290
  - CI(GitHub Actions)跑 `npm ci --legacy-peer-deps` + `npm run typecheck` + `npm test` + `npm run test:client` + 产物新鲜度统一校验(`npm run build && git diff --exit-code lib/`;探针与装配门禁只在有 dsh 的机器跑)。
269
291
 
292
+ ### 提交规范(摘要)
293
+
294
+ - 提交信息遵循 Conventional Commits(`feat:` / `fix:` / `docs:` / `test:` / `ci:` / `chore:` 前缀 + 中文摘要);修复 bump patch、新功能 bump minor;
295
+ - 行为变更同步更新 [CHANGELOG.md](CHANGELOG.md)(Keep a Changelog 格式)与 README 双语([中文](README.md) / [English](README.en.md));
296
+ - 对 dsh 官方 API 的字段假设发生变化时,同步 [docs/compat-audit.md](docs/compat-audit.md) 台账;
297
+ - 完整的架构地图、开发流程、官方文档合规清单与已知坑见 [AGENTS.md](AGENTS.md)——面向 AI 编码代理与深度维护者的项目操作手册。
298
+
270
299
  ## License
271
300
 
272
301
  MIT
package/cordis.patch.yml CHANGED
@@ -15,7 +15,9 @@
15
15
  config:
16
16
  gcSnaps: 50 # 每积累多少条快照触发一次 git gc
17
17
  gcHours: 24 # 距上次 gc 超过多少小时触发(与条数先到先触发)
18
- maxFileBytes: 104857600 # 超过该大小的文件不进快照、不被回退触碰
18
+ maxFileBytes: 8388608 # 超过该大小的文件不进快照、不被回退触碰
19
+ maxWorkspaceBytes: 268435456 # 单工作区可纳入快照的文件总量上限,超过即停用该工作区快照;0 = 不限制
20
+ scanBudgetMs: 10000 # 快照前扫描的时间预算(毫秒),超时即停用本次快照;0 = 不限制
19
21
  refillDraft: true # 撤回后把被撤回的消息文本回填到输入框
20
22
  # baseExcludes 默认不在此列出:默认表随插件 schema 下发,在设置页
21
23
  # 「基础排除表」增删即可。若要在这里覆盖,必须重述完整表——patch 按行
package/lib/client.js CHANGED
@@ -464,6 +464,12 @@
464
464
  "config.maxFileBytes.label": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650",
465
465
  "config.maxFileBytes.hint": "\u8D85\u8FC7\u8BE5\u5927\u5C0F\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0",
466
466
  "config.maxFileBytes.suffix": "MB",
467
+ "config.maxWorkspaceBytes.label": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650",
468
+ "config.maxWorkspaceBytes.hint": "\u5355\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\uFF0C\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF08\u4E0D\u622A\u65AD\uFF0C\u907F\u514D\u56DE\u9000\u8BEF\u5220\uFF09\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
469
+ "config.maxWorkspaceBytes.suffix": "MB",
470
+ "config.scanBudgetMs.label": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97",
471
+ "config.scanBudgetMs.hint": "\u5FEB\u7167\u524D\u7684\u6587\u4EF6\u626B\u63CF\u8D85\u8FC7\u8BE5\u65F6\u957F\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
472
+ "config.scanBudgetMs.suffix": "\u79D2",
467
473
  "config.maxSnapshotsPerWorkspace.label": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650",
468
474
  "config.maxSnapshotsPerWorkspace.hint": "\u6BCF\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
469
475
  "config.maxSnapshotsPerWorkspace.suffix": "\u6761",
@@ -494,6 +500,8 @@
494
500
  "config.err.gcSnaps": "\u5FEB\u7167\u6761\u6570\u9608\u503C\u5FC5\u987B\u662F >= 1 \u7684\u6574\u6570",
495
501
  "config.err.gcHours": "gc \u5C0F\u65F6\u9608\u503C\u5FC5\u987B\u662F >= 1 \u7684\u6574\u6570",
496
502
  "config.err.maxFileBytes": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\u81F3\u5C11 0.01 MB",
503
+ "config.err.maxWorkspaceBytes": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
504
+ "config.err.scanBudgetMs": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
497
505
  "config.err.maxSnapshots": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
498
506
  "config.err.retentionDays": "\u4FDD\u7559\u5929\u6570\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u542F\u7528\uFF09",
499
507
  "config.err.locale": "\u754C\u9762\u8BED\u8A00\u5FC5\u987B\u662F auto / zh / en \u4E4B\u4E00",
@@ -676,6 +684,12 @@
676
684
  "config.maxFileBytes.label": "File size limit",
677
685
  "config.maxFileBytes.hint": "Files larger than this are not snapshotted and never touched by rollback",
678
686
  "config.maxFileBytes.suffix": "MB",
687
+ "config.maxWorkspaceBytes.label": "Workspace total limit",
688
+ "config.maxWorkspaceBytes.hint": "Total snapshot-eligible file size per workspace; beyond it snapshots are disabled for that workspace (no truncation, which would make rollback delete skipped files); 0 means unlimited",
689
+ "config.maxWorkspaceBytes.suffix": "MB",
690
+ "config.scanBudgetMs.label": "Scan time budget",
691
+ "config.scanBudgetMs.hint": "Skip this snapshot when the pre-snapshot file scan exceeds this duration; 0 means unlimited",
692
+ "config.scanBudgetMs.suffix": "s",
679
693
  "config.maxSnapshotsPerWorkspace.label": "Snapshot count limit",
680
694
  "config.maxSnapshotsPerWorkspace.hint": "Maximum snapshots kept per workspace; the oldest are deleted beyond it; 0 means unlimited",
681
695
  "config.maxSnapshotsPerWorkspace.suffix": "snapshots",
@@ -706,6 +720,8 @@
706
720
  "config.err.gcSnaps": "The snapshot count threshold must be an integer >= 1",
707
721
  "config.err.gcHours": "The gc hour threshold must be an integer >= 1",
708
722
  "config.err.maxFileBytes": "The file size limit must be at least 0.01 MB",
723
+ "config.err.maxWorkspaceBytes": "The workspace total limit must be a number >= 0 (0 means unlimited)",
724
+ "config.err.scanBudgetMs": "The scan time budget must be a number >= 0 (0 means unlimited)",
709
725
  "config.err.maxSnapshots": "The snapshot count limit must be an integer >= 0 (0 means unlimited)",
710
726
  "config.err.retentionDays": "Retention days must be an integer >= 0 (0 disables it)",
711
727
  "config.err.locale": "Language must be one of auto / zh / en",
@@ -1701,6 +1717,9 @@
1701
1717
  gcSnaps: String(v.gcSnaps == null ? "" : v.gcSnaps),
1702
1718
  gcHours: String(v.gcHours == null ? "" : v.gcHours),
1703
1719
  maxFileBytes: bytesToMb2(v.maxFileBytes),
1720
+ maxWorkspaceBytes: bytesToMb2(v.maxWorkspaceBytes),
1721
+ // 扫描预算持久化为毫秒、展示为秒(整数);0 表示不限制,原样显示 0
1722
+ scanBudgetMs: String(v.scanBudgetMs == null ? "" : Math.round(Number(v.scanBudgetMs) / 1e3)),
1704
1723
  maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? "" : v.maxSnapshotsPerWorkspace),
1705
1724
  baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
1706
1725
  refillDraft: v.refillDraft !== false,
@@ -1730,7 +1749,7 @@
1730
1749
  function save() {
1731
1750
  if (state.busy || !draft || !baseline) return;
1732
1751
  const patch = {};
1733
- for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "retentionDays", "locale"]) {
1752
+ for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "retentionDays", "locale"]) {
1734
1753
  const value = draft[key];
1735
1754
  if (value !== void 0 && value !== baseline[key]) patch[key] = value;
1736
1755
  }
@@ -1763,6 +1782,22 @@
1763
1782
  }
1764
1783
  clean.maxFileBytes = Math.round(mb * 1048576);
1765
1784
  }
1785
+ if (patch.maxWorkspaceBytes !== void 0) {
1786
+ const mb = Number(patch.maxWorkspaceBytes);
1787
+ if (!Number.isFinite(mb) || mb < 0) {
1788
+ setState({ busy: false, message: t("config.err.maxWorkspaceBytes"), error: true });
1789
+ return;
1790
+ }
1791
+ clean.maxWorkspaceBytes = Math.round(mb * 1048576);
1792
+ }
1793
+ if (patch.scanBudgetMs !== void 0) {
1794
+ const s = Number(patch.scanBudgetMs);
1795
+ if (!Number.isFinite(s) || s < 0) {
1796
+ setState({ busy: false, message: t("config.err.scanBudgetMs"), error: true });
1797
+ return;
1798
+ }
1799
+ clean.scanBudgetMs = Math.round(s * 1e3);
1800
+ }
1766
1801
  if (patch.maxSnapshotsPerWorkspace !== void 0) {
1767
1802
  const n = parseInt(String(patch.maxSnapshotsPerWorkspace), 10);
1768
1803
  if (!Number.isFinite(n) || n < 0) {
@@ -1937,6 +1972,9 @@
1937
1972
  numRow("gcSnaps", t("config.gcSnaps.label"), t("config.gcSnaps.hint"), { suffixKey: "config.gcSnaps.suffix", min: 1, step: 1 }),
1938
1973
  numRow("gcHours", t("config.gcHours.label"), t("config.gcHours.hint"), { suffixKey: "config.gcHours.suffix", min: 1, step: 1 }),
1939
1974
  numRow("maxFileBytes", t("config.maxFileBytes.label"), t("config.maxFileBytes.hint"), { suffixKey: "config.maxFileBytes.suffix", min: 0.01, step: 0.5 }),
1975
+ // issue #24 护栏:单文件上限之外的两道闸(总量 + 扫描耗时),0 = 不限制
1976
+ numRow("maxWorkspaceBytes", t("config.maxWorkspaceBytes.label"), t("config.maxWorkspaceBytes.hint"), { suffixKey: "config.maxWorkspaceBytes.suffix", min: 0, step: 16 }),
1977
+ numRow("scanBudgetMs", t("config.scanBudgetMs.label"), t("config.scanBudgetMs.hint"), { suffixKey: "config.scanBudgetMs.suffix", min: 0, step: 1 }),
1940
1978
  numRow("maxSnapshotsPerWorkspace", t("config.maxSnapshotsPerWorkspace.label"), t("config.maxSnapshotsPerWorkspace.hint"), { suffixKey: "config.maxSnapshotsPerWorkspace.suffix", min: 0, step: 1 }),
1941
1979
  numRow("retentionDays", t("config.retentionDays.label"), t("config.retentionDays.hint"), { suffixKey: "config.retentionDays.suffix", min: 0, step: 1 }),
1942
1980
  React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.ui")),
package/lib/config.js CHANGED
@@ -34,7 +34,12 @@ function withVolatile(field) {
34
34
  const Config = Schema.object({
35
35
  gcSnaps: withVolatile(Schema.number().default(50).description("\u6BCF\u79EF\u7D2F\u591A\u5C11\u6761\u5FEB\u7167\u89E6\u53D1\u4E00\u6B21 git gc")),
36
36
  gcHours: withVolatile(Schema.number().default(24).description("\u8DDD\u4E0A\u6B21 gc \u8D85\u8FC7\u591A\u5C11\u5C0F\u65F6\u89E6\u53D1\uFF08\u4E0E\u6761\u6570\u5148\u5230\u5148\u89E6\u53D1\uFF09")),
37
- maxFileBytes: withVolatile(Schema.number().default(104857600).description("\u8D85\u8FC7\u8BE5\u5B57\u8282\u6570\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0")),
37
+ maxFileBytes: withVolatile(Schema.number().default(8388608).description("\u8D85\u8FC7\u8BE5\u5B57\u8282\u6570\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0")),
38
+ // issue #24:单文件上限挡不住「大量小文件」——下载目录 321 个 ≤100MB 文件
39
+ // 合计 1.05GB 全部进快照。总量超限时**整体停用该工作区快照**而非截断:
40
+ // 截断快照回退时,未纳入的文件会命中回退脚本的「当前有、目标无」删除侧。
41
+ maxWorkspaceBytes: withVolatile(Schema.number().default(268435456).description("\u5355\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u4E0A\u9650\uFF0C\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
42
+ scanBudgetMs: withVolatile(Schema.number().default(1e4).description("\u5FEB\u7167\u524D\u626B\u63CF\uFF08\u904D\u5386\u5DE5\u4F5C\u533A\u7EDF\u8BA1\u603B\u91CF\uFF09\u7684\u65F6\u95F4\u9884\u7B97\uFF0C\u8D85\u65F6\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
38
43
  maxSnapshotsPerWorkspace: withVolatile(Schema.number().default(500).description("\u6BCF\u4E2A\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684")),
39
44
  baseExcludes: withVolatile(Schema.array(Schema.string()).default(BASE_EXCLUDES).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09")),
40
45
  refillDraft: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846")),
@@ -118,7 +123,9 @@ function isPlainObject(value) {
118
123
  const DEFAULTS = {
119
124
  gcSnaps: 50,
120
125
  gcHours: 24,
121
- maxFileBytes: 104857600,
126
+ maxFileBytes: 8388608,
127
+ maxWorkspaceBytes: 268435456,
128
+ scanBudgetMs: 1e4,
122
129
  maxSnapshotsPerWorkspace: 500,
123
130
  baseExcludes: BASE_EXCLUDES,
124
131
  refillDraft: true,
@@ -136,7 +143,9 @@ function createConfig(raw) {
136
143
  }
137
144
  const gcSnaps = pickNumber(process.env.DSH_RECALL_GC_SNAPS, pickNumber(cfg.gcSnaps, 50, 1), 1);
138
145
  const gcHours = pickNumber(process.env.DSH_RECALL_GC_HOURS, pickNumber(cfg.gcHours, 24, 1), 1);
139
- const maxFileBytes = pickNumber(cfg.maxFileBytes, 104857600, 1024);
146
+ const maxFileBytes = pickNumber(cfg.maxFileBytes, 8388608, 1024);
147
+ const maxWorkspaceBytes = pickNumber(cfg.maxWorkspaceBytes, 268435456, 0);
148
+ const scanBudgetMs = pickNumber(cfg.scanBudgetMs, 1e4, 0);
140
149
  const rawMax = typeof cfg.maxSnapshotsPerWorkspace === "number" ? cfg.maxSnapshotsPerWorkspace : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? "" : cfg.maxSnapshotsPerWorkspace), 10);
141
150
  const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500;
142
151
  const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
@@ -146,7 +155,7 @@ function createConfig(raw) {
146
155
  const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
147
156
  const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
148
157
  const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
149
- return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays, locale };
158
+ return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays, locale };
150
159
  }
151
160
  export {
152
161
  Config,
package/lib/index.js CHANGED
@@ -236,6 +236,26 @@ function apply(ctx, config) {
236
236
  },
237
237
  agentBusy: (root) => agentBusy(null, root)
238
238
  });
239
+ const rootReady = /* @__PURE__ */ new Map();
240
+ function ensureRootReady(root, sessionId) {
241
+ const cached = rootReady.get(root);
242
+ if (cached) return cached;
243
+ const task = (async () => {
244
+ let store = await rt.resolveStore(root);
245
+ store = await rt.tryUpgradeToHome(root);
246
+ await rt.ensureGit(root, store);
247
+ await snaps.loadIndex(root, sessionId);
248
+ await snaps.rebuildOrphans(root, sessionId || "");
249
+ if (await snaps.guardStoreFormat(store)) await intentJournal.recover(store);
250
+ rt.cleanupLegacy(root);
251
+ return store;
252
+ })();
253
+ rootReady.set(root, task);
254
+ task.catch(() => {
255
+ rootReady.delete(root);
256
+ });
257
+ return task;
258
+ }
239
259
  const deps = {
240
260
  ctx,
241
261
  rt,
@@ -247,6 +267,7 @@ function apply(ctx, config) {
247
267
  enqueue,
248
268
  agentBusy,
249
269
  runLimited,
270
+ ensureRootReady,
250
271
  errBody,
251
272
  listExcludeFiles,
252
273
  dumpStores,
@@ -343,13 +364,14 @@ function apply(ctx, config) {
343
364
  } catch (error) {
344
365
  }
345
366
  }
346
- for (const [cwd, sessionId] of warmupRoots) {
347
- Promise.resolve(rt.resolveStore(cwd)).then(() => rt.tryUpgradeToHome(cwd)).then((store) => rt.ensureGit(cwd, store)).then(() => snaps.loadIndex(cwd, sessionId)).then(() => snaps.rebuildOrphans(cwd, sessionId)).then(async () => {
348
- const st = state.stores.get(cwd);
349
- if (st && await snaps.guardStoreFormat(st)) await intentJournal.recover(st);
350
- }).then(() => rt.cleanupLegacy(cwd)).catch(() => {
351
- });
352
- }
367
+ const warmupTasks = Array.from(
368
+ warmupRoots,
369
+ ([cwd, sessionId]) => () => ensureRootReady(cwd, sessionId || "").then(() => {
370
+ }).catch(() => {
371
+ })
372
+ // 预热链失败整体吞掉(不刷启动噪音,原语义)
373
+ );
374
+ await runLimited(warmupTasks, 2);
353
375
  })().catch((error) => {
354
376
  console.error("recall warmup skipped:", error);
355
377
  });
@@ -4,7 +4,7 @@ import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js
4
4
  const RECALL_EVENT_COMPLETE_HOST = "dsh-recall/complete";
5
5
  const RECALL_EVENT_FAILED_HOST = "dsh-recall/failed";
6
6
  function createRoutesCore(deps) {
7
- const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, emitEvent, E } = deps;
7
+ const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
8
8
  return {
9
9
  "init": async (args) => {
10
10
  if (!supported) {
@@ -14,13 +14,7 @@ function createRoutesCore(deps) {
14
14
  const root = await rt.resolveRoot(sessionId);
15
15
  let notice = null;
16
16
  if (root) {
17
- let store = await rt.resolveStore(root);
18
- store = await rt.tryUpgradeToHome(root);
19
- await rt.ensureGit(root, store);
20
- await snaps.loadIndex(root, sessionId);
21
- await snaps.rebuildOrphans(root, sessionId);
22
- if (store && await snaps.guardStoreFormat(store)) await intentJournal.recover(store);
23
- rt.cleanupLegacy(root);
17
+ const store = await ensureRootReady(root, sessionId);
24
18
  notice = {
25
19
  gitMissing: state.gitExe === "",
26
20
  homeFallback: store ? !store.home : false
@@ -280,6 +280,8 @@ function createRoutesManage(deps) {
280
280
  gcSnaps: cfg.gcSnaps,
281
281
  gcHours: cfg.gcHours,
282
282
  maxFileBytes: cfg.maxFileBytes,
283
+ maxWorkspaceBytes: cfg.maxWorkspaceBytes,
284
+ scanBudgetMs: cfg.scanBudgetMs,
283
285
  maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
284
286
  baseExcludes: cfg.baseExcludes.slice(),
285
287
  refillDraft: cfg.refillDraft,
@@ -305,6 +307,16 @@ function createRoutesManage(deps) {
305
307
  if (patch.gcSnaps !== void 0) clean.gcSnaps = Number(patch.gcSnaps);
306
308
  if (patch.gcHours !== void 0) clean.gcHours = Number(patch.gcHours);
307
309
  if (patch.maxFileBytes !== void 0) clean.maxFileBytes = Number(patch.maxFileBytes);
310
+ if (patch.maxWorkspaceBytes !== void 0) {
311
+ const n = Number(patch.maxWorkspaceBytes);
312
+ if (!Number.isFinite(n) || n < 0) return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09" };
313
+ clean.maxWorkspaceBytes = Math.trunc(n);
314
+ }
315
+ if (patch.scanBudgetMs !== void 0) {
316
+ const n = Number(patch.scanBudgetMs);
317
+ if (!Number.isFinite(n) || n < 0) return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09" };
318
+ clean.scanBudgetMs = Math.trunc(n);
319
+ }
308
320
  if (patch.maxSnapshotsPerWorkspace !== void 0) {
309
321
  const n = Number(patch.maxSnapshotsPerWorkspace);
310
322
  if (!Number.isFinite(n)) return { ok: false, code: E.RECALL_BAD_TYPE, message: "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F\u6570\u5B57" };
@@ -2,7 +2,7 @@ function psq(value) {
2
2
  return "'" + String(value).replace(/'/g, "'\\''") + "'";
3
3
  }
4
4
  const UTF8_PRELUDE = "export LC_ALL=C";
5
- const MAX_FILE_BYTES = 104857600;
5
+ const MAX_FILE_BYTES = 8388608;
6
6
  const STALE_LOCK_MIN = 5;
7
7
  const HEARTBEAT_TTL_S = 900;
8
8
  const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
@@ -18,7 +18,7 @@ function dropGitlinksBlock() {
18
18
  "done"
19
19
  ].join("\n");
20
20
  }
21
- function oversizeBlock(maxBytes) {
21
+ function dirPruneArgsBlock() {
22
22
  return [
23
23
  "oversize_args=()",
24
24
  'while IFS= read -r line || [ -n "$line" ]; do',
@@ -31,12 +31,41 @@ function oversizeBlock(maxBytes) {
31
31
  ' if [ ${#oversize_args[@]} -eq 0 ]; then oversize_args+=(-name "$t"); else oversize_args+=(-o -name "$t"); fi',
32
32
  'done <<< "$new_exc"',
33
33
  "oversize_prune=()",
34
- "if [ ${#oversize_args[@]} -gt 0 ]; then oversize_prune=('(' -type d '(' \"${oversize_args[@]}\" ')' ')' -prune -o); fi",
34
+ "if [ ${#oversize_args[@]} -gt 0 ]; then oversize_prune=('(' -type d '(' \"${oversize_args[@]}\" ')' ')' -prune -o); fi"
35
+ ].join("\n");
36
+ }
37
+ function oversizeBlock(maxBytes) {
38
+ return [
39
+ dirPruneArgsBlock(),
35
40
  'find "$root" "${oversize_prune[@]}" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + "c -print0 2>/dev/null | while IFS= read -r -d '' f; do",
36
41
  ` printf '%s\\0' "\${f#"$root"/}"`,
37
42
  'done | xargs -0 "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- 2>/dev/null || true'
38
43
  ].join("\n");
39
44
  }
45
+ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
46
+ const limit = maxBytes > 0 ? String(maxBytes) : "0";
47
+ const fileLimit = String(fileMaxBytes || MAX_FILE_BYTES);
48
+ const budgetS = budgetMs > 0 ? String(Math.ceil(budgetMs / 1e3)) : "0";
49
+ return [
50
+ dirPruneArgsBlock(),
51
+ "limit_start=$(date +%s)",
52
+ `if find "$root" -maxdepth 0 -printf '' >/dev/null 2>&1; then`,
53
+ ` limit_sizes=$(find "$root" "\${oversize_prune[@]}" -type f -printf '%s\\n' 2>/dev/null)`,
54
+ "else",
55
+ ' limit_sizes=$(find "$root" "${oversize_prune[@]}" -type f -exec stat -f%z {} + 2>/dev/null)',
56
+ "fi",
57
+ // 口径 = 实际会进快照的字节:超过单文件上限的文件随后被 oversizeBlock
58
+ // 移出 index,不计入总量(同 pwsh 侧,防一个大文件把干净项目误判超限)
59
+ `limit_total=$(printf '%s\\n' "$limit_sizes" | awk -v lim=` + limit + " -v flim=" + fileLimit + ` '{ if ($1 <= flim) { s += $1; if (lim > 0 && s > lim) { print "OVER " s; exit } } } END { if (lim <= 0 || s <= lim) print s }')`,
60
+ "limit_elapsed=$(( $(date +%s) - limit_start ))",
61
+ 'case "$limit_total" in',
62
+ ' OVER\\ *) echo "SNAP_LIMIT bytes ${limit_total#OVER } ' + limit + '"; exit 0 ;;',
63
+ "esac",
64
+ "if [ " + budgetS + ' -gt 0 ] && [ "$limit_elapsed" -gt ' + budgetS + " ]; then",
65
+ ' echo "SNAP_LIMIT time $((limit_elapsed * 1000)) $(( ' + budgetS + ' * 1000 ))"; exit 0',
66
+ "fi"
67
+ ].join("\n");
68
+ }
40
69
  function excludeSyncBlock(excludeFile, base) {
41
70
  const baseList = Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"];
42
71
  const baseLines = baseList.join("\n") + "\n";
@@ -152,6 +181,8 @@ function snapshotScript(root, store, gitExe, messageId, base) {
152
181
  heartbeatBlock(),
153
182
  dropGitlinksBlock(),
154
183
  excludeSyncBlock(store.excludeFile, base),
184
+ // issue #24:总量/预算护栏先于 add——超限时连 add 都不跑
185
+ limitScanBlock(store.maxWorkspaceBytes, store.maxFileBytes, store.scanBudgetMs),
155
186
  attrsMigrateBlock(),
156
187
  // fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
157
188
  // 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal,
@@ -2,7 +2,7 @@ function psq(value) {
2
2
  return "'" + String(value).replace(/'/g, "''") + "'";
3
3
  }
4
4
  const UTF8_PRELUDE = "$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}";
5
- const MAX_FILE_BYTES = 104857600;
5
+ const MAX_FILE_BYTES = 8388608;
6
6
  const STALE_LOCK_MIN = 5;
7
7
  const HEARTBEAT_TTL_S = 900;
8
8
  const FIDELITY_ATTRS = "* -text -filter -ident -export-ignore -export-subst -working-tree-encoding";
@@ -84,6 +84,38 @@ function oversizeBlock(maxBytes) {
84
84
  "}"
85
85
  ].join("\n");
86
86
  }
87
+ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
88
+ const limit = maxBytes > 0 ? String(maxBytes) : "0";
89
+ const fileLimit = String(fileMaxBytes || MAX_FILE_BYTES);
90
+ const budget = budgetMs > 0 ? String(budgetMs) : "0";
91
+ return [
92
+ "$limitSw = [Diagnostics.Stopwatch]::StartNew()",
93
+ dirSkipSetBlock("limitSkip"),
94
+ "$limitStack = [System.Collections.Generic.Stack[string]]::new()",
95
+ "$limitStack.Push($root)",
96
+ "$limitBytes = [long]0",
97
+ "$limitHit = ''",
98
+ "while ($limitStack.Count -gt 0) {",
99
+ " if (" + budget + " -gt 0 -and $limitSw.ElapsedMilliseconds -gt " + budget + ") { $limitHit = 'time'; break }",
100
+ " $dir = $limitStack.Pop()",
101
+ " try {",
102
+ " $di = [System.IO.DirectoryInfo]::new($dir)",
103
+ // 口径 = 实际会进快照的字节:超过单文件上限的文件随后被 oversizeBlock
104
+ // 移出 index,不该计入总量(否则一个 300MB 视频就能把干净项目误判超限)
105
+ " foreach ($f in $di.EnumerateFiles()) { if ($f.Length -le " + fileLimit + ") { $limitBytes += $f.Length } }",
106
+ " if (" + limit + " -gt 0 -and $limitBytes -gt " + limit + ") { $limitHit = 'bytes' }",
107
+ " if ($limitHit) { break }",
108
+ " foreach ($d in $di.EnumerateDirectories()) {",
109
+ " if ($limitSkip.Contains($d.Name)) { continue }",
110
+ " if (($d.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }",
111
+ " $limitStack.Push($d.FullName)",
112
+ " }",
113
+ " } catch {}",
114
+ "}",
115
+ "if ($limitHit -eq 'bytes') { Write-Output ('SNAP_LIMIT bytes ' + $limitBytes + ' ' + " + limit + "); exit 0 }",
116
+ "if ($limitHit -eq 'time') { Write-Output ('SNAP_LIMIT time ' + $limitSw.ElapsedMilliseconds + ' ' + " + budget + "); exit 0 }"
117
+ ].join("\n");
118
+ }
87
119
  function excludeSyncBlock(excludeFile, base) {
88
120
  const baseList = (Array.isArray(base) && base.length ? base : [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"]).map(psq).join(",");
89
121
  return [
@@ -199,6 +231,8 @@ function snapshotScript(root, store, gitExe, messageId, base) {
199
231
  heartbeatBlock(),
200
232
  dropGitlinksBlock(),
201
233
  excludeSyncBlock(store.excludeFile, base),
234
+ // issue #24:总量/预算护栏先于 add——超限时连 add 都不跑
235
+ limitScanBlock(store.maxWorkspaceBytes, store.maxFileBytes, store.scanBudgetMs),
202
236
  attrsMigrateBlock(),
203
237
  // fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
204
238
  // (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
package/lib/snapshots.js CHANGED
@@ -8,6 +8,29 @@ function parseSkipped(out) {
8
8
  }
9
9
  return skipped;
10
10
  }
11
+ function parseSnapshotLimit(out) {
12
+ for (const line of String(out || "").split(/\r?\n/)) {
13
+ if (line.indexOf("SNAP_LIMIT ") !== 0) continue;
14
+ const parts = line.slice("SNAP_LIMIT ".length).trim().split(/\s+/);
15
+ const kind = parts[0];
16
+ if (kind !== "bytes" && kind !== "time") continue;
17
+ const value = parseInt(parts[1] ?? "", 10);
18
+ const limit = parseInt(parts[2] ?? "", 10);
19
+ if (!Number.isFinite(value) || !Number.isFinite(limit)) continue;
20
+ return { kind, value, limit };
21
+ }
22
+ return null;
23
+ }
24
+ function bytesText(bytes) {
25
+ if (bytes >= 1048576) return Math.round(bytes / 1048576) + " MB";
26
+ return Math.max(1, Math.round(bytes / 1024)) + " KB";
27
+ }
28
+ function buildSnapshotLimitNotice(limit) {
29
+ if (limit.kind === "bytes") {
30
+ return "\u8BE5\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u8D85\u8FC7\u4E0A\u9650\uFF08\u7EA6 " + bytesText(limit.value) + " / " + bytesText(limit.limit) + "\uFF09\uFF0C\u5DF2\u505C\u7528\u5FEB\u7167\uFF1B\u53EF\u8C03\u5927\u300C\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u300D\u6216\u8865\u5145\u6392\u9664\u8868\u540E\u81EA\u52A8\u6062\u590D";
31
+ }
32
+ return "\u5FEB\u7167\u524D\u7684\u6587\u4EF6\u626B\u63CF\u8D85\u8FC7\u65F6\u95F4\u9884\u7B97\uFF08\u7EA6 " + Math.max(1, Math.round(limit.value / 1e3)) + " \u79D2 / " + Math.max(1, Math.round(limit.limit / 1e3)) + " \u79D2\uFF09\uFF0C\u5DF2\u505C\u7528\u5FEB\u7167\uFF1B\u53EF\u8C03\u5927\u300C\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u300D\u6216\u8865\u5145\u6392\u9664\u8868\u540E\u81EA\u52A8\u6062\u590D";
33
+ }
11
34
  function parseChanges(text, isWin) {
12
35
  if (isWin) {
13
36
  const parsed = JSON.parse(text);
@@ -163,6 +186,9 @@ function createSnapshots(ctx, rt, config) {
163
186
  const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1e3;
164
187
  const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1e3;
165
188
  const snapFailures = /* @__PURE__ */ new Map();
189
+ const SNAP_LIMIT_RETRY_MS = 10 * 60 * 1e3;
190
+ const snapLimits = /* @__PURE__ */ new Map();
191
+ const limitBlockedAt = /* @__PURE__ */ new Map();
166
192
  const SUPPORTED_FORMAT = 1;
167
193
  const FORMAT_OK_TTL_MS = 3e4;
168
194
  const formatOkAt = /* @__PURE__ */ new Map();
@@ -375,6 +401,8 @@ function createSnapshots(ctx, rt, config) {
375
401
  if (!root) return;
376
402
  const fused = snapFailures.get(root);
377
403
  if (fused && Date.now() < fused.skipUntil) return;
404
+ const limited = snapLimits.get(root);
405
+ if (limited && limited.maxWorkspaceBytes === config.maxWorkspaceBytes && limited.scanBudgetMs === config.scanBudgetMs && Date.now() - limited.at < SNAP_LIMIT_RETRY_MS) return;
378
406
  if (buildArtifactRootSegment(root, BASE(), rt.isWin)) return;
379
407
  let store = await rt.resolveStore(root);
380
408
  store = await rt.tryUpgradeToHome(root);
@@ -387,6 +415,18 @@ function createSnapshots(ctx, rt, config) {
387
415
  await loadIndex(root, sessionId);
388
416
  try {
389
417
  const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
418
+ const limit = parseSnapshotLimit(out);
419
+ if (limit) {
420
+ snapLimits.set(root, { ...limit, at: Date.now(), maxWorkspaceBytes: config.maxWorkspaceBytes, scanBudgetMs: config.scanBudgetMs });
421
+ const notice = buildSnapshotLimitNotice(limit);
422
+ const last = limitBlockedAt.get(root) || 0;
423
+ if (Date.now() - last >= 5 * 60 * 1e3) {
424
+ limitBlockedAt.set(root, Date.now());
425
+ rt.recordError("recall snapshot disabled for " + root + ": " + notice);
426
+ }
427
+ setFeedback(messageId, { failed: true, kind: "workspaceLimit", error: notice });
428
+ return;
429
+ }
390
430
  snapFailures.delete(root);
391
431
  state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId });
392
432
  await saveIndex(root, sessionId);
@@ -533,12 +573,14 @@ function createSnapshots(ctx, rt, config) {
533
573
  return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, resolveStaleQueueItemIds, feedbackFor, loadLineage, recordLineage, guardStoreFormat };
534
574
  }
535
575
  export {
576
+ buildSnapshotLimitNotice,
536
577
  createSnapshots,
537
578
  isSafetySnapshotId,
538
579
  judgeStoreFormat,
539
580
  parseChanges,
540
581
  parseDiffOutput,
541
582
  parseSkipped,
583
+ parseSnapshotLimit,
542
584
  parseTagsWithTime,
543
585
  parseTreeId,
544
586
  rescueRollback,
package/lib/store.js CHANGED
@@ -392,6 +392,14 @@ function createRuntime(ctx, config, deps) {
392
392
  excludeFile,
393
393
  get maxFileBytes() {
394
394
  return config.maxFileBytes;
395
+ },
396
+ // issue #24 快照护栏:与 maxFileBytes 同款 getter——设置页改完上限,
397
+ // 下一次快照立即按新值判定,无需重启或重建 store 缓存
398
+ get maxWorkspaceBytes() {
399
+ return config.maxWorkspaceBytes;
400
+ },
401
+ get scanBudgetMs() {
402
+ return config.scanBudgetMs;
395
403
  }
396
404
  };
397
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.4.12",
3
+ "version": "2.4.14",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",
@@ -71,7 +71,10 @@
71
71
  "0.2.1-alpha.1": "compatible",
72
72
  "0.2.1-alpha.2": "compatible",
73
73
  "0.2.1-alpha.3": "compatible",
74
- "0.2.1-alpha.4": "compatible"
74
+ "0.2.1-alpha.4": "compatible",
75
+ "0.2.1-alpha.5": "compatible",
76
+ "0.2.1-rc.1": "compatible",
77
+ "0.2.1-rc.2": "compatible"
75
78
  }
76
79
  }
77
80
  },