dsh-custom-mode 1.9.19 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +5 -0
- package/README.md +411 -44
- package/README.zh.md +345 -0
- package/assistants.mjs +23 -3
- package/atomic.mjs +93 -7
- package/base-composition.mjs +25 -1
- package/client.js +26 -4
- package/composition.mjs +296 -18
- package/index.mjs +60 -5
- package/journal.mjs +33 -6
- package/locales.mjs +8 -0
- package/meta.mjs +77 -10
- package/package.json +12 -9
- package/preset/agent.cordis.yml +1 -1
- package/seed.mjs +1 -1
package/README.zh.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# dsh-custom-mode
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
**DeepSeek Harness(dsh)自定义模式插件** —— 在设置页编辑系统提示词、选择基础模式、逐行开关插件,可并存多个
|
|
6
|
+
助手(多模式 / 多角色扮演 RP)。英文关键词:dsh custom mode / custom prompt / system prompt editor /
|
|
7
|
+
base mode / plugin switches / multi-assistant / multi-persona。
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
<!-- 社区入口:第二排徽章在每个新仓库里都要加(见 SOP/DSH Plugins/仓库门面模板.md) -->
|
|
12
|
+
|
|
13
|
+
[](https://github.com/BOWLUNA/dsh-custom-mode/stargazers) [](https://www.npmjs.com/package/dsh-custom-mode) [](https://github.com/BOWLUNA/dsh-custom-mode/actions/workflows/test.yml) [](LICENSE) [](https://github.com/BOWLUNA/dsh-custom-mode#readme)
|
|
14
|
+
|
|
15
|
+
<!-- 徽章:两排统一"深色标签 + 彩色值"的两段式;新仓库照抄这一块 -->
|
|
16
|
+
[](https://b23.tv/qJ4Ev0W) [](https://v.douyin.com/VWh0M03Fa4Y/) [](https://xhslink.cn/o/A7QtXmePBBF) [](https://discord.gg/pz97SfAfSy) [](https://github.com/BOWLUNA/dsh-custom-mode/discussions)
|
|
17
|
+
|
|
18
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)用的自定义模式。它的系统提示词是一个普通文件,可以在 Web 设置页里编辑,**改完下一步模型调用即生效** —— 不用重启,也不用新建会话。
|
|
19
|
+
|
|
20
|
+
一句话:**给 dsh 的 agent 模式用的设置页** —— 选这个模式的基础组成、逐行开关它挂载的插件、并编辑它的系统提示词;提示词每个模型调用前重新读取。可以并存多个模式(「助手」),各自的提示词互不影响。
|
|
21
|
+
|
|
22
|
+
常见的叫法:自定义模式 · 自定义提示词 · 系统提示词编辑 · 多助手/多模式 · 多个 Agent 模式 ·
|
|
23
|
+
角色扮演/聊天人格(RP)。
|
|
24
|
+
|
|
25
|
+
**支持两条线:最新线(npm 的 `latest` 与 `next` 自 2026-09-29 起都指向 `0.2.0-rc.2`)与上一个正式版** —— 两条都是每次 push 真跑,不是声明:
|
|
26
|
+
|
|
27
|
+
| dsh | 定位 | 状态 |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `0.2.0-rc.2` | **最新线** —— npm 的 `latest` 与 `next` 都指向它,官方桌面端也在这一条线上(与 dsh 同版本号) | ✅ CI(ubuntu node 20/24 + **windows**)+ 真机:这条线上安装、组合树与播种都验过(整道渲染闸门跑在这里)|
|
|
30
|
+
| `0.1.7-rc.2` | 上一个正式版 —— 仍在声明范围内,现役安装大多在它上面 | ✅ CI + 真机:自 1.9.15 起就在这条线上验过渲染 |
|
|
31
|
+
|
|
32
|
+
更早的线(`0.1.5-rc.3`、`0.1.6-alpha.*`)共用同一套机制,peer 范围仍然接纳,但不再单独占 CI 腿。
|
|
33
|
+
|
|
34
|
+
官方四个模式(`standard` / `ptc` / `minimal` / `cordis`)不受影响。
|
|
35
|
+
|
|
36
|
+
| | |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
|  |  |
|
|
39
|
+
|  |  |
|
|
40
|
+
|
|
41
|
+
多助手管理器(裁到它自己那一段)。五张图都是英文、**统一 800x800**,由一个**没有会话记录的**一次性
|
|
42
|
+
`DSH_HOME` 拍出,脚本是 `tools/screenshots/shoot-fresh.mjs`(渲染后的页面由 `tools/browser-verify.mjs` 校验):
|
|
43
|
+
|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
## 安装
|
|
47
|
+
|
|
48
|
+
一条命令装完——设置页插件,以及它在首次激活时自动播种的 preset:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.0 # 钉版本才能确定拿到这一版
|
|
52
|
+
# 不带版本号会受 pnpm 的发布冷却期影响(`minimumReleaseAge`,默认一天):发布后数小时内按名安装
|
|
53
|
+
# 可能**静默装到旧版** —— 实测 1.3.0 发布 38 分钟后按名安装装到了 1.0.3。用 profile 里的
|
|
54
|
+
# `npm ls dsh-custom-mode` 核对实际装到的版本,或像上面那样钉版本。
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
装完重启 dsh,新建会话时选「自定义模式」。preset 会被写到 `$DSH_HOME/.agent-presets/custom/`;
|
|
58
|
+
**那里已有的文件一律不动**,所以你自己写过的 `prompt.md` 不会被覆盖。
|
|
59
|
+
|
|
60
|
+
**平台**:测试套件每次 push 都在 Ubuntu(Node 20 与 24)**和 Windows(Node 24)**上跑 —— 加 Windows
|
|
61
|
+
任务是因为那个平台有自己的失败模式(`install.sh` 里的 MSYS 路径、并发保存时 `rename` 的目标锁、平台表达式
|
|
62
|
+
求值方向相反)。两个 shell 脚本在任何 POSIX shell 下都能用,含 Git Bash。
|
|
63
|
+
|
|
64
|
+
### 图形界面安装(不用终端)
|
|
65
|
+
|
|
66
|
+
dsh `0.1.6-alpha.2` 起有插件管理页:**侧边栏 → 插件 → 添加插件**。它接受三种输入,本插件对应如下:
|
|
67
|
+
|
|
68
|
+
| 输入 | 填什么 | 说明 |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| **包名** | `dsh-custom-mode` | 最省事 |
|
|
71
|
+
| **GitHub 仓库地址** | `https://github.com/BOWLUNA/dsh-custom-mode` | 指向**仓库根**即可 |
|
|
72
|
+
| **本地插件目录** | `<你 clone 的路径>` | 就是仓库根 —— 仓库根**本身**就是发布包 |
|
|
73
|
+
|
|
74
|
+
想逛整个生态还有一个**应用内市场**:装 `dshmarket`(`dsh plugin --profile web add dshmarket`),
|
|
75
|
+
打开 **设置 → 插件市场**,搜 `dsh-custom-mode` —— 卡片会读本插件声明的 `engines.dsh` 范围,
|
|
76
|
+
以及仓库里 `screenshots.json` 列出的精选截图(五张;它们就是 README 用的那几张
|
|
77
|
+
`docs/images/*.png`,所以每张图在仓库里只有一份)。
|
|
78
|
+
|
|
79
|
+
三条都可用,因为**仓库根的 `package.json` 就是发布包**:`dsh.bundle` / `main` /
|
|
80
|
+
`exports["./client"]` 都声明在仓库根,所以 npm 安装与"GitHub 地址"安装拿到的是同一批文件。
|
|
81
|
+
1.10.0 之前这个仓库有**两份清单**——根部的 `private: true` 包装清单加 `editor/package.json`——
|
|
82
|
+
那正是第三方目录把本插件显示成 `dsh-custom-mode#editor`、以及 dshfind 这类站点读到包装清单的
|
|
83
|
+
`private: true` 后报"作者尚未发布到 npm"的原因。现在只有根清单一份,`test/manifests.test.mjs` 盯着它不被加回来。
|
|
84
|
+
|
|
85
|
+
### 桌面端
|
|
86
|
+
|
|
87
|
+
桌面端就在 dsh 仓库里(`apps/desktop`),**与 dsh 同版本号发布**(Electron 与 `@deepseek-ai/dsh`
|
|
88
|
+
永远同一个精确版本),而且它就是**套了 Electron 壳的完整 Web 应用** —— 所以本插件的页面在那边原样渲染。
|
|
89
|
+
安装只有一条路,就是上面那条:
|
|
90
|
+
|
|
91
|
+
- **在应用里装**:侧栏 → **插件** → 添加插件 → 搜 `dsh-custom-mode`。应用自带 pnpm,走的是同一套插件管理器。
|
|
92
|
+
- **不要试着用 CLI 装 —— dsh 自己就会拒绝**(0.1.7-rc.2 实测):
|
|
93
|
+
```
|
|
94
|
+
$ dsh plugin --profile desktop add .
|
|
95
|
+
error: profile "desktop" is managed exclusively by the Electron application
|
|
96
|
+
```
|
|
97
|
+
桌面端独占 `$DSH_HOME/profiles/desktop`:那里的包操作持有 profile 事务锁,启动恢复还会重命名
|
|
98
|
+
`cordis.patch.yml`。所以 `install.sh` 也**拒绝** `--profile desktop` 并指向应用内安装 —— 两边一致,
|
|
99
|
+
而不是某一方偷偷绕过去。(只想在本机模拟 profile 形状时可设 `DSH_ALLOW_DESKTOP_PROFILE=1`。)
|
|
100
|
+
- **预设那一侧不用做任何事**:`$DSH_HOME/.agent-presets/` 是桌面端与 CLI 共享的产品数据,
|
|
101
|
+
预设由插件首次激活时落盘。
|
|
102
|
+
- 万一某个第三方 bundle 让应用起不来:原生恢复对话框提供**「禁用第三方插件」**,已安装的包与插件数据都留在磁盘上。
|
|
103
|
+
|
|
104
|
+
**兼容性是声明出来的,不是猜的。** dsh 会拿每个插件的 `peerDependencies["@deepseek-ai/dsh"]` 与运行时
|
|
105
|
+
版本比对(含预发布版);而且自 0.2.0 起这项检查从"警告"升级成了**安装闸门**:范围不覆盖运行时就
|
|
106
|
+
直接装不进去(`installation rejected: Plugin … is incompatible with dsh 0.2.0-rc.2`)。所以那个范围
|
|
107
|
+
**就是**支持声明本身,只有在真实安装并测过新线之后才会动——"大概能用"的范围正是会变成支持工单的那种声明。
|
|
108
|
+
|
|
109
|
+
想同时留下源码(或者不用 npm 安装),就 clone 下来跑脚本,它把同样两件事显式做一遍:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
git clone https://github.com/BOWLUNA/dsh-custom-mode
|
|
113
|
+
cd dsh-custom-mode
|
|
114
|
+
./install.sh # 复制 preset/ 到 $DSH_HOME/.agent-presets/custom/,并安装插件
|
|
115
|
+
./uninstall.sh # 卸载插件;默认保留你的提示词,加 --purge 一并删除
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
模式需要带 `agent-presets` 的 profile:`web` 有,`tui` 与 `headless` 没有。
|
|
119
|
+
|
|
120
|
+
### 不装插件也能先试
|
|
121
|
+
|
|
122
|
+
每个 release 都带一个 **`dsh-custom-mode.dshpreset`**
|
|
123
|
+
([最新版](https://github.com/BOWLUNA/dsh-custom-mode/releases/latest/download/dsh-custom-mode.dshpreset))。
|
|
124
|
+
它是一个 zip,里面是一个**开箱可用的助手**:`manifest.json` 加 `preset/` —— 组成文件、`prompt.md`、
|
|
125
|
+
每次模型调用前重读它的读取器,以及一个**模型可见的工具**(读写这份提示词)。在桌面端导入它,
|
|
126
|
+
**不用装任何包**就能得到一个可用的「自定义模式」:
|
|
127
|
+
|
|
128
|
+
| | |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| **你得到** | 一个助手,它的系统提示词就是一份普通文件。用任何编辑器改 —— agent loop 每次模型调用前重读,所以下一步就生效,不用重启。也可以**直接让模型改**:预设里带的就是这个工具。 |
|
|
131
|
+
| **你没有** | 设置页。那是插件提供的东西 —— 页面本身,以及**多个助手并存**、新建会话时从菜单里挑一个。 |
|
|
132
|
+
|
|
133
|
+
⇒ 预设是「30 秒看看这是什么」,插件是「长期用它」。导入预设与之后装插件**不冲突**:
|
|
134
|
+
插件把自己的预设写在 `$DSH_HOME/.agent-presets/` 下,且**从不覆盖**你自己写的 `prompt.md`。
|
|
135
|
+
|
|
136
|
+
## 使用
|
|
137
|
+
|
|
138
|
+
设置页(设置 → 「自定义模式」)是一个**助手管理器**:上面列出你所有的自定义模式,可以新增、切换、删除,下面四个区块(模式名称 / 基础模式 / 插件开关 / 系统提示词)编辑的是**当前选中的那一个**。
|
|
139
|
+
|
|
140
|
+
- **排序** —— 选中助手后「上移 / 下移」,顺序写在每个助手的 `preset.yml` 里(`order`,也就是 roster 自己的排序键),所以重启后仍然生效,新建会话的选择器按这个顺序排列。
|
|
141
|
+
- **导入 / 导出提示词** —— 「导出提示词」把当前文本存成 `.md`;「导入提示词」把文件读进**编辑器**(不会直接落盘,仍需点保存),因此导入同样要过 `{{…}}` 校验。
|
|
142
|
+
- **让 agent 改自己的提示词要经过你批准** —— 会话内的 `custom_prompt` 工具走平台的审批缝
|
|
143
|
+
(`tools/pre-execute` 返回 `ask`):请求会停在「等待审批」,面板里写明**要写什么、写到哪**,你点「允许一次」
|
|
144
|
+
才会执行。实测:审批策略为 `ask` 时弹面板且批准后真的写入;为 `never`(完全权限)时不弹窗、**直接拒绝**。
|
|
145
|
+
所以最坏情况是"改不成",从不是"悄悄改成了"。
|
|
146
|
+
- **「配置了却不生效」会被点名** —— 设置页会指出那些不生效的配置:「身份(系统提示词)」这一行关着而
|
|
147
|
+
`prompt.md` 还有内容(你写的提示词被静默忽略)、`custom_prompt` 工具行关着、助手没有名字或描述
|
|
148
|
+
(选择器里会显示成裸 id 或「暂无描述」)。
|
|
149
|
+
- **agent 能改自己的提示词,但要你批准** —— 会话内的 `custom_prompt` 工具可以读、整体替换,或者**追加**。
|
|
150
|
+
追加的意义在于**不必把整段提示词重新写一遍**:想记住一条规则时,不可能因为重写而丢掉已有内容
|
|
151
|
+
(实测:模型可能仍会先读一次,看看自己要加到什么内容后面)。两种写入动作都会先过平台的审批面板。
|
|
152
|
+
- **改动历史** —— 每次保存,以及**在设置页之外**发生的任何改动(会话内的 `custom_prompt` 工具、手工编辑 `prompt.md`),都会在提示词框下面留下一版,并标明时间与来源。载入某一版**只改草稿**:不点保存不会落盘,所以翻旧版本不会毁掉当前这版。在此之前,提示词被会话内改掉是**看不见的** —— 页面永远只显示"当前文本"。
|
|
153
|
+
- **恢复出厂提示词** —— 一键把**出厂模板**(新建助手时得到的那份文本)填回编辑器。它和页面上其它改动一样只改**草稿**:不点保存不会落盘,「重新读取」即可撤销。在此之前,把提示词改坏了只能删掉助手重建。
|
|
154
|
+
- **新增助手** —— 填个名字点「新增助手」。新助手从模板生成:标准模式的全部行 + 一份默认提示词,写好后在新建会话的选择器里就能选到它 —— **已经打开的页面里,选择器的列表是加载时的快照,要看到新助手需刷新一次(F5)**(实测;roster 本身已是最新)。
|
|
155
|
+
- **复制一份** —— 把当前助手的提示词、基础模式、逐行开关整个复制到新助手,之后各改各的。
|
|
156
|
+
- **每个助手各自独立** —— 提示词、基础模式、逐行开关都属于它自己,改一个不影响别的。
|
|
157
|
+
- **基础模式是"行集合",不是提示词** —— 底子决定有哪些行、这个模式有哪些工具;本插件始终把底子的
|
|
158
|
+
`persona` 行替换成自己的读取器(`complete: false`),所以底子的**提示词语义不会被继承**。最明显的是
|
|
159
|
+
「极简模式」:你拿到的是极简的工具集,不是极简的提示词。
|
|
160
|
+
- **第五个基础模式:自定义模式(全部出厂行的并集)** —— 前四个是 dsh 出厂的模式,第五个是**本插件自己合成**的。
|
|
161
|
+
它的行集合是那四个模式的并集;同一行在多个模式里都有时,取**先出现的那个模式**的原文与出厂默认
|
|
162
|
+
(顺序:标准 → PTC → 极简 → 创造),所以出厂状态以标准模式为准。
|
|
163
|
+
为什么需要它:开关只能作用在**基础文本里已经有的行**上,于是选「标准模式」时,只有 PTC 才有的
|
|
164
|
+
`tool-presentation`、只有极简才有的持久终端组、只有创造模式才有的 `tool-cordis` 是**拨不到的**
|
|
165
|
+
—— 不是界面藏起来了,是合成器里根本没有那条通路。0.2.0-rc.2 上实测:标准 32 行、PTC 33 行、创造 33 行、
|
|
166
|
+
极简 7 行,**并集 40 行**。取并集时分组整体搬运,`isolate` realm 与 `!!js` 平台条件原样保留,
|
|
167
|
+
因此不会凭空造出一个会让 `dsh-agent-presets` 拒绝挂载的行。
|
|
168
|
+
- **两套壳不能同时开 —— 插件替你避让。** 并集里有一组互斥的行:极简的「持久终端壳」和标准的
|
|
169
|
+
`tool-bash`/`tool-pwsh` 是同一件东西的两种实现,都注册一个叫 `bash` 的工具。同时启用会让
|
|
170
|
+
`dsh-agent-presets` 把整个模式判为 **broken**,而 broken 的模式会被**从所有选择器里静默丢掉** ——
|
|
171
|
+
设置页却全绿。所以当选「自定义模式」时,打开其中一侧会自动关掉另一侧,并在保存提示里说明动了哪一行。
|
|
172
|
+
页面之外写进去的冲突组合(手工编辑、别的工具)会在告警里被点名。
|
|
173
|
+
- **切换助手不会丢草稿** —— 每个助手各自留着未保存的修改,列表上用「未保存」标出来;唯一会放弃修改的是「放弃修改并重新读取」(有草稿时按钮会改名说明)。
|
|
174
|
+
- **删除** —— 先确认,再由插件自己动手删。确认形态取决于这条线:壳把 `RiskConfirmation` 交给第三方客户端插件时,是壳自己的弹窗(需勾选);而 `0.1.7-rc.2` 的客户端种子表只暴露 `Button/Input/Switch/Tag/Pill`,于是退化为原生 `confirm`(护栏不变,少一个勾选框)。删除两条线都能用:`0.1.6` 及更早有 `agentPresets.remove()`,`0.1.7`+ 根本没有这个调用 —— 那里由插件自己注销注册表项并删掉目录。删除只移除磁盘上的模式目录:**正在使用它的会话不受影响**(组成在会话创建时就已读取),新建会话时不再出现。
|
|
175
|
+
- **直接对 agent 说** —— 每个助手都自带 `custom_prompt` 工具,会话里可以读取或改写**它自己**的提示词。
|
|
176
|
+
- **直接改文件** —— `$DSH_HOME/.agent-presets/<助手 id>/prompt.md` 是那个助手的唯一事实来源。
|
|
177
|
+
|
|
178
|
+
插值变量只有 `{{model}}`、`{{cwd}}`、`{{provider}}`。出现未知的 `{{…}}` 会在保存时被拒绝:渲染器对它抛错,那会让该模式每个请求都失败。
|
|
179
|
+
|
|
180
|
+
界面控件(按钮、输入框、开关、标签、确认弹窗、图标)全部来自壳自己的
|
|
181
|
+
`@deepseek-ai/dsh-client-ui-primitives`,因此主题、深浅色与后续改版都会自动作用到本页;老壳没有提供这组
|
|
182
|
+
原子组件时会退回内置的朴素控件,功能不变。
|
|
183
|
+
|
|
184
|
+
「助手」= 用户预设根目录(默认 `$DSH_HOME/.agent-presets/`)下的**一个目录**,目录名就是它的内部 id。设置页只管理**本工具创建的模式**(判据:目录里有 `prompt.md`,且组成文件用 `prompt-reader.mjs` 注入身份)。手工编写的其它 preset 不会被列进来,更不会被改写 —— 页面会按基础模式重新生成组成文件,对一份手写的组成文件做这件事等于毁掉它。
|
|
185
|
+
|
|
186
|
+
### 与官方「插件管理」页的分工
|
|
187
|
+
|
|
188
|
+
dsh `0.1.6-alpha.2` 起自带了插件管理页,可以实时启用/停用插件。它和本模式的逐行开关**管的是不同层级**:
|
|
189
|
+
|
|
190
|
+
| | 官方插件管理页 | 本模式的逐行开关 |
|
|
191
|
+
| --- | --- | --- |
|
|
192
|
+
| 作用范围 | **这套 profile**:这台机器装了哪些插件 | **这一个 agent 模式**:它的组成文件里挂哪些行 |
|
|
193
|
+
| 典型用法 | 全局关掉某个插件 | 让标准模式全开,而这个模式只留必要行 |
|
|
194
|
+
|
|
195
|
+
两者并存,不冲突:官方决定「这台机器有什么」,本模式决定「这个模式用其中的哪些」。
|
|
196
|
+
|
|
197
|
+
而且界限是**官方划的**:插件管理页的说明写着它管理「本 profile 的 bundle 与其中可寻址的行」,并明确
|
|
198
|
+
**agent-preset 行保持只读**。也就是说**官方管不到 preset 这一层**——本模式补的正是这一块。
|
|
199
|
+
|
|
200
|
+
一个可以当场演示的例子:`tool-plugin-manager`(模型侧的插件安装/启停工具)在**标准模式与 PTC 模式里
|
|
201
|
+
出厂就是关闭的**(只有创造模式默认打开)。官方模式不给你改它的入口,而在这里**拨一下就能打开**。
|
|
202
|
+
|
|
203
|
+
## 工作原理
|
|
204
|
+
|
|
205
|
+
dsh 的系统提示词通常来自 preset 的 YAML,而官方 `@deepseek-ai/dsh-persona` 的 `prefix` 只在挂载时解析一次。本 preset 注册同名的 `deployment:persona-prefix` section,但把 `text` 写成**函数**,agent loop 在每次模型调用前都会调它。
|
|
206
|
+
|
|
207
|
+
因此两件事的生效时机不同:
|
|
208
|
+
|
|
209
|
+
- **提示词文本**每步重新读取,所以改动对**正在运行**的会话立即生效。
|
|
210
|
+
- **开关与基础模式**会重写组成文件。`agent-presets` 在该文件的 `mtimeMs` 与 `size` 变化时重新挂载,所以由**新会话**生效;已开着的会话保持它启动时的配置 —— 这是刻意的,中途换工具集会出问题。
|
|
211
|
+
|
|
212
|
+
行开关是三态。没碰过的行与出厂行逐字节相同,包括 `!!js` 平台条件与出厂 `disabled`;显式开或关才会把那个条件替换成布尔值。平台表达式在宿主端求值,所以页面显示的是这台机器上实际生效的状态,而不是"有没有这个键"。
|
|
213
|
+
|
|
214
|
+
多助手不需要机制上的新能力:`dsh-agent-presets` 本来就会扫描用户预设根目录下的**每一个**目录,而且每次读 roster 都重新扫盘,所以一个刚建的目录在下一次选会话时就可见。每个助手的 `prompt-reader.mjs` / `prompt-tool.mjs` 都是按**自己模块位置**解析 `prompt.md` 的,N 份拷贝等于 N 套互不干扰的提示词。新增用包内模板播种;删除在有 `agentPresets.remove()` 的线上交给平台(它会拒绝删出厂 preset,并再确认目录确实在可写根目录下),在 `0.1.7`+ 上由本插件自己注销注册表项并删目录。
|
|
215
|
+
|
|
216
|
+
## 版本
|
|
217
|
+
|
|
218
|
+
**支持两条线:最新线(`0.2.0-rc.2`,npm 的 `latest` 与 `next` 自 2026-09-29 起都指向它,也是桌面端
|
|
219
|
+
所在的那条线)与上一个正式版(`0.1.7-rc.2`,仍在声明范围内,现役安装大多在它上面)** —— 声明为
|
|
220
|
+
`>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0 || >=0.2.0-0 <0.3.0-0`(多出来的两段是因为
|
|
221
|
+
semver 只允许范围里**点名了** `major.minor.patch` 的预发布版本被匹配到),CI 会两条线各装一次并各跑一遍
|
|
222
|
+
完整测试 —— 其中**渲染闸门跑在最新线**上,因为壳自己的 UI 改动最先落在那里。
|
|
223
|
+
这里的策略是"只跟两个最新的":更早的线(`0.1.5-rc.3`、`0.1.6-alpha.*`)仍在 peer 范围内,但不单独占 CI 腿。
|
|
224
|
+
**官方桌面端(DeepSeek Harness Desktop)也在覆盖范围内**:它与 dsh 同版本号,当前即为 `0.2.0-rc.2`,
|
|
225
|
+
正是这条矩阵钉住的组合;在应用内(侧栏 → Plugins)安装即可,CLI 那条路 dsh 自己会拒。
|
|
226
|
+
`0.2.0-rc.2` 实测(2026-09-30,干净的一次性 `DSH_HOME`):`dsh plugin --profile web add dsh-custom-mode`
|
|
227
|
+
640ms 解析成功、组合树里有本插件的行、启动时五个预设文件全部播种、声明式注册表同步出助手,
|
|
228
|
+
且 `agentPresets` 能力面不变(`list, register, inventory, select, document`)。
|
|
229
|
+
|
|
230
|
+
包版本走**自己的线** —— `1.0.0`、`1.0.1` …… 它不镜像 DSH 的版本号。本插件支持哪些 dsh,由
|
|
231
|
+
根 `package.json` 的 `engines.dsh` 与 `@deepseek-ai/dsh` peer 范围声明,并由
|
|
232
|
+
`tools/verify-version-consistency.mjs`(CI 里执行)断言"CI 实际安装并测试的 dsh 版本落在这些范围内"。
|
|
233
|
+
|
|
234
|
+
拆开有两个原因。一是目录与市场要求裸 `x.y.z` 才自动安装 —— 有的会解析 npm `latest` 并拒绝任何带
|
|
235
|
+
预发布标签的版本;二是版本号字符串本来就不是一个可校验的声明,声明式的范围才是,而且它才是官方改动时
|
|
236
|
+
会过期的那一个。实际决定兼容性的仍然是下面这些 API 是否还在 —— 那些范围就是为它们写的。
|
|
237
|
+
|
|
238
|
+
<details>
|
|
239
|
+
<summary>耦合点清单(升级 dsh 时逐个核对)</summary>
|
|
240
|
+
|
|
241
|
+
| 依赖 | 变化后的后果 |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| `ctx.systemPrompt.section()` 且 `text` 支持**函数** | 提示词不再热更新 —— 整个项目的立足点 |
|
|
244
|
+
| `agentPresets` 依据组成文件的 `mtimeMs`+`size` 重挂载 | 开关要重启进程才生效 |
|
|
245
|
+
| `ctx.tools.register()` | 失去 `custom_prompt` 工具 |
|
|
246
|
+
| `ctx.connection.fetch.register({ path, methods, requestBody, fetch })` | 设置页 404 —— 什么都没注册 |
|
|
247
|
+
| `kind: 'prefix'` 同时匹配 `path` 与 `path/…` | 只有列表能打开,`/state`、`/create`、`/delete` 全部 404 |
|
|
248
|
+
| `agentPresets.list()` 行里有 `id` / `trust` / `path`,`preset.yml` 提供 `name` / `description` | 助手列表为空或认不出助手 |
|
|
249
|
+
| `agentPresets.remove(id)`(仅旧线),且拒绝 `trust: 'system'` | 0.1.7+ 的删除不再需要它 —— 声明式后端自己注销注册表项并删目录 |
|
|
250
|
+
| `ctx.connection.requestRejection(req)` | 设置页失败关闭(503),不再提供服务 |
|
|
251
|
+
| `ctx.inject(deps, cb)`(作用域化等待) | 在没有 web 服务器的 profile 里,整行会停在 `pending` |
|
|
252
|
+
| `dsh.client` + `exports["./client"]`,且客户端 bundle id 等于包名 | 浏览器半不会被发现 |
|
|
253
|
+
| `settings.section` 插槽(`id` / `order` / `label`) | 设置项位置与标签 |
|
|
254
|
+
| **`settings.section` 不再提供 `locale:`**(0.1.6-alpha.2 起) | 壳不会递进绑定到本命名空间的 `t`;页面自带词典兜底,见 ARCHITECTURE §15 |
|
|
255
|
+
| `preset.yml` 的 `order` 参与 roster 排序 | 「上移 / 下移」不生效 |
|
|
256
|
+
| `ctx.locale.register/bind` | 回退中文 |
|
|
257
|
+
| **出厂组成从哪来** —— 0.1.7+ 是 `agentPresets.readDocument(<mode>).content`,更早是 `@deepseek-ai/dsh-agent-presets` 的文件 | 基础模式与插件开关变为只读(提示词仍可保存),见 `base-composition.mjs` |
|
|
258
|
+
| 出厂布局 `<presets>/<id>/agent.cordis.yml` 与行的文本形状 | 基础模式切换失效 |
|
|
259
|
+
| `!!js` 平台表达式 | 平台行显示错误状态 |
|
|
260
|
+
|
|
261
|
+
</details>
|
|
262
|
+
|
|
263
|
+
## 文档
|
|
264
|
+
|
|
265
|
+
- [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.zh.md) —— 在真机上复现过的失败,含症状、原因与自救方法。
|
|
266
|
+
- [`docs/MEASUREMENTS.md`](docs/MEASUREMENTS.zh.md) —— 每条结论背后的命令与原始输出。
|
|
267
|
+
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.zh.md) —— 为什么必须是两个产物,以及依赖了哪些宿主 API。
|
|
268
|
+
- [`docs/PUBLISHING.md`](docs/PUBLISHING.zh.md) —— npm 包的发布方式。
|
|
269
|
+
- [`AGENTS.md`](AGENTS.md) —— 给 agent 的工作说明,含「实验机上做真浏览器验证」的完整配方(`tools/browser-verify.mjs`)。
|
|
270
|
+
- [`CHANGELOG.md`](CHANGELOG.zh.md) · [`SECURITY.md`](SECURITY.zh.md) · [`CONTRIBUTING.zh.md`](CONTRIBUTING.zh.md)
|
|
271
|
+
|
|
272
|
+
## 开发
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
node test/run.mjs # 15 个套件;自己解析出厂 preset 目录(0.1.7+ 从宿主声明派生)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
改 `client.js` 会被 `@deepseek-ai/dsh-client-hmr` 在约 1 秒后热替换;改宿主半(`index.mjs`、`composition.mjs`、`meta.mjs`、`paths.mjs`)需要重启。每个套件在防什么见 [`test/README.md`](test/README.zh.md),改行为之前先读 [`CONTRIBUTING.zh.md`](CONTRIBUTING.zh.md)。
|
|
279
|
+
|
|
280
|
+
## 如果它有用
|
|
281
|
+
|
|
282
|
+
在 GitHub 上点个 ⭐,是让一个插件在几千个条目的目录里被看见的唯一办法 —— 它不花你什么,却正是这个项目
|
|
283
|
+
还在被维护的理由。如果它在你的 dsh 线上**不好用**,带上 `dsh --version` 提个 issue:那是最快的修法,
|
|
284
|
+
上面那张兼容表就是上一次这么来的。
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## 构建环境
|
|
293
|
+
|
|
294
|
+
| | |
|
|
295
|
+
| --- | --- |
|
|
296
|
+
| 模型 | DeepSeek V4.1 Flash(`deepseek-v4-flash`,provider `deepseek-official`) |
|
|
297
|
+
| 运行时 | DeepSeek Harness **0.2.0-rc.2**(最新线)/ **0.1.7-rc.2**(上一个正式版)—— 支持的就是这两条线;下面的用量数字来自更早的 `0.1.6-alpha.2` 那一轮 |
|
|
298
|
+
| 未缓存输入 | 224,058 tok |
|
|
299
|
+
| 缓存读取 | 125,638,016 tok |
|
|
300
|
+
| 输出 | 425,539 tok |
|
|
301
|
+
|
|
302
|
+
整个项目(含调研与返工)由 DSH 客户端统计:**126,287,613 tok**,缓存命中率 **99.8%**
|
|
303
|
+
|
|
304
|
+
### 评审驱动的迭代轮次花了多少
|
|
305
|
+
|
|
306
|
+
四份外部审阅变成了 `1.4.0` … `1.9.0` 这些版本(稳定线选择器修复、Windows 写入竞争、i18n 泄漏、可移植性修复、
|
|
307
|
+
两轮 UI 改动)。同一个客户端里的实测:
|
|
308
|
+
|
|
309
|
+
| | |
|
|
310
|
+
| --- | --- |
|
|
311
|
+
| 未缓存输入 | 3,973,904 tok |
|
|
312
|
+
| 缓存读取 | 711,962,624 tok |
|
|
313
|
+
| 输出 | 1,559,791 tok |
|
|
314
|
+
|
|
315
|
+
合计 **717,496,319 tokens**(3,973,904 + 711,962,624 + 1,559,791),缓存命中率 **99.4%**
|
|
316
|
+
(711,962,624 ÷ 715,936,528)。连同第一遍一起,项目累计 **843,783,932 tokens**,缓存命中 **99.5%**
|
|
317
|
+
(837,600,640 ÷ 841,798,602)。
|
|
318
|
+
(缓存读取 ÷ 全部输入 125,862,074)。
|
|
319
|
+
|
|
320
|
+
## 更新
|
|
321
|
+
|
|
322
|
+
插件装在 profile 的 `node_modules` 里,安装由平台负责,所以"更新"就是**再装一次** —— 你的数据不会被碰:
|
|
323
|
+
|
|
324
|
+
```sh
|
|
325
|
+
# 钉版本的写法:要哪版就是哪版
|
|
326
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.0
|
|
327
|
+
# 然后重启为该 profile 提供服务的 DSH 进程
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**想要这一版就别用不带版本号的安装。** pnpm 有发布冷却期(`minimumReleaseAge`,默认 24 小时),
|
|
331
|
+
`dsh plugin add dsh-custom-mode` 会解析成**"超过 24 小时的最新版"**。实测:在 1.9.0 已是 latest 的干净机器上,
|
|
332
|
+
不带版本号的命令装到的是 **1.0.1**。钉版本可以绕过冷却期,所以上面的命令带 `@`。
|
|
333
|
+
|
|
334
|
+
**怎么知道自己在跑哪一版**:设置页底部显示已安装版本(「插件版本」),与 `npm view dsh-custom-mode version` 对照即可。
|
|
335
|
+
|
|
336
|
+
**更新不会碰的东西**:`$DSH_HOME/.agent-presets/<你的助手>/` —— `prompt.md`、`preset.yml` 与你逐行拨过的开关都属于你。
|
|
337
|
+
播种只补**缺失**的文件,你写过的提示词永远不会被覆盖。
|
|
338
|
+
|
|
339
|
+
**如果基础模式与插件开关变灰、并带一行提示**:这条 dsh 线没有向本插件交出出厂组成(解析器依次试过宿主的
|
|
340
|
+
`readDocument()`、旧线的 presets 包、打包的 `dsh-web-app` patch,宿主日志里会写清每一次尝试)。系统提示词
|
|
341
|
+
仍可单独保存,模式本身不受影响。0.1.7 上这在 1.9.13 之前是一个硬 500。
|
|
342
|
+
|
|
343
|
+
**如果更新后某个模式不再出现在选择器里**:老版本创建的助手会保留它自己的组成文件;如果那份文件启用了一行本机
|
|
344
|
+
这条 dsh 线不提供的插件,平台会把整个预设判为 broken 并从选择器里静默丢弃(设置页照常能开)。打开设置页:
|
|
345
|
+
它会明确告诉你,并提供**「按本线修复」** —— 只关掉那几行,其它选择一字不动。
|
package/assistants.mjs
CHANGED
|
@@ -409,17 +409,37 @@ export function reorderAssistant(rows, input, write = writePresetMeta) {
|
|
|
409
409
|
const result = write(item.name, item.description, directory, { order: position + 1 })
|
|
410
410
|
if (result.ok !== true) {
|
|
411
411
|
const failed = assistantDir(rows, item.id)
|
|
412
|
+
// 报的必须是**真的回滚了几个**,不是"尝试了几个"。
|
|
413
|
+
//
|
|
414
|
+
// 实测(`probes/probe-io.mjs` O):旧代码把 `written.length` 写进消息,而循环里
|
|
415
|
+
// 快照为 null 的条目被 `continue` 跳过、写失败的被 catch 吞掉 —— 于是两种都没还原的情况下,
|
|
416
|
+
// 用户看到的仍然是"已回滚 2 个"。那是一句**被夸大的保证**:磁盘上留着改了一半的时间线,
|
|
417
|
+
// 而错误消息说安全网已经兜住了。
|
|
418
|
+
//
|
|
419
|
+
// 现在分开数:真的还原成功几个、有几个还原不了(快照没读到 / 写回失败)。
|
|
420
|
+
let restored = 0
|
|
421
|
+
let unrestorable = 0
|
|
412
422
|
for (const done of written) {
|
|
413
|
-
if (done.text === null)
|
|
423
|
+
if (done.text === null) {
|
|
424
|
+
// 快照没读到,就没有可以写回去的内容 —— 这条回滚不了,不能算进"已回滚"。
|
|
425
|
+
unrestorable += 1
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
414
428
|
try {
|
|
415
429
|
writeAtomic(presetMetaPath(done.directory), done.text)
|
|
430
|
+
restored += 1
|
|
416
431
|
} catch {
|
|
417
|
-
/*
|
|
432
|
+
/* 回滚失败不掩盖原始错误,但也不算成功 */
|
|
433
|
+
unrestorable += 1
|
|
418
434
|
}
|
|
419
435
|
}
|
|
436
|
+
const rollback =
|
|
437
|
+
unrestorable === 0
|
|
438
|
+
? `已回滚 ${String(restored)} 个已写入的顺序`
|
|
439
|
+
: `已回滚 ${String(restored)} 个;另有 ${String(unrestorable)} 个**未能还原**(快照读不到或写回失败),磁盘上可能留有改动`
|
|
420
440
|
return {
|
|
421
441
|
...result,
|
|
422
|
-
error: result.error +
|
|
442
|
+
error: result.error + `(${rollback};失败的目录:${String(failed)})`,
|
|
423
443
|
}
|
|
424
444
|
}
|
|
425
445
|
written.push({ directory, text: snapshot.find((entry) => entry.item.id === item.id)?.text ?? null })
|
package/atomic.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* random temporary name does not help with *that* collision, only with temp-vs-temp ones.
|
|
15
15
|
*/
|
|
16
16
|
import { randomBytes } from 'node:crypto'
|
|
17
|
-
import { renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { existsSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
18
18
|
|
|
19
19
|
/** Synchronous backoff: these write paths are synchronous, so waiting is simpler than async plumbing. */
|
|
20
20
|
function sleepSync(ms) {
|
|
@@ -81,22 +81,105 @@ export function discardStaged(staged) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Write several files together
|
|
84
|
+
* Write several files together, as close to a transaction as a filesystem will give us.
|
|
85
85
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
86
|
+
* **Why this is not the old two-phase version.** The previous implementation staged everything and then
|
|
87
|
+
* renamed them one by one, and said so honestly in a comment ("not a true transaction — a rename can still
|
|
88
|
+
* fail between two commits"). But the callers' contracts do not say that: `savePromptOnly` promises
|
|
89
|
+
* "提示词与 preset.yml 一起换名,失败则两个都不动", and `saveState` says the three files are written
|
|
90
|
+
* together (issue #5). A caller that returns `ok:false` while the disk holds new-prompt + old-name is
|
|
91
|
+
* exactly the state those promises rule out — the user is told nothing was saved and half of it was.
|
|
92
|
+
*
|
|
93
|
+
* Two defects were measured by `probes/probe-io.mjs`:
|
|
94
|
+
*
|
|
95
|
+
* L staging happened **outside** the try, so a staging failure left its `.tmp-…` behind — and the
|
|
96
|
+
* module's own comment says an orphan temporary in an assistant directory is not just litter,
|
|
97
|
+
* because the roster scans that directory;
|
|
98
|
+
* K committing renamed them in order and gave up on the first failure — the earlier targets were
|
|
99
|
+
* already replaced, and the caller saw a plain failure.
|
|
100
|
+
*
|
|
101
|
+
* **How it is transactional now**: stage everything, then move every existing target aside to a backup,
|
|
102
|
+
* then put the new files in place. Any failure anywhere in that sequence restores from the backups and
|
|
103
|
+
* removes what it had already installed, so the caller's "nothing changed" is true. The backups are
|
|
104
|
+
* removed only after every destination is in place.
|
|
88
105
|
*
|
|
89
106
|
* @param {Array<[string, string]>} entries - `[file, text]` pairs.
|
|
90
107
|
* @returns {void}
|
|
91
108
|
*/
|
|
92
109
|
export function writeAtomicPair(entries) {
|
|
93
|
-
const
|
|
110
|
+
const stamp = `${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
111
|
+
|
|
112
|
+
// ── phase 0: refuse to touch a destination that is a directory. ──
|
|
113
|
+
//
|
|
114
|
+
// Measured: `probes/probe-io.mjs` K injects the failure by making the second target a directory.
|
|
115
|
+
// A naive "move the old target aside, then install" transaction **succeeds** on that input — it renames
|
|
116
|
+
// the user's directory to `.bak-…` and leaves a file where it was. That is worse than failing: it obeys
|
|
117
|
+
// the letter of "transactional" while quietly relocating a directory nobody asked it to touch.
|
|
118
|
+
// Being transactional must not mean "willing to move anything out of the way". A directory at a path that
|
|
119
|
+
// should hold a file is not a target to replace — it is a reason to refuse before anything moves.
|
|
120
|
+
for (const [file] of entries) {
|
|
121
|
+
if (existsSync(file) && statSync(file).isDirectory()) {
|
|
122
|
+
throw new Error(`拒绝写入:目标已存在且是目录 — ${file}(未改动任何文件)`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── phase 1: stage everything. A failure here must not leave a temporary behind. ──
|
|
127
|
+
const staged = []
|
|
94
128
|
try {
|
|
95
|
-
for (const
|
|
129
|
+
for (const [file, text] of entries) staged.push(stageAtomic(file, text))
|
|
96
130
|
} catch (error) {
|
|
97
131
|
for (const one of staged) discardStaged(one)
|
|
98
132
|
throw error
|
|
99
133
|
}
|
|
134
|
+
|
|
135
|
+
// ── phase 2: move the current targets aside. `null` = the target did not exist. ──
|
|
136
|
+
const backups = []
|
|
137
|
+
const installed = []
|
|
138
|
+
try {
|
|
139
|
+
for (const one of staged) {
|
|
140
|
+
if (existsSync(one.file)) {
|
|
141
|
+
const backup = `${one.file}.bak-${stamp}`
|
|
142
|
+
renameWithRetry(one.file, backup)
|
|
143
|
+
backups.push([one.file, backup])
|
|
144
|
+
} else {
|
|
145
|
+
backups.push([one.file, null])
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ── phase 3: install. ──
|
|
149
|
+
for (const one of staged) {
|
|
150
|
+
commitStaged(one)
|
|
151
|
+
installed.push(one)
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Undo in reverse: remove what we installed, then put the backups back.
|
|
155
|
+
for (const one of installed) {
|
|
156
|
+
try {
|
|
157
|
+
rmSync(one.file, { force: true })
|
|
158
|
+
} catch {
|
|
159
|
+
/* best effort — the restore below is what matters */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [file, backup] of backups) {
|
|
163
|
+
if (backup === null) continue
|
|
164
|
+
try {
|
|
165
|
+
renameWithRetry(backup, file)
|
|
166
|
+
} catch {
|
|
167
|
+
/* best effort; the thrown error still tells the caller nothing is consistent */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const one of staged) discardStaged(one)
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── success: drop the backups ──
|
|
175
|
+
for (const [, backup] of backups) {
|
|
176
|
+
if (backup === null) continue
|
|
177
|
+
try {
|
|
178
|
+
rmSync(backup, { force: true })
|
|
179
|
+
} catch {
|
|
180
|
+
/* a leftover .bak-… is inert; failing the whole write over it would be worse */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
100
183
|
}
|
|
101
184
|
|
|
102
185
|
/**
|
|
@@ -104,6 +187,9 @@ export function writeAtomicPair(entries) {
|
|
|
104
187
|
*
|
|
105
188
|
* The temporary name is unique per call (pid + random suffix). On failure the temporary file is removed —
|
|
106
189
|
* an orphan `.tmp-…` inside an assistant directory is not just litter: the roster scans that directory.
|
|
190
|
+
* **The staging write itself is inside the try** for the same reason: a `writeFileSync` that fails part way
|
|
191
|
+
* (a full disk, an EACCES) leaves a partial temporary, and that is exactly the litter this promises not to
|
|
192
|
+
* leave. Measured by `probes/probe-io.mjs` — the same defect the pair version had.
|
|
107
193
|
*
|
|
108
194
|
* @param {string} file - destination path.
|
|
109
195
|
* @param {string} text - content to write.
|
|
@@ -111,8 +197,8 @@ export function writeAtomicPair(entries) {
|
|
|
111
197
|
*/
|
|
112
198
|
export function writeAtomic(file, text) {
|
|
113
199
|
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
114
|
-
writeFileSync(temporary, text, 'utf8')
|
|
115
200
|
try {
|
|
201
|
+
writeFileSync(temporary, text, 'utf8')
|
|
116
202
|
renameWithRetry(temporary, file)
|
|
117
203
|
} catch (error) {
|
|
118
204
|
try {
|
package/base-composition.mjs
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* 5. nothing left → {@link BaseCompositionUnavailableError}, which callers turn into a **visible,
|
|
26
26
|
* typed** state instead of a 500.
|
|
27
27
|
*
|
|
28
|
+
* One mode is not on this chain at all: {@link UNION_MODE_ID} ships nothing, because it is *synthesised*
|
|
29
|
+
* from the four above (in `composition.mjs`, which owns the splitter). Nothing in this file resolves it.
|
|
30
|
+
*
|
|
28
31
|
* Why `readDocument` and not `compositionInventory()`: the inventory is **flattened**
|
|
29
32
|
* (`entryId`/`moduleName`/`enabled`/`condition`), so the `isolate` realms a service row must sit inside
|
|
30
33
|
* are gone (measured 2026-09-25; `parse-composition.mjs` rules the same route out for the same reason).
|
|
@@ -44,9 +47,30 @@ export const LEGACY_DIR_ENV = 'DSH_SHIPPED_PRESETS_DIR'
|
|
|
44
47
|
/** Explicit override for the declarative file layout (a directory holding `<mode>.patch.yml`). */
|
|
45
48
|
export const PATCH_DIR_ENV = 'DSH_PRESET_PATCH_DIR'
|
|
46
49
|
|
|
47
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* The **shipped** base modes — the four the host declares.
|
|
52
|
+
*
|
|
53
|
+
* Kept here so discovery has no opinion about the UI. Everything that means "ask the host" keys off exactly
|
|
54
|
+
* these four: legacy/patch discovery, the CI fixture materialisation, and the `readDocument(<mode>)` sweep in
|
|
55
|
+
* `index.mjs`. The picker offers one more ({@link UNION_MODE_ID}) and it is deliberately **absent** from this
|
|
56
|
+
* list — there is no shipped file or document behind it, so nothing may go looking for one.
|
|
57
|
+
*/
|
|
48
58
|
export const BASE_MODE_IDS = ['standard', 'ptc', 'minimal', 'cordis']
|
|
49
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The fifth base mode a user may build on: all four shipped compositions unioned into one.
|
|
62
|
+
*
|
|
63
|
+
* **The id is `all`, not `custom`.** `custom` is the user preset this feature writes
|
|
64
|
+
* (`$DSH_HOME/.agent-presets/custom/`), so a base mode by that name would make the resolver ask the host for
|
|
65
|
+
* `readDocument('custom')` and hand the preset its own composition back — the self-inclusion the picker has
|
|
66
|
+
* always refused. The id is the machine name; the label is the user's (`base.all.label`), exactly as
|
|
67
|
+
* `cordis` is shown as 「创造模式」.
|
|
68
|
+
*
|
|
69
|
+
* Its text is synthesised in `composition.mjs` from the four shipped ones. Only the *name* lives here: the
|
|
70
|
+
* synthesis needs the composition splitter, and importing that would be a cycle.
|
|
71
|
+
*/
|
|
72
|
+
export const UNION_MODE_ID = 'all'
|
|
73
|
+
|
|
50
74
|
/**
|
|
51
75
|
* Thrown when no route yields a base composition.
|
|
52
76
|
*
|