dsh-openwrite 0.2.24 → 0.2.25
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 +7 -5
- package/docs/COST_MEASUREMENT.md +2 -2
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ISSUES_88_95.md +4 -4
- package/docs/ISSUES_98_100.md +59 -0
- package/docs/RELEASE_ACCEPTANCE.md +10 -0
- package/package.json +5 -3
- package/packages/openwrite-bridge/lib/client.d.ts +2 -0
- package/packages/openwrite-bridge/lib/client.js +22 -2
- package/packages/openwrite-bridge/lib/domain.js +14 -1
- package/packages/openwrite-bridge/lib/tool-policy.js +2 -0
- package/packages/openwrite-bridge/lib/tools.js +96 -5
- package/packages/studio-panel/lib/client.js +1458 -526
- package/packages/studio-panel/lib/client.js.map +1 -1
- package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +6 -0
- package/packages/studio-panel/lib/types/client/benchmark-options.d.ts +1 -0
- package/packages/studio-panel/lib/types/client/dto.d.ts +1 -1
- package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
- package/presets/openwrite/agent.cordis.yml +11 -0
- package/release/{openwrite-5.8.11-py3-none-any.whl → openwrite-5.8.12-py3-none-any.whl} +0 -0
- package/release/runtime-manifest.json +5 -5
- package/release/source.json +3 -3
- package/scripts/safe-upgrade.mjs +211 -0
package/README.md
CHANGED
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
|
|
30
30
|
## 快速安装
|
|
31
31
|
|
|
32
|
-
**0.2.
|
|
32
|
+
**0.2.24 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2 / 0.2.1-rc.2**。四个版本均已完成 Web 工作台渲染与 DoG 数据读取验证,Windows Electron 桌面端启动/恢复链路尚未实机复测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
|
|
33
33
|
|
|
34
|
-
npm
|
|
34
|
+
npm `dsh-openwrite@0.2.24` 与对应 GitHub Release 均已发布,`latest` 已更新到 0.2.24,使用同一份验收产物。也可安装精确版本的 Release 包:
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.
|
|
37
|
+
dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.24/dsh-openwrite-0.2.24.tgz
|
|
38
38
|
dsh web
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -64,9 +64,11 @@ dsh web
|
|
|
64
64
|
|
|
65
65
|
### dsh 0.2 桌面端升级
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
`0.2.25`(Core 5.8.12)为本分支的未发布候选,新增连载体检、分段续写、共享术语/范本、章组审计和漂移裁决,并修复验收与任务可观测性。范围、离线升级工具及宿主侧限制见 [#98–#100 实现说明](docs/ISSUES_98_100.md)。不要把候选版本当作已上架版本。
|
|
68
68
|
|
|
69
|
-
`0.2.
|
|
69
|
+
`0.2.24`(Core 5.8.11)修复 [#88–#95](docs/ISSUES_88_95.md):删章验收账本、结算降级、长文本流式生成、后台写章进度、统一字数门限、草稿发现与恢复及诊断/路由语义。[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.24) 与 [npm](https://www.npmjs.com/package/dsh-openwrite/v/0.2.24) 均已发布,完整验收见 [发布验收](docs/RELEASE_ACCEPTANCE.md#024)。升级后停止并重新启动 dsh,再刷新浏览器页面。
|
|
70
|
+
|
|
71
|
+
`0.2.23` 修复 [#83](https://github.com/LiPu-jpg/Openwrite/issues/83)/[#84](https://github.com/LiPu-jpg/Openwrite/issues/84)/[#85](https://github.com/LiPu-jpg/Openwrite/issues/85):根包与全部内嵌子包的兼容声明加入精确宿主 `0.2.1-rc.2`,安装和激活不再被宿主版本闸门拒绝,无需版本豁免或手工编辑 `compatibility.json`。已在 `0.2.1-rc.2` 完成 Web 客户端验收(启动、OpenWrite 入口、工作台三页签、DoG 面板与其他插件共存),`0.1.2-rc.1`、`0.2.0-rc.2` 回归通过;Windows Electron 桌面端未实机复测。该版本完整验收见 [发布验收](docs/RELEASE_ACCEPTANCE.md#023)。升级后停止并重新启动 dsh,再刷新浏览器页面。
|
|
70
72
|
|
|
71
73
|
`0.2.21` 修复 [#79](https://github.com/LiPu-jpg/Openwrite/issues/79) 的 Windows Desktop 目录选择:宿主接口失败后使用桌面提供的系统文件夹对话框,覆盖侧栏进入与工作台内目录选择。Core 保持 5.8.10。排查与手填路径方式见 [安装指南](docs/INSTALL.md#windows-desktop-目录选择)。
|
|
72
74
|
|
package/docs/COST_MEASUREMENT.md
CHANGED
|
@@ -11,7 +11,7 @@ npm --prefix packages/openwrite-bridge run build
|
|
|
11
11
|
node --test packages/openwrite-bridge/scripts/tool-policy.test.mjs
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`real dsh tool registry isolates
|
|
14
|
+
`real dsh tool registry isolates 95 tools` 会打印两行 JSON:
|
|
15
15
|
|
|
16
16
|
| 字段 | 含义 | 不是什么 |
|
|
17
17
|
|---|---|---|
|
|
@@ -20,7 +20,7 @@ node --test packages/openwrite-bridge/scripts/tool-policy.test.mjs
|
|
|
20
20
|
| `sdkBytes` | 注入到 system prompt 的 SDK 文本字节 | 不是费用 |
|
|
21
21
|
| `tokenizer: not-measured` | 本仓库不内置某家 tokenizer | 不得改写成已测 token |
|
|
22
22
|
|
|
23
|
-
同一套检查还断言:普通预设 `novel_*` 为 0;创作预设为
|
|
23
|
+
同一套检查还断言:普通预设 `novel_*` 为 0;创作预设为 95 且不随 PTC 另一 scope 改变。
|
|
24
24
|
|
|
25
25
|
要对比「请求大小」,把一次真实 `tools.schemas(agent)` 或 PTC `wireSchemas` 的 JSON 写入文件后对体积做 `wc -c` / SHA-256。这仍是请求体字节,不是 token。
|
|
26
26
|
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
| 路径 | 说明 |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `packages/openwrite-bridge/` | dsh 插件:
|
|
21
|
+
| `packages/openwrite-bridge/` | dsh 插件:95 个 `novel_*` 工具,覆盖 Studio HTTP 动作面并提供隔离模型横评与脱敏 trace |
|
|
22
22
|
| `packages/studio-panel/` | dsh web 原生工作台:创作 / 资料 / 任务,含规范章节导航、连续审读、场景双序、正文编辑器、双 DAG、模型测试台与工具卡 |
|
|
23
23
|
| `presets/openwrite/` | OpenWrite 全流程 agent 预设(规划/资产/写章/评审/修订) |
|
|
24
24
|
| `scripts/install.sh` | 构建插件、安装预设到 `~/.dsh/.agent-presets/`、插件装进 dsh profile |
|
package/docs/ISSUES_88_95.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Core 5.8.11:写作与恢复修复
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
插件 0.2.24 / Core 5.8.11 修复 #88–#95,已发布到 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.24) 与 [npm](https://www.npmjs.com/package/dsh-openwrite/v/0.2.24);分发状态和完整验收见 [发布验收](RELEASE_ACCEPTANCE.md#024)。测试使用假模型和隔离目录,不调用付费模型。
|
|
4
4
|
|
|
5
5
|
| Issue | 行为 |
|
|
6
6
|
| --- | --- |
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
|
|
32
32
|
`novel_manuscript_edit_action(action="drafts")` 列出未提交草稿;`action="restore_draft"` 传 `run_id_v2`、`confirm: true`,覆盖已有正文时用 `revision` 传当前正文 SHA。工具将其映射到 Core 的 `expected_revision`。恢复属于本地写入操作;不会按生成任务执行。随后使用现有验收调和工具处理返回的 operation。
|
|
33
33
|
|
|
34
|
-
#86、#87 的精确宿主 `0.2.1-rc.2` 声明已经包含在 0.2.23
|
|
34
|
+
#86、#87 的精确宿主 `0.2.1-rc.2` 声明已经包含在 0.2.23,本版继续保留该兼容范围。
|
|
35
35
|
|
|
36
36
|
插件验证:`npm run check:plugin` 通过(构建、doctor、36 项生命周期检查、预设与双 profile、bridge/panel smoke、14 项 epoch、298 项组件);`npm run test:managed` 23 项通过,跨仓库 contracts 通过。
|
|
37
37
|
|
|
38
|
-
核心全量 pytest:1409 passed、32 skipped;新 wheel
|
|
38
|
+
核心全量 pytest:1409 passed、32 skipped;新 wheel 另做打包内容与针对性回归检查。发布版本为插件 0.2.24 / Core 5.8.11。
|
|
39
39
|
|
|
40
|
-
新 wheel 解包后隔离导入并执行针对性回归:11 passed
|
|
40
|
+
新 wheel 解包后隔离导入并执行针对性回归:11 passed。已合并核心 [PR #96](https://github.com/LiPu-jpg/Openwrite/pull/96) 与插件 [PR #97](https://github.com/LiPu-jpg/Openwrite/pull/97)。
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# #98–#100:连载写作与运行可靠性
|
|
2
|
+
|
|
3
|
+
本分支为 dsh-openwrite 0.2.25 / Core 5.8.12 候选,尚未发布。Core 代码位于同仓库 native-core 分支;安装包将捆绑对应 wheel。完整数据契约见 Core `docs/SERIAL_WRITING.md`。
|
|
4
|
+
|
|
5
|
+
## 覆盖范围
|
|
6
|
+
|
|
7
|
+
| Issue | 实现 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| #98.1 插件升级破坏 bundle | 新增停机离线旁路升级工具,复制整个 profile 后安装、校验、切换,保留旧目录和日志;支持修复不完整 OpenWrite bundle 与中断恢复。原生 dsh 安装器和宿主启动降级未修改,见下方限制。 |
|
|
10
|
+
| #98.2 外部验收失败 | `no_model=true` / `mode=fast` 承认正文版本,派生事实明确 stale,可稍后完整对账。 |
|
|
11
|
+
| #98.3 误连实例 | status/响应头标识 root、instance、port;Bridge 校验实际根,不一致返回 409。 |
|
|
12
|
+
| #98.4、#99.1 长章 | 按 beats 分段,携带 1,500 字尾文续写,拼接去重;不足时续写,预算耗尽保留草稿。 |
|
|
13
|
+
| #98.5 已定稿章横评 | 显式选择历史隔离改写,产物不写正式正文/事实账本;标注当前正典上下文限制。 |
|
|
14
|
+
| #98.6 自留存档 | `personal_archive` 档位将质量门禁降为警告并写 hash 回执,结构错误仍阻断。 |
|
|
15
|
+
| #98.7 心跳与超时 | 活动、worker 心跳、当前模型调用时长分别显示;流式总期限有界,超时保留部分输出。 |
|
|
16
|
+
| #98.8 读写互斥 | GET 与纯读诊断不被写任务统一阻断;修改仍持项目写锁。 |
|
|
17
|
+
| #98.9 章级体检 | 汉字/段落、句长和段长 P50/P90、对白占比、项目词典名词密度,写后自动保存。 |
|
|
18
|
+
| #99.2–#99.6 写作资产 | anchor 范本、共享术语表及例句、最低次数、review 开关、12 字窗口重合/重复、角色属性分组轮换和明确已用标记。 |
|
|
19
|
+
| #99.7 用量 | reasoning_tokens 从上游嵌套 usage 提升,真实 0 与未知分别保留。 |
|
|
20
|
+
| #100 章节组审计 | 章纲四字段、只读计数与缺失报告、声明伏笔连边、漂移预算与冻结、吸收/回收/冻结裁决、不可变基线和事务恢复。 |
|
|
21
|
+
|
|
22
|
+
工作台「操作 → 连载体检」提供资产编辑、范本导入、章节体检、章纲元数据、漂移记账与裁决。相关工具为 `novel_writing_assets`、`novel_manuscript_metrics`、`novel_outline_audit`、`novel_drift_entry`、`novel_arc_reconcile`;当前预设共 95 个领域工具。大纲吸收要求真实版本化更新;界面里的承诺计数是声明检查,不能当作已验证的故事推进。
|
|
23
|
+
|
|
24
|
+
## 离线升级和损坏修复
|
|
25
|
+
|
|
26
|
+
先完整退出 DeepSeek Harness / DSH Desktop 并停止 dsh、OpenWrite managed runtime。整个升级过程中保持停机。工具会在安装前及切换前检查进程;未知进程状态也拒绝修改。宿主原生命令不受此检查保护。
|
|
27
|
+
|
|
28
|
+
从解压的候选/发行包运行(或已安装版本提供的 `openwrite-safe-upgrade`):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
node /path/to/package/scripts/safe-upgrade.mjs --profile desktop --package /absolute/path/dsh-openwrite-0.2.25.tgz
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
候选版本未上架前不要使用 `dsh-openwrite@0.2.25` 下载命令。`DSH_HOME` 必须指向实际宿主的 home;默认 `~/.dsh`。Windows 另设 `DSH_BIN` 为已验收宿主运行时的绝对 `lib/bin.js` 路径,使用 Node 直接执行而非 `.cmd` shell。其余平台可通过 DSH_BIN 指定同一受信任 CLI。
|
|
35
|
+
|
|
36
|
+
安装在旁路 profile 中进行;整个旧 profile(包括其他插件、锁文件及设置)在切换前保持可用。只有 OpenWrite 的必要文件和 manifest 校验成功才进入目录切换。切换为两次同盘 rename,**不是整个目录树的单次原子交换**;停机状态避免读取窗口,中断由持久事务记录恢复。锁覆盖本工具的并发操作,不能阻止未经协调的原生安装器或用户重新启动宿主。
|
|
37
|
+
|
|
38
|
+
记录保存在 `<DSH_HOME>/openwrite/upgrades/upgrade_*/`:`operation.json`、`pnpm.log`、成功切换后的 `previous-profile/`。stdout/stderr 实时落盘;安装 180 秒超时终止子进程。保留目录可能占用较多空间,确认升级稳定后再人工清理。
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
# 中断恢复,只恢复同一批准操作;若后来修改过 profile 则拒绝覆盖
|
|
42
|
+
node /path/to/package/scripts/safe-upgrade.mjs --recover upgrade_<实际操作ID>
|
|
43
|
+
|
|
44
|
+
# 损坏 bundle 修复:先检查,再明确应用。仅禁用不完整的 OpenWrite bundle。
|
|
45
|
+
node /path/to/package/scripts/safe-upgrade.mjs --profile desktop --repair
|
|
46
|
+
node /path/to/package/scripts/safe-upgrade.mjs --profile desktop --repair --apply
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
修复会备份 profile manifest,不删除正文、其他插件或设置;不会假装重装已经成功。完整 OpenWrite bundle 不会被该修复入口禁用。之后使用旁路升级重新安装。
|
|
50
|
+
|
|
51
|
+
## 验收与边界
|
|
52
|
+
|
|
53
|
+
自动化覆盖分段续写/去重、版本冲突、术语与 Unicode 位置、快验收及后续完整对账、持双重写锁时纯读、心跳不伪造进度、流式期限、漂移超预算及中断恢复、存档回执、升级失败时旧 profile 不变和非空日志。前端测试覆盖连载体检读取/资产保存/版本化追认;手工浏览器验收仅使用 `~/my_novel`。
|
|
54
|
+
|
|
55
|
+
这些测试不调用付费模型。真实模型达到 10,000 字的稳定率仍由模型决定;系统保证有限续写与草稿留存,不保证模型质量。具象名词密度依赖作者词典;承诺是否兑现未做语义判断。历史横评使用当前正典,不声称复原当时章前状态。伏笔声明边与 DAG 已确认状态分别报告,不自动篡改已收状态。
|
|
56
|
+
|
|
57
|
+
Windows Desktop 的文件句柄冲突与恢复需 Windows 实机补验;本次在 macOS 完成实现和本地回归。原生 `dsh plugin add/update` 与「损坏 bundle 自动跳过并提供修复入口」属于 deepseek-harness 宿主代码,未在这个仓库修复,因此 #98 不应标为全部解决。现有宿主用户必须使用本工具的停机路径,或等宿主更新后再走原生更新。
|
|
58
|
+
|
|
59
|
+
本地回归记录(2026-10-23,macOS arm64):Core 全量 1,424 passed / 32 skipped;后续超时配置校验等定向回归 38 passed、规划转换回归 36 passed;前端 301 passed;managed runtime/策略 24 passed;离线升级 5 passed;跨端契约检查通过。Core wheel 已从独立解压目录验证版本、体检模块及 HTTP 路由。真实服务商调用、Windows 实机和发布操作均未执行。
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
新建作品的验收请求最多等待 60 秒(原为 15 秒),保留成功耗时和失败诊断;不重试写请求,超时仍失败。此调整容纳慢速运行器,不代表初始化耗时问题已被修复。
|
|
8
8
|
|
|
9
|
+
## 0.2.24
|
|
10
|
+
|
|
11
|
+
[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.24) 与 [npm `dsh-openwrite@0.2.24`](https://www.npmjs.com/package/dsh-openwrite/v/0.2.24) 均已发布,npm `latest` 已更新到 0.2.24。直接使用[完整发布 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/39716542888) 验收通过的原始产物,未重新打包。来源提交 `d16623c6b0d2a84d45dffe23c24e641df790175f`,源码 tree `e0b2fd0647114e46e6fd188f4fb32b13fadc6b3d` 与主分支合并提交 `32166de6a3f66d8ebb2e3b5bd3912c1c1236f07b` 一致。npm 包 SHA-256 为 `a9fb766e8277b0f7dfecd9ea2284720512583e766e82515b70a529c967aa0170`;从官方 registry 无认证重新下载后,包字节、SHA-1、SHA-512 integrity 与 CI 产物和 registry 元数据均一致。
|
|
12
|
+
|
|
13
|
+
8 份原生安装/源码报告与 4 份客户端契约报告全部通过,覆盖 Linux、macOS arm64/x64、Windows、Node 22.19/24/26、源码安装、alpha 兼容与精确宿主 `0.1.2-rc.1` / `0.1.5-alpha.1` / `0.2.0-rc.2` / `0.2.1-rc.2`。GitHub 四份公开附件重新下载后均与 CI 原文件逐字节一致;合并后的[主分支完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/39717124199) 也已通过。源码安装命令的超时从两分钟调整为五分钟,保留原有成功条件,并补充退出状态、信号和错误码诊断。
|
|
14
|
+
|
|
15
|
+
修复 #88–#95,合并核心 [PR #96](https://github.com/LiPu-jpg/Openwrite/pull/96) 与插件 [PR #97](https://github.com/LiPu-jpg/Openwrite/pull/97)。捆绑 Core 5.8.11,wheel 来源提交 `596709e3f4bbe4eab7ce59513c9f36c4390ad367` 已包含在 `native-core` 分支,wheel SHA-256 为 `7937914eee6b99cd64fee2ead96a5e6d63e807acae0a610c1c4abf863e814188`。行为与草稿恢复步骤见[修复说明](ISSUES_88_95.md)。
|
|
16
|
+
|
|
17
|
+
核心全量 pytest:1409 passed、32 skipped;wheel 解包后隔离回归:11 passed。插件构建、doctor、生命周期、双 profile、预设、bridge/panel smoke、14 项 epoch、298 项组件、23 项受管理运行时检查及跨仓库 contracts 通过。真实服务商连续请求与 Windows Electron 实机验收未运行,不计入通过。
|
|
18
|
+
|
|
9
19
|
## 0.2.23
|
|
10
20
|
|
|
11
21
|
[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.23) 与 npm `dsh-openwrite@0.2.23` 已发布,npm `latest` 已更新到 0.2.23。使用[主分支完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/39219394324) 验收的原始产物,来源提交 `341408b80075fe4cc429f30a3327bcbdf2e69903`,npm 包 SHA-256 为 `8eb13e3de31b2144f1d5a57094abff38951c0329058520c96331aed03c735470`。8 份安装/源码报告与 4 份宿主客户端报告(含精确 `0.2.1-rc.2`)全部通过;GitHub 四份附件逐一与 CI 原文件比对一致,官方 npm registry 无认证下载包的字节与 SHA-256 也一致。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-openwrite",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
4
4
|
"description": "OpenWrite 长篇小说领域后端接入 DeepSeek Harness:统一创作 Agent 预设 + 原生创作工作台",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"install-dsh": "bash scripts/install.sh",
|
|
15
15
|
"dev": "bash scripts/dev.sh",
|
|
16
16
|
"doctor": "node scripts/plugin-doctor.mjs",
|
|
17
|
-
"test:maintenance": "node --test scripts/plugin-doctor.test.mjs scripts/lifecycle.test.mjs packages/openwrite-bridge/scripts/runtime-lifecycle.test.mjs scripts/maintenance.test.mjs scripts/preset-lifecycle.test.mjs",
|
|
17
|
+
"test:maintenance": "node --test scripts/plugin-doctor.test.mjs scripts/lifecycle.test.mjs packages/openwrite-bridge/scripts/runtime-lifecycle.test.mjs scripts/maintenance.test.mjs scripts/preset-lifecycle.test.mjs scripts/safe-upgrade.test.mjs",
|
|
18
18
|
"check": "npm run check:plugin && npm run test:contracts",
|
|
19
19
|
"test:lifecycle": "node --test scripts/lifecycle.test.mjs",
|
|
20
20
|
"test:runtime": "node --test packages/openwrite-bridge/scripts/runtime-lifecycle.test.mjs",
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"packages/studio-panel/package.json",
|
|
96
96
|
"scripts/dog",
|
|
97
97
|
"scripts/maintenance.mjs",
|
|
98
|
+
"scripts/safe-upgrade.mjs",
|
|
98
99
|
"packages/openwrite-bridge/LICENSE",
|
|
99
100
|
"packages/studio-panel/LICENSE",
|
|
100
101
|
"release/licenses",
|
|
@@ -119,7 +120,8 @@
|
|
|
119
120
|
"@deepseek-ai/dsh-tools": "0.1.2-rc.1 || 0.1.5-alpha.1 || 0.2.0-rc.2 || 0.2.1-rc.2"
|
|
120
121
|
},
|
|
121
122
|
"bin": {
|
|
122
|
-
"openwrite-maintenance": "scripts/maintenance.mjs"
|
|
123
|
+
"openwrite-maintenance": "scripts/maintenance.mjs",
|
|
124
|
+
"openwrite-safe-upgrade": "scripts/safe-upgrade.mjs"
|
|
123
125
|
},
|
|
124
126
|
"repository": {
|
|
125
127
|
"type": "git",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { BackendConnection } from './managed-runtime.js';
|
|
2
|
+
/** Windows roots are case-insensitive and may use either separator. */
|
|
3
|
+
export declare function workspaceRootsEqual(left: string, right: string, platform?: NodeJS.Platform): boolean;
|
|
2
4
|
/**
|
|
3
5
|
* Shared HTTP client for the OpenWrite Studio action surface.
|
|
4
6
|
*
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve, win32 } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
/** Windows roots are case-insensitive and may use either separator. */
|
|
6
|
+
export function workspaceRootsEqual(left, right, platform = process.platform) {
|
|
7
|
+
const normalize = platform === 'win32'
|
|
8
|
+
? (value) => win32.normalize(value).replace(/[\\/]+$/, '').toLowerCase()
|
|
9
|
+
: (value) => resolve(value);
|
|
10
|
+
return normalize(left) === normalize(right);
|
|
11
|
+
}
|
|
4
12
|
/** Header OpenWrite requires on every write request (POST/PUT). */
|
|
5
13
|
const WRITE_HEADER = 'X-OpenWrite-Studio';
|
|
6
14
|
/** HTTP header values are bytes, so Unicode paths need explicit wire encoding.
|
|
@@ -183,9 +191,21 @@ export class StudioClient {
|
|
|
183
191
|
const connection = await this.connect?.();
|
|
184
192
|
const target = connection ? new URL(url.pathname + url.search, connection.baseUrl) : url;
|
|
185
193
|
const headers = connection?.token ? { ...init.headers, Authorization: `Bearer ${connection.token}` } : init.headers;
|
|
186
|
-
|
|
194
|
+
const response = await fetch(target, { ...init, headers, signal: AbortSignal.any(signals) });
|
|
195
|
+
const actual = response.headers.get('X-OpenWrite-Project-Path');
|
|
196
|
+
if (response.ok && actual && this.context?.workspaceRoot) {
|
|
197
|
+
const expected = realpathSync(this.context.workspaceRoot);
|
|
198
|
+
const received = decodeURIComponent(actual);
|
|
199
|
+
if (!workspaceRootsEqual(received, expected)) {
|
|
200
|
+
await response.body?.cancel();
|
|
201
|
+
throw new StudioError('Connected workspace differs from requested project', 409, 'WORKSPACE_CONTEXT_MISMATCH', { requested_path: expected, project_path: received });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return response;
|
|
187
205
|
}
|
|
188
206
|
catch (error) {
|
|
207
|
+
if (error instanceof StudioError)
|
|
208
|
+
throw error;
|
|
189
209
|
if (this.context?.workspaceRoot) {
|
|
190
210
|
try {
|
|
191
211
|
const root = join(this.context.workspaceRoot, '.openwrite', 'transport-errors');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Service } from '@deepseek-ai/cordis';
|
|
2
|
-
import { StudioClient, workspaceRootHeaders, mutationAffectsState } from './client.js';
|
|
2
|
+
import { StudioClient, workspaceRootHeaders, workspaceRootsEqual, mutationAffectsState } from './client.js';
|
|
3
3
|
import { workspaceContextFromExec } from './tools.js';
|
|
4
4
|
export const CONFIG_ROUTE = '/studio-panel/config.json';
|
|
5
5
|
export const API_PROXY_ROUTE = '/studio-panel/api';
|
|
@@ -9,6 +9,8 @@ const PROXY_TIMEOUT_MS = 60_000;
|
|
|
9
9
|
/** Cap for the best-effort upstream context_epoch read; never blocks a snapshot longer. */
|
|
10
10
|
const CONTEXT_EPOCH_TIMEOUT_MS = 5_000;
|
|
11
11
|
const WRITABLE_PATHS = new Set([
|
|
12
|
+
'writing-assets',
|
|
13
|
+
'arc-reconcile',
|
|
12
14
|
'assets',
|
|
13
15
|
'assets/update',
|
|
14
16
|
'assets/package/import',
|
|
@@ -474,6 +476,12 @@ export class NovelDomainService extends Service {
|
|
|
474
476
|
});
|
|
475
477
|
if (lifetime.aborted)
|
|
476
478
|
return;
|
|
479
|
+
const actualRoot = upstream.headers.get('X-OpenWrite-Project-Path');
|
|
480
|
+
if (upstream.ok && actualRoot && !workspaceRootsEqual(decodeURIComponent(actualRoot), resolved.root)) {
|
|
481
|
+
await upstream.body?.cancel();
|
|
482
|
+
sendJson(res, 409, { error: 'Connected workspace differs from requested project', code: 'WORKSPACE_CONTEXT_MISMATCH', details: { requested_path: resolved.root, project_path: decodeURIComponent(actualRoot) } });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
477
485
|
const upstreamBytes = Buffer.from(await upstream.arrayBuffer());
|
|
478
486
|
const bytes = compactTaskList(upstreamBytes, pathPart, method);
|
|
479
487
|
const responseHeaders = {
|
|
@@ -482,6 +490,11 @@ export class NovelDomainService extends Service {
|
|
|
482
490
|
'content-length': String(bytes.length),
|
|
483
491
|
};
|
|
484
492
|
const disposition = upstream.headers.get('content-disposition');
|
|
493
|
+
const instanceId = upstream.headers.get('X-OpenWrite-Instance-Id');
|
|
494
|
+
if (instanceId)
|
|
495
|
+
responseHeaders['X-OpenWrite-Instance-Id'] = instanceId;
|
|
496
|
+
if (actualRoot)
|
|
497
|
+
responseHeaders['X-OpenWrite-Project-Path'] = actualRoot;
|
|
485
498
|
if (disposition !== null)
|
|
486
499
|
responseHeaders['content-disposition'] = disposition;
|
|
487
500
|
res.writeHead(upstream.status, responseHeaders);
|
|
@@ -6,6 +6,7 @@ const reads = new Set([
|
|
|
6
6
|
'novel_revision_get', 'novel_tasks_list', 'novel_continuity', 'novel_diagnostics',
|
|
7
7
|
'novel_research_status', 'novel_research_report', 'novel_model_profiles',
|
|
8
8
|
'novel_manuscript_acceptance', 'novel_export_preflight',
|
|
9
|
+
'novel_manuscript_metrics', 'novel_outline_audit',
|
|
9
10
|
]);
|
|
10
11
|
const generates = new Set([
|
|
11
12
|
'novel_write_chapter', 'novel_review_chapter', 'novel_multi_write', 'novel_task_create',
|
|
@@ -14,6 +15,7 @@ const generates = new Set([
|
|
|
14
15
|
'novel_model_test', 'novel_model_embedding_test',
|
|
15
16
|
]);
|
|
16
17
|
const readActions = {
|
|
18
|
+
novel_writing_assets: ['get'],
|
|
17
19
|
novel_model_benchmark: ['list', 'get', 'options'],
|
|
18
20
|
novel_chapter_run_action: ['list', 'get'],
|
|
19
21
|
novel_rolling_plan_action: ['list', 'get'],
|
|
@@ -178,6 +178,11 @@ function resolveReviewPath(args) {
|
|
|
178
178
|
function objectValue(value) {
|
|
179
179
|
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
180
180
|
}
|
|
181
|
+
function requireRecord(value, name) {
|
|
182
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
183
|
+
throw new Error(`${name} must be a JSON object`);
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
181
186
|
async function deliveryForResponse(client, response, chapterId, signal, threshold) {
|
|
182
187
|
const embedded = objectValue(response)['workspace'];
|
|
183
188
|
const workspace = embedded !== null && typeof embedded === 'object' && !Array.isArray(embedded)
|
|
@@ -979,7 +984,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
979
984
|
'so the downloaded file is bound to the manuscript state that was inspected.',
|
|
980
985
|
parameters: {
|
|
981
986
|
format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
|
|
982
|
-
purpose: { type: 'string', enum: ['backup', 'delivery'], description: 'Export purpose (default "delivery").
|
|
987
|
+
purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
|
|
983
988
|
},
|
|
984
989
|
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
985
990
|
timeoutMs,
|
|
@@ -998,7 +1003,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
998
1003
|
'preflight_revision unchanged to bind the download to that inspected manuscript state. Returns the saved file path.',
|
|
999
1004
|
parameters: {
|
|
1000
1005
|
format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
|
|
1001
|
-
purpose: { type: 'string', enum: ['backup', 'delivery'], description: 'Export purpose (default "delivery").' },
|
|
1006
|
+
purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery").' },
|
|
1002
1007
|
preflight_revision: { type: 'string', description: 'Revision returned by novel_export_preflight for the same format and purpose.' },
|
|
1003
1008
|
},
|
|
1004
1009
|
output: {
|
|
@@ -2174,6 +2179,8 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2174
2179
|
'acknowledge are confirmation-gated by the server.',
|
|
2175
2180
|
parameters: {
|
|
2176
2181
|
action: { type: 'string', required: true, enum: ['baseline', 'external', 'resume', 'acknowledge', 'abandon'], description: 'The reconciliation action.' },
|
|
2182
|
+
mode: { type: 'string', enum: ['full', 'fast'], description: 'resume: fast accepts frozen manuscript without calling a model; derived facts remain stale and can be reconciled later.' },
|
|
2183
|
+
no_model: { type: 'boolean', description: 'external: accept manuscript bytes without extracting facts; does not clear the facts guard.' },
|
|
2177
2184
|
chapter_id: { type: 'string', description: 'Optional chapter id (ch_<digits>); omit for the project-level operation.' },
|
|
2178
2185
|
operation_id: { type: 'string', description: 'Durable operation id to resume or acknowledge.' },
|
|
2179
2186
|
domains: { type: 'array', items: { type: 'string' }, description: 'Affected domains to acknowledge or retry; acknowledge defaults to outline and foreshadowing.' },
|
|
@@ -2186,7 +2193,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2186
2193
|
throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
|
|
2187
2194
|
}
|
|
2188
2195
|
const body = {};
|
|
2189
|
-
for (const key of ['chapter_id', 'operation_id', 'domains', 'confirm']) {
|
|
2196
|
+
for (const key of ['chapter_id', 'operation_id', 'domains', 'confirm', 'mode', 'no_model']) {
|
|
2190
2197
|
const value = args[key];
|
|
2191
2198
|
if (value !== undefined)
|
|
2192
2199
|
body[key] = value;
|
|
@@ -2197,6 +2204,89 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2197
2204
|
return await clientFor(exec).postJson(`/api/manuscript/acceptance/${route}`, body, exec.signal);
|
|
2198
2205
|
},
|
|
2199
2206
|
}));
|
|
2207
|
+
ctx.tools.register(defineTool({
|
|
2208
|
+
name: 'novel_writing_assets',
|
|
2209
|
+
description: 'Read or save shared project terminology, review_terms toggle, concrete noun lexicon, overlap_threshold and character_groups. Anchors are Markdown files in the novel anchor/ directory. Save requires the revision returned by get. Character groups are optional descriptive material; retain identity and canonical state in character cards.',
|
|
2210
|
+
parameters: {
|
|
2211
|
+
action: { type: 'string', required: true, enum: ['get', 'save', 'save_anchor', 'mark_usage'], description: 'Asset operation.' },
|
|
2212
|
+
assets: { type: 'json', description: 'Object: terms [{term, forbidden?, replacement?, min_occurrences?, example?}], character_groups {name: [{id,text}]}, review_terms?, concrete_nouns?, overlap_threshold?.' },
|
|
2213
|
+
expected_revision: { type: 'string', description: 'Revision from get; empty for a new asset table.' },
|
|
2214
|
+
name: { type: 'string', description: 'save_anchor: simple .md filename.' },
|
|
2215
|
+
content: { type: 'string', description: 'save_anchor: Markdown reference prose.' },
|
|
2216
|
+
chapter_id: { type: 'string', description: 'mark_usage: chapter whose optional character details were used.' },
|
|
2217
|
+
groups: { type: 'json', description: 'mark_usage: character name to group IDs used in this chapter.' },
|
|
2218
|
+
},
|
|
2219
|
+
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2220
|
+
timeoutMs,
|
|
2221
|
+
isConcurrencySafe: args => args.action === 'get',
|
|
2222
|
+
async execute(args, exec) {
|
|
2223
|
+
const client = clientFor(exec);
|
|
2224
|
+
if (args.action === 'get')
|
|
2225
|
+
return await client.getJson('/api/writing-assets', {}, exec.signal);
|
|
2226
|
+
if (args.action === 'save_anchor')
|
|
2227
|
+
return await client.postJson('/api/writing-assets', { action: args.action, name: args.name ?? '', content: args.content ?? '', expected_revision: args.expected_revision ?? '' }, exec.signal);
|
|
2228
|
+
if (args.action === 'mark_usage')
|
|
2229
|
+
return await client.postJson('/api/writing-assets', { action: args.action, chapter_id: args.chapter_id ?? '', groups: requireRecord(args.groups, 'groups') }, exec.signal);
|
|
2230
|
+
const assets = requireRecord(args.assets, 'assets');
|
|
2231
|
+
return await client.postJson('/api/writing-assets', { assets: assets, expected_revision: args.expected_revision ?? '' }, exec.signal);
|
|
2232
|
+
},
|
|
2233
|
+
}));
|
|
2234
|
+
ctx.tools.register(defineTool({
|
|
2235
|
+
name: 'novel_manuscript_metrics',
|
|
2236
|
+
description: 'Read deterministic chapter metrics, terminology findings, overlap against anchors and the previous three chapters, duplicate paragraphs and selected character groups. Overlap spans use Unicode codepoint offsets. No model call.',
|
|
2237
|
+
parameters: { chapter_id: { type: 'string', required: true, description: 'Canonical chapter ID.' } },
|
|
2238
|
+
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2239
|
+
timeoutMs, isConcurrencySafe: () => true,
|
|
2240
|
+
async execute(args, exec) {
|
|
2241
|
+
if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
|
|
2242
|
+
throw new Error('Invalid chapter_id');
|
|
2243
|
+
return await clientFor(exec).getJson(`/api/manuscript/${encodeURIComponent(args.chapter_id)}/metrics`, {}, exec.signal);
|
|
2244
|
+
},
|
|
2245
|
+
}));
|
|
2246
|
+
ctx.tools.register(defineTool({
|
|
2247
|
+
name: 'novel_outline_audit',
|
|
2248
|
+
description: 'Read countable chapter-group facts: budgets/actual words, declared promises and function streaks, declared/confirmed payoffs, overdue hooks and drift ledger. Never generates a score or claims prose fulfilled a promise. No model call.',
|
|
2249
|
+
parameters: {
|
|
2250
|
+
group_id: { type: 'string', description: 'Optional sec_* or arc_* group.' },
|
|
2251
|
+
start: { type: 'string', description: 'Optional first chapter ID.' },
|
|
2252
|
+
end: { type: 'string', description: 'Optional last chapter ID.' },
|
|
2253
|
+
},
|
|
2254
|
+
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2255
|
+
timeoutMs, isConcurrencySafe: () => true,
|
|
2256
|
+
async execute(args, exec) {
|
|
2257
|
+
return await clientFor(exec).getJson('/api/outline/audit', { group_id: args.group_id ?? '', start: args.start ?? '', end: args.end ?? '' }, exec.signal);
|
|
2258
|
+
},
|
|
2259
|
+
}));
|
|
2260
|
+
ctx.tools.register(defineTool({
|
|
2261
|
+
name: 'novel_drift_entry',
|
|
2262
|
+
description: 'Record author-observed chapter drift. Saves an immutable outline/manuscript baseline first. More than two unresolved rewrite drifts in a section freezes generation until explicit reconciliation and replanning.',
|
|
2263
|
+
parameters: {
|
|
2264
|
+
group_id: { type: 'string', required: true, description: 'Section ID sec_*.' },
|
|
2265
|
+
chapter_id: { type: 'string', required: true, description: 'Chapter in this section.' },
|
|
2266
|
+
kind: { type: 'string', required: true, enum: ['growth', 'escape', 'dilution', 'snowball'], description: 'Drift kind.' },
|
|
2267
|
+
impact: { type: 'string', required: true, enum: ['local', 'section', 'arc'], description: 'Impact scope.' },
|
|
2268
|
+
rewrite: { type: 'boolean', description: 'Counts toward rewrite budget; default true.' },
|
|
2269
|
+
note: { type: 'string', required: true, description: 'Observed drift and supporting evidence.' },
|
|
2270
|
+
expected_revision: { type: 'integer', required: true, description: 'ledger.revision from outline audit.' },
|
|
2271
|
+
},
|
|
2272
|
+
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2273
|
+
timeoutMs,
|
|
2274
|
+
async execute(args, exec) {
|
|
2275
|
+
return await clientFor(exec).postJson('/api/arc-reconcile', { ...args, action: 'drift_entry' }, exec.signal);
|
|
2276
|
+
},
|
|
2277
|
+
}));
|
|
2278
|
+
ctx.tools.register(defineTool({
|
|
2279
|
+
name: 'novel_arc_reconcile',
|
|
2280
|
+
description: 'Resolve selected drift entries by absorbing authored outline metadata, scheduling recovery in a later chapter, or freezing. Absorption requires concrete outline_updates and outline_revision; baseline is saved before edits. resume=true explicitly resumes a replanned group within budget. Does not rewrite manuscript.',
|
|
2281
|
+
parameters: {
|
|
2282
|
+
data: { type: 'json', required: true, description: 'Object: group_id, expected_revision, entry_ids[], decision absorb/recover/freeze, confirm:true. absorb: outline_revision, outline_updates[{node_id,metadata}], resume?. recover: plan, target_chapter_id.' },
|
|
2283
|
+
},
|
|
2284
|
+
output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
|
|
2285
|
+
timeoutMs,
|
|
2286
|
+
async execute(args, exec) {
|
|
2287
|
+
return await clientFor(exec).postJson('/api/arc-reconcile', { ...requireRecord(args.data, 'data'), action: 'arc_reconcile' }, exec.signal);
|
|
2288
|
+
},
|
|
2289
|
+
}));
|
|
2200
2290
|
// ── style sources, reference library, runtime skills, rules ──────────────
|
|
2201
2291
|
ctx.tools.register(defineTool({
|
|
2202
2292
|
name: 'novel_source_action',
|
|
@@ -2393,13 +2483,14 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2393
2483
|
run_id: { type: 'string', description: 'Benchmark run id for get.' },
|
|
2394
2484
|
task_type: { type: 'string', enum: ['chapter', 'outline'], description: 'Run target: chapter (default) or outline. Outline supports framework mode only.' },
|
|
2395
2485
|
chapter_id: { type: 'string', description: 'Chapter task target: next (default) or an available canonical chapter_id from options, e.g. ch_003. Unavailable targets are rejected without fallback; inspect availability/reason first.' },
|
|
2486
|
+
allow_historical: { type: 'boolean', description: 'Explicit isolated rewrite of an already written chapter using current canon. This is not a reconstructed pre-chapter baseline. Source manuscript and ledgers remain unchanged.' },
|
|
2396
2487
|
outline_start_chapter: { type: 'integer', description: 'Outline task first chapter number, inclusive, 1-100000. Omit to continue after the last outline chapter.' },
|
|
2397
2488
|
outline_chapter_count: { type: 'integer', description: 'Outline task range length including its starting chapter, 1-20 (default 3).' },
|
|
2398
2489
|
writer_profile_ids: { type: 'array', items: { type: 'string' }, description: 'Writing profiles to compare (run).' },
|
|
2399
2490
|
reviewer_profile_ids: { type: 'array', items: { type: 'string' }, description: 'Independent blind-review profiles (run).' },
|
|
2400
2491
|
execution_mode: { type: 'string', enum: ['framework', 'creative'], description: 'Execution path: full framework (default); creative diagnostic is available only for chapter tasks. Read task pipelines with options.' },
|
|
2401
2492
|
repeats: { type: 'integer', description: 'Repeats per writing profile, 1-5.' },
|
|
2402
|
-
target_words: { type: 'integer', description: 'Chapter candidate target length, 200-
|
|
2493
|
+
target_words: { type: 'integer', description: 'Chapter candidate target length, 200-50000; use outline_chapter_count for outline range.' },
|
|
2403
2494
|
concurrency: { type: 'integer', description: 'Concurrent model jobs, 1-4.' },
|
|
2404
2495
|
limit: { type: 'integer', description: 'List limit, default 20.' },
|
|
2405
2496
|
},
|
|
@@ -2429,7 +2520,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
|
|
|
2429
2520
|
writer_profile_ids: args.writer_profile_ids,
|
|
2430
2521
|
reviewer_profile_ids: args.reviewer_profile_ids,
|
|
2431
2522
|
};
|
|
2432
|
-
for (const key of ['task_type', 'chapter_id', 'outline_start_chapter', 'outline_chapter_count', 'execution_mode', 'repeats', 'target_words', 'concurrency']) {
|
|
2523
|
+
for (const key of ['task_type', 'chapter_id', 'allow_historical', 'outline_start_chapter', 'outline_chapter_count', 'execution_mode', 'repeats', 'target_words', 'concurrency']) {
|
|
2433
2524
|
const value = args[key];
|
|
2434
2525
|
if (value !== undefined)
|
|
2435
2526
|
body[key] = value;
|