dsh-openwrite 0.2.28 → 0.2.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +49 -8
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/companions.mjs +508 -0
- package/cordis.patch.yml +0 -2
- package/docs/CLOSED_ISSUE_FOLLOWUPS.md +1 -1
- package/docs/COMPANION_PLUGINS.md +142 -0
- package/docs/CONTEXT_SLOTS.md +1 -1
- package/docs/COST_MEASUREMENT.md +2 -2
- package/docs/DEVELOPMENT.md +6 -11
- package/docs/DSH_STORE.md +14 -2
- package/docs/EDITOR_SAFETY_REPAIRS.md +2 -2
- package/docs/INSTALL.md +112 -4
- package/docs/ISSUES_121_123.md +4 -4
- package/docs/ISSUES_140_142.md +58 -0
- package/docs/ISSUES_149_150_151.md +77 -0
- package/docs/ISSUE_123_FOLLOWUP.md +3 -2
- package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
- package/docs/LEARNING.md +1 -1
- package/docs/MOBILE_READINESS.md +62 -0
- package/docs/PLUGIN_MAINTENANCE.md +1 -3
- package/docs/RELEASE_ACCEPTANCE.md +20 -3
- package/docs/REVIEW_ROUND_22.md +67 -0
- package/docs/REVIEW_ROUND_23.md +48 -0
- package/docs/REVIEW_ROUND_25.md +51 -0
- package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
- package/docs/TOOLS.md +181 -604
- package/docs/WORKFLOWS.md +5 -5
- package/package.json +17 -21
- package/packages/openwrite-bridge/lib/client.d.ts +0 -1
- package/packages/openwrite-bridge/lib/client.js +90 -9
- package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
- package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
- package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
- package/packages/openwrite-bridge/lib/dog-review.js +15 -3
- package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
- package/packages/openwrite-bridge/lib/domain.js +250 -29
- package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
- package/packages/openwrite-bridge/lib/host-llm.js +657 -0
- package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
- package/packages/openwrite-bridge/lib/host-web.js +166 -0
- package/packages/openwrite-bridge/lib/index.d.ts +4 -0
- package/packages/openwrite-bridge/lib/index.js +150 -6
- package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
- package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
- package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
- package/packages/openwrite-bridge/lib/tools.js +1223 -1227
- package/packages/openwrite-bridge/package.json +7 -5
- package/packages/studio-panel/lib/client.js +123207 -121534
- package/packages/studio-panel/lib/client.js.map +1 -1
- package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
- package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
- package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
- package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
- package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
- package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
- package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
- package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
- package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
- package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
- package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
- package/packages/studio-panel/package.json +5 -3
- package/plugin.mjs +159 -20
- package/preset-definition.mjs +144 -4
- package/presets/openwrite/agent.cordis.yml +7 -9
- package/presets/openwrite/ops-integration.mjs +28 -0
- package/presets/openwrite/preset.yml +2 -1
- package/presets/openwrite/skills/canon/SKILL.md +3 -3
- package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
- package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
- package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
- package/presets/openwrite/skills/export-book/SKILL.md +3 -3
- package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
- package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
- package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
- package/presets/openwrite/skills/learn/SKILL.md +3 -3
- package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
- package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
- package/presets/openwrite/skills/progress/SKILL.md +1 -1
- package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
- package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
- package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
- package/presets/openwrite/skills/style-system/SKILL.md +13 -6
- package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
- package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
- package/presets/openwrite/skills/world-query/SKILL.md +16 -11
- package/presets/openwrite/skills/write-next/SKILL.md +1 -1
- package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
- package/release/runtime-manifest.json +4 -13
- package/release/source.json +21 -21
- package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
- package/scripts/maintenance.mjs +67 -7
- package/scripts/plugin-doctor.mjs +179 -0
- package/scripts/prepare.mjs +11 -2
- package/scripts/safe-upgrade.mjs +38 -5
- package/dog-tools.mjs +0 -13
- package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
- package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
- package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
- package/scripts/dog/delivery-stage.js +0 -32
- package/scripts/dog/import-chapter.js +0 -20
- package/scripts/dog/import-record.js +0 -35
- package/scripts/dog/review-dimension.js +0 -41
- package/scripts/dog/review-record.js +0 -42
- package/vendor/dsh-dog/LICENSE +0 -29
- package/vendor/dsh-dog/README.md +0 -114
- package/vendor/dsh-dog/UPSTREAM.md +0 -30
- package/vendor/dsh-dog/cordis.patch.yml +0 -14
- package/vendor/dsh-dog/lib/client.js +0 -2960
- package/vendor/dsh-dog/lib/client.js.map +0 -1
- package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
- package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
- package/vendor/dsh-dog/lib/core.d.ts +0 -2
- package/vendor/dsh-dog/lib/core.js +0 -2
- package/vendor/dsh-dog/lib/index.d.ts +0 -143
- package/vendor/dsh-dog/lib/index.js +0 -1230
- package/vendor/dsh-dog/package.json +0 -110
- package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
- package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
- package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
- package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
- package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
package/LICENSE
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright
|
|
189
|
+
Copyright 2026 The OpenWrite Project Authors
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<p align="center">在 DeepSeek Harness 中规划、写作、审稿和管理长篇小说。</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
|
-
<a href="package.json"><img src="https://img.shields.io/badge/dsh-0.2.1--rc.
|
|
13
|
+
<a href="package.json"><img src="https://img.shields.io/badge/dsh-0.2.1--rc.5-2563eb" alt="dsh 0.2.1-rc.5"></a>
|
|
14
14
|
<a href="package.json"><img src="https://img.shields.io/badge/Node-%E2%89%A522.19-15803d" alt="Node >= 22.19"></a>
|
|
15
15
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-0f766e" alt="Apache-2.0"></a>
|
|
16
16
|
<a href="https://dsh-plugin.org/plugins/lipu-jpg/openwrite"><img src="https://dsh-plugin.org/badges/listed.svg" alt="Listed on dsh-plugin.org"></a>
|
|
@@ -27,9 +27,48 @@
|
|
|
27
27
|
这是 [OpenWrite](https://github.com/LiPu-jpg/Openwrite/tree/native-core) 的 dsh 插件版本。
|
|
28
28
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供 Agent 与交互界面,OpenWrite Core 提供小说领域能力;人物、设定、大纲、正文和评审保存在本地作品目录。
|
|
29
29
|
|
|
30
|
-
## 开发中:0.2.
|
|
30
|
+
## 开发中:0.2.29(未发布)
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
增加 `pythonPath` / `OPENWRITE_PYTHON` 和显式依赖目录配置,允许使用已准备依赖的 Python,
|
|
33
|
+
跳过 uv 与解释器下载;补充 Android/Termux 的本机 external Studio 步骤,以及旧版安装选择诊断。
|
|
34
|
+
配置和边界见 [安装说明](docs/INSTALL.md#bring-your-own-python)。同时补齐手机后台输入保留、独立 Studio 正文草稿恢复、触屏选区与下载,并加入 Chromium / WebKit 发布回归,见 [手机验收说明](docs/MOBILE_READINESS.md)。正式可安装版本仍为下方的 0.2.28;
|
|
35
|
+
同时修复审稿证据漏填误判与机器批注污染,候选捆绑 Core 5.8.22,见 [#146 修复说明](docs/ISSUE_146_REVIEW_EVIDENCE.md)。
|
|
36
|
+
Android 完整工具验收与宿主升级保护发布继续在 [#123](https://github.com/LiPu-jpg/Openwrite/issues/123) 跟踪。
|
|
37
|
+
|
|
38
|
+
## 0.2.33
|
|
39
|
+
|
|
40
|
+
- **深度研究搜索默认走宿主 DSH**:托管运行时把 `ctx.web` 的搜索/抓取经环回中继交给 Core,默认 provider `dsh`,
|
|
41
|
+
不需要第三方搜索 Key;外部 Studio 仍默认 `bocha`。
|
|
42
|
+
- **研究页新增搜索设置**:提供方、API Key、记住 Key;`dsh` 显示无需 Key。保存与 `novel_research_settings_save`
|
|
43
|
+
同路由,设置按作品作用域落盘。
|
|
44
|
+
- 新增宿主 `host-web.ts` 中继(`/search`、`/fetch`、Bocha 兼容 `/bocha`,仅 127.0.0.1 + 机器 token)和 Core
|
|
45
|
+
handshake;DeepResearch 仍可选,首次使用需在 integration 目录 `pnpm install --frozen-lockfile`。
|
|
46
|
+
- 搜索默认值放到左下角 **OpenWrite → 设置 → 搜索**(机器级默认,新作品生效;单个作品可在研究页覆盖)。
|
|
47
|
+
- 研究 `quality` 在启动子进程前校验为 `advisory` / `balanced` / `strict`。
|
|
48
|
+
- 修复非 ASCII `Authorization` 头返回 500 的问题,认证比较改为 UTF-8 bytes。
|
|
49
|
+
- 左下角设置页视觉重做:搜索 provider 卡片、胶囊 Tab、卡片化配置块与主渐变保存按钮,对齐 dsh-our-free-model。
|
|
50
|
+
|
|
51
|
+
## 0.2.30
|
|
52
|
+
|
|
53
|
+
- **联动插件可执行接入**:`dsh-our-free-model` 接入按钮现在写 profile 并把写作/评审路由切到该模型(其余路由保留、二次确认、部分失败如实报告);`dsh-ops` 的挂载计划(文件工具/渐进发现/无 shell/不替换官方工具)显示在「联动插件」区;`dsh-dog` 新增非斜杠技能 `dog-delivery-verification`,把正文与评审证据写成 handoff 图交给 `dog_validate`/`dog_create`/`dog_run` 独立验收。详见 [联动插件说明](docs/COMPANION_PLUGINS.md)。
|
|
54
|
+
|
|
55
|
+
本轮候选还包含两项发布阻塞修复,见 [#149/#150/#151 修复说明](docs/ISSUES_149_150_151.md):
|
|
56
|
+
|
|
57
|
+
- 兼容精确宿主 dsh `0.2.1-rc.4` 与 `0.2.1-rc.5`,修复 `plugin does not support the DSH version in use` 直接拒装([#150](https://github.com/LiPu-jpg/Openwrite/issues/150)/[#151](https://github.com/LiPu-jpg/Openwrite/issues/151)/[#142](https://github.com/LiPu-jpg/Openwrite/issues/142))。
|
|
58
|
+
- 新建会话选择器不再为每个已发布版本注册预设别名,只保留会话真实引用过的旧版本([#149](https://github.com/LiPu-jpg/Openwrite/issues/149));工作台入口改用稳定预设 id。
|
|
59
|
+
|
|
60
|
+
本轮候选还加入**可选联动插件**支持,见 [联动插件说明](docs/COMPANION_PLUGINS.md):检测到 [dsh-ops](https://github.com/T-Auto/dsh-ops)
|
|
61
|
+
时,用其官方预设接口挂载有界文件工具与渐进发现;检测到 [dsh-our-free-model](https://github.com/Ebony-Vinyl/dsh-our-free-model) 时,
|
|
62
|
+
在工作台「联动插件」区显示状态与接入提示。两者均为可选项,未安装时行为与之前一致;任何判定失败都回退到 OpenWrite 自己的实现。
|
|
63
|
+
同时**移除了内置的 DoG 引擎**(原 `vendor/dsh-dog`):包里不再有 `dog_*` 工具与「DoG 验收」入口,
|
|
64
|
+
三个查询技能改为直接读 OpenWrite 自己的评审/交付/导入产物;需要 DoG 图形验证可自行安装
|
|
65
|
+
[@dsh-external/dsh-dog](https://github.com/Fun10165/dsh-dog)。
|
|
66
|
+
|
|
67
|
+
## 0.2.28
|
|
68
|
+
|
|
69
|
+
0.2.28 捆绑 Core 5.8.20,最终 22 项 CI 全部通过;修复正文最后输入漏存、审稿修订越过选区,以及真实 Markdown 编辑器格式转换导致的选区拒绝,见 [正文编辑与审稿补修](docs/EDITOR_SAFETY_REPAIRS.md)。并包含前轮 [选区与升级补修](docs/SELECTION_UPGRADE_REPAIRS.md)、[写作与审稿改进](docs/ISSUES_121_123.md)。
|
|
70
|
+
|
|
71
|
+
[GitHub 0.2.28](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.28) 与 [npm 0.2.28](https://www.npmjs.com/package/dsh-openwrite/v/0.2.28) 均已发布,npm `latest` 已更新为 0.2.28;两处公开下载包均与 CI 原始安装包逐字节一致。#123 的宿主正式发布和 Android/Termux 真机两项仍未完成,作为已知限制继续跟踪。
|
|
33
72
|
|
|
34
73
|
## 0.2.27
|
|
35
74
|
|
|
@@ -46,12 +85,12 @@ Android/ARM 外部 Studio 诊断,以及连载写作可靠性、完整上下文
|
|
|
46
85
|
|
|
47
86
|
## 快速安装
|
|
48
87
|
|
|
49
|
-
**0.2.
|
|
88
|
+
**0.2.28 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3**。五个版本均已完成 Web 工作台渲染与 DoG 数据读取验证;三系统原生安装和 Electron 回归通过,报告者也补齐了 Windows 历史会话恢复实测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
|
|
50
89
|
|
|
51
|
-
正式发行状态以 [npm 版本列表](https://www.npmjs.com/package/dsh-openwrite?activeTab=versions) 和 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 为准。以下是 0.2.
|
|
90
|
+
正式发行状态以 [npm 版本列表](https://www.npmjs.com/package/dsh-openwrite?activeTab=versions) 和 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 为准。以下是 0.2.28 精确版本的安装命令;发布时两处使用同一份验收产物:
|
|
52
91
|
|
|
53
92
|
```sh
|
|
54
|
-
dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.
|
|
93
|
+
dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.28/dsh-openwrite-0.2.28.tgz
|
|
55
94
|
dsh web
|
|
56
95
|
```
|
|
57
96
|
|
|
@@ -81,6 +120,8 @@ dsh web
|
|
|
81
120
|
|
|
82
121
|
### dsh 0.2 桌面端升级
|
|
83
122
|
|
|
123
|
+
`0.2.28`(Core 5.8.20)已发布。升级前完全退出 Desktop(含托盘)及 dsh,备份 profile;宿主原生升级保护尚未正式发布,停机升级工具及限制见 [升级说明](docs/ISSUES_98_100.md#离线升级和损坏修复)。完成后重启宿主并刷新界面。
|
|
124
|
+
|
|
84
125
|
`0.2.27`(Core 5.8.16)包含 Go 工作台认证、评审输出预算及后台重试补漏,并保留 #105–#113 与 #103/#104 的既有修复;原始场景仍待验证的条目见 [#123](https://github.com/LiPu-jpg/Openwrite/issues/123)。升级前完全退出 dsh/桌面客户端;安装后重新启动并刷新浏览器。原有作品、凭据和会话继续保留,历史版本预设用于恢复旧会话。受限平台使用外部 Studio,OpenCode Go 的服务商使用范围和配置要求见上述文档。
|
|
85
126
|
|
|
86
127
|
`0.2.25`(Core 5.8.12)已在 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.25) 与 [npm](https://www.npmjs.com/package/dsh-openwrite/v/0.2.25) 发布,新增连载体检、分段续写、共享术语/范本、章组审计和漂移裁决,并修复验收与任务可观测性。范围、离线升级工具及宿主侧限制见 [#98–#100 实现说明](docs/ISSUES_98_100.md),完整验收见 [发布验收](docs/RELEASE_ACCEPTANCE.md#025)。
|
|
@@ -183,7 +224,7 @@ dsh web
|
|
|
183
224
|
packages/openwrite-bridge/ 小说工具与后端桥接
|
|
184
225
|
packages/studio-panel/ dsh 原生工作台
|
|
185
226
|
presets/openwrite/ OpenWrite 创作预设与技能
|
|
186
|
-
scripts/
|
|
227
|
+
scripts/ 安装、启动与检查
|
|
187
228
|
conductor/ 连续写章、评审与修订编排
|
|
188
229
|
```
|
|
189
230
|
|
|
@@ -193,7 +234,7 @@ conductor/ 连续写章、评审与修订编排
|
|
|
193
234
|
## 许可与来源
|
|
194
235
|
|
|
195
236
|
项目采用 [Apache-2.0](LICENSE)。`oh-story-*` 技能和随包编辑器保留各自目录中的原有许可证。
|
|
196
|
-
Logo 基于 OpenWrite `native-core` 分支,已调整深浅主题配色;基于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
|
237
|
+
Logo 基于 OpenWrite `native-core` 分支,已调整深浅主题配色;基于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),图谱验证可选装第三方 [dsh-dog](https://github.com/Fun10165/dsh-dog)(内置引擎已移除)。
|
|
197
238
|
|
|
198
239
|
生态目录:[dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI) · [社区插件市场](https://dshtui.com/plugins/)。OpenWrite 当前面向 DeepSeek Harness 的 `web` profile,使用 `dsh web` 打开创作工作台;尚未验证 dsh-TUI 界面兼容性。
|
|
199
240
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# Third-party components
|
|
2
2
|
|
|
3
3
|
OpenWrite is Apache-2.0. The separately maintained Studio panel retains its MIT
|
|
4
|
-
license.
|
|
5
|
-
and integration changes are in vendor/dsh-dog/LICENSE and UPSTREAM.md.
|
|
4
|
+
license.
|
|
6
5
|
|
|
7
6
|
The editor ships Vditor and its auxiliary files with the upstream license in
|
|
8
7
|
packages/studio-panel/vendor/vditor/LICENSE. OpenWrite preset skills retain
|
|
@@ -21,7 +20,9 @@ they are not inlined into the plugin's browser code.
|
|
|
21
20
|
The release includes an OpenWrite-built cryptography 50.0.1 wheel for macOS
|
|
22
21
|
x86_64 because upstream discontinued Intel Mac wheels. It is built from the
|
|
23
22
|
SHA-256 pinned upstream source with statically linked OpenSSL 3.6.3. The wheel
|
|
24
|
-
retains upstream licenses; OpenSSL's full license is in
|
|
23
|
+
retains upstream licenses; OpenSSL's full license is in
|
|
24
|
+
release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt (Apache License 2.0,
|
|
25
|
+
reproduced verbatim from the wheel with its SHA-256 recorded in that file).
|
|
25
26
|
Source hash, toolchain, linkage inspection and the native CI build URL are in
|
|
26
27
|
release/wheels/provenance.json. No cryptography source code was modified.
|
|
27
28
|
|
package/companions.mjs
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// Companion-plugin detection. OpenWrite ships its own DoG engine and works
|
|
2
|
+
// without any companion; when a companion plugin is installed in the same
|
|
3
|
+
// profile AND its declared host range covers the running host, the companion's
|
|
4
|
+
// newest implementation is used instead of ours. Every unknown answer is
|
|
5
|
+
// fail-closed: detection problems keep our own implementation.
|
|
6
|
+
import { readFile, readdir, stat, mkdir, writeFile, realpath } from 'node:fs/promises'
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { dirname, join, resolve } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Bundle names that identify each companion, with and without scope. */
|
|
12
|
+
const companionNames = {
|
|
13
|
+
dog: ['@dsh-external/dsh-dog'],
|
|
14
|
+
ops: ['dsh-ops'],
|
|
15
|
+
freeModel: ['dsh-our-free-model', '@ebony-vinyl/dsh-our-free-model'],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The host constraint a companion actually declares.
|
|
20
|
+
*
|
|
21
|
+
* Most plugins declare `peerDependencies["@deepseek-ai/dsh"]`, and the host reads
|
|
22
|
+
* exactly that. Some (dsh-dog 1.4.x) declare only `@deepseek-ai/dsh-*`
|
|
23
|
+
* sub-packages instead; the host needs every one of them, so their ranges are the
|
|
24
|
+
* real constraint. Treating a missing `@deepseek-ai/dsh` peer as "no constraint"
|
|
25
|
+
* would report such a plugin usable on hosts where the host itself refuses it.
|
|
26
|
+
*/
|
|
27
|
+
function hostConstraint(manifest) {
|
|
28
|
+
const peers = manifest?.peerDependencies ?? {}
|
|
29
|
+
const direct = peers['@deepseek-ai/dsh']
|
|
30
|
+
if (typeof direct === 'string' && direct !== '') return { range: direct, source: '@deepseek-ai/dsh' }
|
|
31
|
+
const subpackage = Object.entries(peers)
|
|
32
|
+
.filter(([name]) => name.startsWith('@deepseek-ai/dsh-'))
|
|
33
|
+
.map(([, range]) => String(range))
|
|
34
|
+
if (subpackage.length > 0) return { range: subpackage.join(' '), source: '@deepseek-ai/dsh-*' }
|
|
35
|
+
return { range: null, source: null }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The public entry each companion must expose for OpenWrite to use it. A
|
|
40
|
+
* companion that is installed but does not expose its entry (an older release,
|
|
41
|
+
* or one that moved the API) is treated as absent: mounting a row whose import
|
|
42
|
+
* fails would take the whole preset composition down with it.
|
|
43
|
+
*/
|
|
44
|
+
const companionEntries = { ops: 'dsh-ops/integration' }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The exact dsh-ops integration OpenWrite mounts. Detection builds it too, so an
|
|
48
|
+
* upstream release that stops accepting these options is reported instead of
|
|
49
|
+
* breaking the preset composition at load time.
|
|
50
|
+
*/
|
|
51
|
+
/** The exact options OpenWrite passes to the dsh-ops integration factory. */
|
|
52
|
+
export const OPENWRITE_OPS_OPTIONS = Object.freeze({
|
|
53
|
+
preset: 'openwrite',
|
|
54
|
+
files: true,
|
|
55
|
+
discovery: true,
|
|
56
|
+
presentation: 'native',
|
|
57
|
+
replaceOfficial: false,
|
|
58
|
+
shell: false,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const opsIntegration = factory => factory({ ...OPENWRITE_OPS_OPTIONS })
|
|
62
|
+
|
|
63
|
+
/** The small, serializable summary of a resolved dsh-ops plan. */
|
|
64
|
+
export function summarizeOpsPlan(plan) {
|
|
65
|
+
return {
|
|
66
|
+
files: plan.files === true,
|
|
67
|
+
discovery: plan.discovery === true,
|
|
68
|
+
presentation: String(plan.presentation ?? ''),
|
|
69
|
+
shell: plan.shell === true,
|
|
70
|
+
replaceOfficial: plan.replaceOfficial === true,
|
|
71
|
+
residentTools: plan.residentTools === true,
|
|
72
|
+
residentNames: Array.isArray(plan.residentNames) ? plan.residentNames.length : 0,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
|
|
77
|
+
|
|
78
|
+
/** Compare two parsed versions, prerelease identifiers included. */
|
|
79
|
+
function compareVersions(a, b) {
|
|
80
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
81
|
+
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
|
|
82
|
+
}
|
|
83
|
+
const [x, y] = [a.pre, b.pre]
|
|
84
|
+
if (x.length === 0 && y.length === 0) return 0
|
|
85
|
+
if (x.length === 0) return 1
|
|
86
|
+
if (y.length === 0) return -1
|
|
87
|
+
for (let index = 0; index < Math.max(x.length, y.length); index += 1) {
|
|
88
|
+
if (index >= x.length) return -1
|
|
89
|
+
if (index >= y.length) return 1
|
|
90
|
+
const left = x[index], right = y[index]
|
|
91
|
+
if (left === right) continue
|
|
92
|
+
const numeric = /^\d+$/.test(left) && /^\d+$/.test(right)
|
|
93
|
+
if (numeric) return Number(left) < Number(right) ? -1 : 1
|
|
94
|
+
return left < right ? -1 : 1
|
|
95
|
+
}
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse `1.2.3-rc.4` into comparable parts; returns null when unparseable. */
|
|
100
|
+
function parseVersion(value) {
|
|
101
|
+
const match = /^(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec((value ?? '').trim())
|
|
102
|
+
if (!match) return null
|
|
103
|
+
const wildcard = part => part === undefined || /[xX*]/.test(part)
|
|
104
|
+
// `1.x.3` is not a version spelling npm accepts.
|
|
105
|
+
if (wildcard(match[2]) && match[3] !== undefined && !wildcard(match[3])) return null
|
|
106
|
+
if (wildcard(match[2]) && match[4] !== undefined) return null
|
|
107
|
+
return { major: +match[1], minor: +(wildcard(match[2]) ? 0 : match[2]), patch: +(wildcard(match[3]) ? 0 : match[3]),
|
|
108
|
+
pre: match[4] ? match[4].split('.') : [], partial: 3 - [match[2], match[3]].filter(wildcard).length }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One comparator: an optional operator plus its version spelling. */
|
|
112
|
+
function parseComparator(comparator) {
|
|
113
|
+
const match = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(comparator.trim())
|
|
114
|
+
if (!match) return null
|
|
115
|
+
const version = parseVersion(match[2].trim().replace(/^[vV]/, ''))
|
|
116
|
+
if (!version) return null
|
|
117
|
+
return { op: match[1] ?? '=', version }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The exclusive end of a spelled version prefix (`0.3` -> 0.4.0-0, `1` -> 2.0.0-0). */
|
|
121
|
+
function upperBoundOf(spelling) {
|
|
122
|
+
const version = parseVersion(spelling.replace(/^[vV]/, ''))
|
|
123
|
+
if (!version) return null
|
|
124
|
+
if (version.partial === 3) return { ...version, pre: ['0'] }
|
|
125
|
+
return version.partial === 1
|
|
126
|
+
? { major: version.major + 1, minor: 0, patch: 0, pre: ['0'] }
|
|
127
|
+
: { major: version.major, minor: version.minor + 1, patch: 0, pre: ['0'] }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Expand one comparator (`1.2.3`, `^1.2.3`, `~1.2.3`, `>=1.2.3`, `*`) into bounds. */
|
|
131
|
+
function expandComparator(comparator) {
|
|
132
|
+
const trimmed = comparator.trim()
|
|
133
|
+
// `x`, `*`, `x.x.x` and most operators applied to them accept any version;
|
|
134
|
+
// the strict `>x` / `<x` accept none.
|
|
135
|
+
const wildcard = /^((?:>=|<=|>|<|=|\^|~)?)\s*[xX*](?:\.[xX*]){0,2}$/.exec(trimmed)
|
|
136
|
+
if (trimmed === '' || wildcard) {
|
|
137
|
+
const strict = wildcard !== null && (wildcard[1] === '>' || wildcard[1] === '<')
|
|
138
|
+
return strict ? [] : [{ op: '>=', version: { major: 0, minor: 0, patch: 0, pre: [] } }]
|
|
139
|
+
}
|
|
140
|
+
const parsed = parseComparator(trimmed)
|
|
141
|
+
if (!parsed) return null
|
|
142
|
+
const { op, version } = parsed
|
|
143
|
+
const upper = (major, minor, patch) => ({ op: '<', version: { major, minor, patch, pre: ['0'] } })
|
|
144
|
+
if (op !== '^' && op !== '~') {
|
|
145
|
+
// npm X-range semantics for a partial spelling, taken from semver's own Range
|
|
146
|
+
// expansion (verified comparator by comparator):
|
|
147
|
+
// `0.2` -> >=0.2.0 <0.3.0-0 `<=0.2` -> <0.3.0-0
|
|
148
|
+
// `<0.3` -> <0.3.0-0 `<=0.3` -> <0.4.0-0
|
|
149
|
+
// `>0.2` -> >=0.3.0 `>=0.3` -> >=0.3.0
|
|
150
|
+
// `>0.3` -> >=0.4.0 `<1` -> <1.0.0-0
|
|
151
|
+
// `<=0` -> <1.0.0-0 `>0` -> >=1.0.0
|
|
152
|
+
// `<` bounds at the spelled prefix; `<=`, `>` and `>=` bound one component
|
|
153
|
+
// further. Zero-filling the bound instead would make `>0.2` accept 0.2.5
|
|
154
|
+
// (fail open) and `<=0.2` reject it (fail closed).
|
|
155
|
+
if (version.partial < 3) {
|
|
156
|
+
const start = { major: version.major, minor: version.minor, patch: version.patch, pre: [] }
|
|
157
|
+
const end = version.partial === 1
|
|
158
|
+
? { major: version.major + 1, minor: 0, patch: 0, pre: ['0'] }
|
|
159
|
+
: { major: version.major, minor: version.minor + 1, patch: 0, pre: ['0'] }
|
|
160
|
+
const here = { major: version.major, minor: version.minor, patch: 0, pre: ['0'] }
|
|
161
|
+
if (op === '=') return [{ op: '>=', version: start }, { op: '<', version: end }]
|
|
162
|
+
if (op === '>') return [{ op: '>=', version: { ...end, pre: [] } }]
|
|
163
|
+
if (op === '<') return [{ op: '<', version: here }]
|
|
164
|
+
if (op === '<=') return [{ op: '<', version: end }]
|
|
165
|
+
// `>=0.3` starts AT the spelled prefix; `>0.3` starts one component later.
|
|
166
|
+
return [{ op: '>=', version: op === '>=' ? start : end }]
|
|
167
|
+
}
|
|
168
|
+
return [{ op, version }]
|
|
169
|
+
}
|
|
170
|
+
const floor = { op: '>=', version }
|
|
171
|
+
// npm X-ranges inside a caret/tilde: `^0` and `~0` both stop at 1.0.0.
|
|
172
|
+
if (version.partial === 1) return [floor, upper(version.major + 1, 0, 0)]
|
|
173
|
+
if (op === '~') return [floor, upper(version.major, version.minor + 1, 0)]
|
|
174
|
+
if (op === '^' && version.partial === 2 && version.major === 0) return [floor, upper(0, version.minor + 1, 0)]
|
|
175
|
+
if (version.major > 0) return [floor, upper(version.major + 1, 0, 0)]
|
|
176
|
+
if (version.minor > 0) return [floor, upper(0, version.minor + 1, 0)]
|
|
177
|
+
return [floor, upper(0, 0, version.patch + 1)]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Split one `||` alternative into comparators, keeping `>= 1.2.3` together. */
|
|
181
|
+
function expandAlternative(alternative) {
|
|
182
|
+
const trimmed = alternative.trim()
|
|
183
|
+
if (trimmed === '') return [{ op: '>=', version: { major: 0, minor: 0, patch: 0, pre: [] } }]
|
|
184
|
+
const comparators = []
|
|
185
|
+
const verbatim = []
|
|
186
|
+
let cursor = 0
|
|
187
|
+
for (const match of trimmed.matchAll(/(>=|<=|>|<|=|\^|~)?\s*(v?[0-9xX*][^\s]*)/g)) {
|
|
188
|
+
verbatim.push({ op: match[1] ?? '=', version: match[2].replace(/^[vV]/, '') })
|
|
189
|
+
// Anything but whitespace between two comparators is a spelling we refuse,
|
|
190
|
+
// except the lone `-` of a hyphen range (`1.2.3 - 2.3.4`).
|
|
191
|
+
const gap = trimmed.slice(cursor, match.index)
|
|
192
|
+
if (gap.trim() !== '' && gap.trim() !== '-') return null
|
|
193
|
+
const expanded = expandComparator(`${match[1] ?? ''} ${match[2]}`)
|
|
194
|
+
if (expanded === null) return null
|
|
195
|
+
comparators.push(...expanded)
|
|
196
|
+
cursor = match.index + match[0].length
|
|
197
|
+
}
|
|
198
|
+
// (hyphen operands are re-emitted verbatim below)
|
|
199
|
+
if (trimmed.slice(cursor).trim() !== '') return null
|
|
200
|
+
// A hyphen range (`1.2.3 - 2.3.4`, `0.2 - 0.3`) is two partial-or-full
|
|
201
|
+
// versions with a lone `-` between them; npm expands it to `>=lo <=hi` with
|
|
202
|
+
// both bounds verbatim, so the X-range rule must not rewrite them. The
|
|
203
|
+
// tokenizer drops the `-`, so detect it from the original text.
|
|
204
|
+
if (verbatim.length === 2 && verbatim.every(item => item.op === '=')
|
|
205
|
+
&& /(^|\s)-(\s|$)/.test(trimmed)) {
|
|
206
|
+
// npm's hyphen range is the X-range of the lower bound plus the prefix end of
|
|
207
|
+
// the upper one: `0.2 - 0.3` is `>=0.2.0 <0.4.0-0`, `1 - 2` is
|
|
208
|
+
// `>=1.0.0 <3.0.0-0`. Both operands keep their own partial expansion.
|
|
209
|
+
const lower = parseVersion(verbatim[0].version.replace(/^[vV]/, ''))
|
|
210
|
+
const upper = parseVersion(verbatim[1].version.replace(/^[vV]/, ''))
|
|
211
|
+
// The lower operand is always inclusive; the upper one keeps its own value
|
|
212
|
+
// when it is a full version and bumps to its prefix end when it is partial.
|
|
213
|
+
return [{ op: '>=', version: lower }, upper.partial === 3
|
|
214
|
+
? { op: '<=', version: upper }
|
|
215
|
+
: { op: '<', version: upperBoundOf(verbatim[1].version) }]
|
|
216
|
+
}
|
|
217
|
+
return comparators
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Whether one version satisfies one range. Implements the subset the dsh host
|
|
222
|
+
* uses: `||` alternatives, `=`/`>`/`>=`/`<`/`<=`/`^`/`~`, X-ranges.
|
|
223
|
+
*
|
|
224
|
+
* The host decides with `semver.satisfies(version, range, { includePrerelease: true })`
|
|
225
|
+
* (dsh-app-boot `unmetRequirements` → `satisfies`). That flag relaxes npm's rule
|
|
226
|
+
* that a prerelease only matches a comparator carrying the same
|
|
227
|
+
* major/minor/patch, so `0.2.1-rc.5` DOES satisfy `>=0.2.0-rc.2 <0.2.2-0`.
|
|
228
|
+
*
|
|
229
|
+
* Implementing the stricter rule here made this detection more conservative than
|
|
230
|
+
* the host: dsh-ops and dsh-our-free-model were reported "installed but the host
|
|
231
|
+
* does not allow it" on 0.2.1-rc.* while the host loaded both. Detection must
|
|
232
|
+
* match the host, not npm's install-time resolution.
|
|
233
|
+
*/
|
|
234
|
+
export function satisfiesRange(version, range) {
|
|
235
|
+
const parsed = parseVersion(version)
|
|
236
|
+
// Only a complete version is testable; partial spellings belong to ranges.
|
|
237
|
+
if (!parsed || parsed.partial !== 3 || typeof range !== 'string') return false
|
|
238
|
+
for (const alternative of range.split('||')) {
|
|
239
|
+
const comparators = expandAlternative(alternative)
|
|
240
|
+
if (comparators === null || comparators.length === 0) continue
|
|
241
|
+
if (comparators.every(({ op, version: bound }) => {
|
|
242
|
+
const order = compareVersions(parsed, bound)
|
|
243
|
+
return op === '=' ? order === 0
|
|
244
|
+
: op === '>' ? order > 0
|
|
245
|
+
: op === '>=' ? order >= 0
|
|
246
|
+
: op === '<' ? order < 0
|
|
247
|
+
: order <= 0
|
|
248
|
+
})) return true
|
|
249
|
+
}
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Walk up from one path to the version of the host package that owns it. */
|
|
254
|
+
async function hostVersionAbove(path) {
|
|
255
|
+
// Resolve symlinks first. The running host is usually started through the
|
|
256
|
+
// `dsh` shim in the npm global bin directory, which is a symlink into
|
|
257
|
+
// lib/node_modules/@deepseek-ai/dsh/lib/bin.js. `path.resolve` does not follow
|
|
258
|
+
// symlinks, so walking up from the shim never reaches the package and the
|
|
259
|
+
// version silently falls through to whatever this checkout happens to depend
|
|
260
|
+
// on - reported a source checkout's 0.1.2-rc.1 devDependency while the real
|
|
261
|
+
// host was 0.2.1-rc.5.
|
|
262
|
+
let directory = dirname(await realpath(path).catch(() => resolve(path)))
|
|
263
|
+
while (true) {
|
|
264
|
+
const manifest = await readJson(join(directory, 'package.json')).catch(() => null)
|
|
265
|
+
if (manifest?.name === '@deepseek-ai/dsh' && typeof manifest.version === 'string') return manifest.version
|
|
266
|
+
const parent = dirname(directory)
|
|
267
|
+
if (parent === directory) return null
|
|
268
|
+
directory = parent
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The running host version. A host installed into a profile is read from that
|
|
274
|
+
* profile; otherwise the host sub-packages this plugin already resolves are
|
|
275
|
+
* read, because the harness publishes them in lockstep with `@deepseek-ai/dsh`.
|
|
276
|
+
* `OPENWRITE_HOST_VERSION` overrides every source for diagnostics and tests.
|
|
277
|
+
* Unknown when nothing resolves, which keeps every companion decision
|
|
278
|
+
* fail-closed.
|
|
279
|
+
*/
|
|
280
|
+
export async function resolveHostVersion(home) {
|
|
281
|
+
const override = process.env.OPENWRITE_HOST_VERSION
|
|
282
|
+
if (typeof override === 'string' && parseVersion(override)) return override
|
|
283
|
+
for (const profile of await readdir(join(home, 'profiles')).catch(() => [])) {
|
|
284
|
+
const installed = join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh')
|
|
285
|
+
const version = await hostVersionAbove(join(installed, 'lib', 'index.js'))
|
|
286
|
+
?? (await readJson(join(installed, 'package.json')).catch(() => null))?.version
|
|
287
|
+
if (typeof version === 'string' && parseVersion(version)) return version
|
|
288
|
+
}
|
|
289
|
+
// The entry this process was started from is the host that is actually
|
|
290
|
+
// running; it outranks whatever this checkout happens to depend on.
|
|
291
|
+
const entry = process.argv?.[1]
|
|
292
|
+
if (typeof entry === 'string') {
|
|
293
|
+
const version = await hostVersionAbove(entry)
|
|
294
|
+
if (version) return version
|
|
295
|
+
}
|
|
296
|
+
// Last resort: the host packages resolvable from here. In a source checkout
|
|
297
|
+
// that is this repo's own pinned devDependency, never the running host, so it
|
|
298
|
+
// can answer wrongly in both directions — prefer it over nothing only.
|
|
299
|
+
for (const name of ['@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-settings', '@deepseek-ai/dsh-tools']) {
|
|
300
|
+
const version = await readJson(createRequire(import.meta.url).resolve(`${name}/package.json`)).then(manifest => manifest?.version).catch(() => null)
|
|
301
|
+
if (typeof version === 'string' && parseVersion(version)) return version
|
|
302
|
+
}
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Every profile bundle list plus a fingerprint of the manifests behind them. */
|
|
307
|
+
async function readProfiles(home) {
|
|
308
|
+
const profiles = []
|
|
309
|
+
let fingerprint = ''
|
|
310
|
+
for (const name of await readdir(join(home, 'profiles')).catch(() => [])) {
|
|
311
|
+
const manifest = join(home, 'profiles', name, 'package.json')
|
|
312
|
+
let parsed, modified
|
|
313
|
+
try {
|
|
314
|
+
parsed = await readJson(manifest)
|
|
315
|
+
modified = (await stat(manifest)).mtimeMs
|
|
316
|
+
} catch { continue }
|
|
317
|
+
const bundles = parsed?.dsh?.profile?.bundles
|
|
318
|
+
if (!Array.isArray(bundles)) continue
|
|
319
|
+
profiles.push({ name, directory: join(home, 'profiles', name), bundles })
|
|
320
|
+
fingerprint += `${name}:${Math.round(modified)};`
|
|
321
|
+
// The companion's own manifest decides whether the running host is inside its
|
|
322
|
+
// declared range, so an upgrade that widens (or narrows) that range must
|
|
323
|
+
// invalidate the cache. Keying only on the profile manifest kept serving the
|
|
324
|
+
// previous verdict after a companion upgrade - the exact #150/#151/#142 class
|
|
325
|
+
// of bug, reachable again the moment the range changes.
|
|
326
|
+
for (const candidates of Object.values(companionNames)) {
|
|
327
|
+
for (const companion of candidates) {
|
|
328
|
+
const companionManifest = join(profiles[profiles.length - 1].directory, 'node_modules', ...companion.split('/'), 'package.json')
|
|
329
|
+
try {
|
|
330
|
+
fingerprint += `${companion}:${Math.round((await stat(companionManifest)).mtimeMs)};`
|
|
331
|
+
} catch { /* companion not installed in this profile */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { profiles, fingerprint }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Locate one installed companion package inside a profile. */
|
|
339
|
+
async function installedPackage(profile, names) {
|
|
340
|
+
for (const name of names) {
|
|
341
|
+
if (!profile.bundles.some(bundle => String(bundle).toLowerCase() === name.toLowerCase())) continue
|
|
342
|
+
const manifest = join(profile.directory, 'node_modules', ...name.split('/'), 'package.json')
|
|
343
|
+
const parsed = await readJson(manifest).catch(() => null)
|
|
344
|
+
if (parsed) {
|
|
345
|
+
const constraint = hostConstraint(parsed)
|
|
346
|
+
return { name, directory: join(profile.directory, 'node_modules', ...name.split('/')),
|
|
347
|
+
version: parsed.version, range: constraint.range, rangeSource: constraint.source }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Load a companion's entry and build the integration OpenWrite would mount, so an
|
|
355
|
+
* entry that no longer accepts our options is caught here instead of taking the
|
|
356
|
+
* preset composition down with it. Returns null when usable, or why it is not.
|
|
357
|
+
*/
|
|
358
|
+
async function validateEntry(directory, entry, build) {
|
|
359
|
+
if (!resolvesEntry(directory, entry)) return `installed package does not expose ${entry}`
|
|
360
|
+
try {
|
|
361
|
+
const resolved = createRequire(join(directory, 'package.json')).resolve(entry)
|
|
362
|
+
const module = await import(pathToFileURL(resolved).href)
|
|
363
|
+
const factory = module.integration ?? module.default
|
|
364
|
+
if (typeof factory !== 'function') return `${entry} no longer exports an integration factory`
|
|
365
|
+
// The mounted row immediately calls `.apply` on the result, so a factory
|
|
366
|
+
// that returns nothing must be refused here rather than at activation.
|
|
367
|
+
const built = build(factory)
|
|
368
|
+
if (built === null || typeof built !== 'object' || typeof built.apply !== 'function') {
|
|
369
|
+
return `${entry} returned no plugin object`
|
|
370
|
+
}
|
|
371
|
+
return null
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return `${entry} rejected the OpenWrite integration: ${error instanceof Error ? error.message : String(error)}`
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Whether one package directory exposes the entry OpenWrite would import. */
|
|
378
|
+
function resolvesEntry(directory, entry) {
|
|
379
|
+
try {
|
|
380
|
+
createRequire(join(directory, 'package.json')).resolve(entry)
|
|
381
|
+
return true
|
|
382
|
+
} catch { return false }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Whether a companion's own bundle patch is self-consistent.
|
|
387
|
+
*
|
|
388
|
+
* The host imports each `insert` row by its `name`, resolved as a package from
|
|
389
|
+
* the profile. A scoped package that ships a patch naming the bare package
|
|
390
|
+
* (`@ebony-vinyl/dsh-our-free-model` declaring `name: dsh-our-free-model`)
|
|
391
|
+
* therefore never activates: the host reports
|
|
392
|
+
* "Cannot find package 'dsh-our-free-model'". Detecting that here turns a
|
|
393
|
+
* panel that shows "available" and then fails to read the gateway into an
|
|
394
|
+
* accurate "installed but the host cannot load it".
|
|
395
|
+
*/
|
|
396
|
+
async function bundlePatchReason(directory, packageName) {
|
|
397
|
+
const manifest = await readJson(join(directory, 'package.json')).catch(() => null)
|
|
398
|
+
const patch = manifest?.dsh?.bundle?.patch
|
|
399
|
+
if (typeof patch !== 'string' || patch === '') return null
|
|
400
|
+
const text = await readFile(join(directory, patch), 'utf8').catch(() => null)
|
|
401
|
+
if (text === null) return `bundle patch ${patch} is missing`
|
|
402
|
+
// Minimal scan: every `name:` value in an insert block. A full YAML parse is
|
|
403
|
+
// deliberately avoided so a malformed patch cannot make detection itself fail.
|
|
404
|
+
const names = [...text.matchAll(/^\s*-?\s*name:\s*['"]?([^'"\n]+)['"]?\s*$/gm)].map(m => m[1].trim())
|
|
405
|
+
if (names.length === 0) return null
|
|
406
|
+
for (const name of names) {
|
|
407
|
+
if (name === packageName) continue
|
|
408
|
+
// A relative or subpath specifier is resolved against the package itself.
|
|
409
|
+
if (name.startsWith('.') || name.startsWith('#')) continue
|
|
410
|
+
try {
|
|
411
|
+
const resolved = createRequire(join(directory, 'package.json')).resolve(name)
|
|
412
|
+
if (resolved.startsWith(resolve(directory))) continue
|
|
413
|
+
} catch { /* fall through to the mismatch report */ }
|
|
414
|
+
return `bundle patch names ${name}, which is not this package (${packageName}); the host cannot load it`
|
|
415
|
+
}
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Why a companion is not usable because of its declared host constraint, or null
|
|
420
|
+
* when the constraint covers the running host.
|
|
421
|
+
*
|
|
422
|
+
* A companion may declare several constraints (dsh-dog declares one range per
|
|
423
|
+
* `@deepseek-ai/dsh-*` sub-package); the host needs all of them, so every one
|
|
424
|
+
* must cover. An unknown host version is reported rather than silently accepted,
|
|
425
|
+
* because "no constraint declared" and "cannot tell" must not read the same.
|
|
426
|
+
*/
|
|
427
|
+
function rangeExclusionReason(host, candidate) {
|
|
428
|
+
const range = candidate.range
|
|
429
|
+
if (range === null || range === undefined) {
|
|
430
|
+
return host === null || host === undefined
|
|
431
|
+
? 'the running host version is unknown, so its declared range cannot be checked'
|
|
432
|
+
: null
|
|
433
|
+
}
|
|
434
|
+
if (host === null || host === undefined) {
|
|
435
|
+
const shown = candidate.rangeSource === '@deepseek-ai/dsh-*'
|
|
436
|
+
? `${candidate.range.split(' ').filter(Boolean).length} @deepseek-ai/dsh-* peer ranges`
|
|
437
|
+
: `declared range ${range}`
|
|
438
|
+
return `the running host version is unknown, so ${shown} cannot be checked`
|
|
439
|
+
}
|
|
440
|
+
if (satisfiesRange(host, range)) return null
|
|
441
|
+
const source = candidate.rangeSource === '@deepseek-ai/dsh-*'
|
|
442
|
+
? 'its @deepseek-ai/dsh-* peer ranges'
|
|
443
|
+
: `declared host range ${range}`
|
|
444
|
+
return `${source} excludes dsh ${host}`
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Describe every companion: whether it is installed and whether the running host
|
|
449
|
+
* would load it.
|
|
450
|
+
*/
|
|
451
|
+
export async function detectCompanions(home, options = {}) {
|
|
452
|
+
const { profiles, fingerprint } = await readProfiles(home)
|
|
453
|
+
// An explicit host version wins; `undefined` means "resolve it" while an
|
|
454
|
+
// explicit null forces the unknown-host path.
|
|
455
|
+
const host = 'hostVersion' in options ? options.hostVersion : await resolveHostVersion(home)
|
|
456
|
+
const describe = async (names, entry, build) => {
|
|
457
|
+
// Every profile is examined: readdir order is unspecified, so stopping at
|
|
458
|
+
// the first match would make the answer depend on directory order when one
|
|
459
|
+
// profile carries a usable copy and another does not.
|
|
460
|
+
const found = []
|
|
461
|
+
for (const profile of profiles) {
|
|
462
|
+
const installed = await installedPackage(profile, names)
|
|
463
|
+
if (installed) found.push({ profile, ...installed })
|
|
464
|
+
}
|
|
465
|
+
if (found.length === 0) return { installed: false, usable: false, version: null, range: null, profile: null, reason: null }
|
|
466
|
+
const described = []
|
|
467
|
+
for (const candidate of found) {
|
|
468
|
+
const entryReason = entry === undefined ? null : await validateEntry(candidate.directory, entry, build)
|
|
469
|
+
// A companion whose own bundle patch cannot be loaded by the host is not
|
|
470
|
+
// usable however well its range matches: the panel would offer an
|
|
471
|
+
// integration that never activates.
|
|
472
|
+
const patchReason = entryReason ?? await bundlePatchReason(candidate.directory, candidate.name)
|
|
473
|
+
// A null range means the plugin declares no host constraint at all. That is
|
|
474
|
+
// not "usable everywhere": an unknown constraint is treated as covering,
|
|
475
|
+
// because the host would also accept it, but it is reported so the panel
|
|
476
|
+
// can say the range is unknown rather than asserting compatibility.
|
|
477
|
+
const rangeReason = patchReason ?? rangeExclusionReason(host, candidate)
|
|
478
|
+
const usable = rangeReason === null
|
|
479
|
+
described.push({ installed: true, usable, version: candidate.version ?? null, range: candidate.range ?? null,
|
|
480
|
+
rangeSource: candidate.rangeSource ?? null,
|
|
481
|
+
profile: candidate.profile.name, reason: rangeReason })
|
|
482
|
+
}
|
|
483
|
+
return described.find(candidate => candidate.usable) ?? described[0]
|
|
484
|
+
}
|
|
485
|
+
const [dog, ops, freeModel] = await Promise.all([
|
|
486
|
+
describe(companionNames.dog),
|
|
487
|
+
describe(companionNames.ops, companionEntries.ops, opsIntegration),
|
|
488
|
+
describe(companionNames.freeModel),
|
|
489
|
+
])
|
|
490
|
+
const result = { host, dog, ops, freeModel }
|
|
491
|
+
const cache = join(home, 'openwrite', 'companions.json')
|
|
492
|
+
// The cache key must include this plugin's own version. Keying only on the
|
|
493
|
+
// profile manifests and the host meant an upgrade that changes the detection
|
|
494
|
+
// logic (new companion, corrected range semantics) kept serving the previous
|
|
495
|
+
// answer: a real 0.2.1-rc.5 host showed the old "ops excludes dsh 0.2.1-rc.5"
|
|
496
|
+
// verdict and no DoG row at all, until the manifests happened to change.
|
|
497
|
+
const build = await readJson(new URL('package.json', import.meta.url)).then(m => m?.version).catch(() => null)
|
|
498
|
+
const cached = await readJson(cache).catch(() => null)
|
|
499
|
+
if (cached?.fingerprint === fingerprint && cached.host === host && cached.build === build) {
|
|
500
|
+
// Serve the stored answer: recomputing on every call would re-read every
|
|
501
|
+
// profile and re-import the companion entry each time.
|
|
502
|
+
const { fingerprint: _fingerprint, build: _build, ...rest } = cached
|
|
503
|
+
return { ...rest, host, cached: true }
|
|
504
|
+
}
|
|
505
|
+
await mkdir(join(home, 'openwrite'), { recursive: true }).catch(() => {})
|
|
506
|
+
await writeFile(cache, JSON.stringify({ fingerprint, host, build, ...result }) + '\n').catch(() => {})
|
|
507
|
+
return { ...result, cached: false }
|
|
508
|
+
}
|