dsh-custom-mode 1.10.0 → 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 +2 -2
- package/README.md +38 -3
- package/README.zh.md +32 -3
- package/assistants.mjs +23 -3
- package/atomic.mjs +93 -7
- package/base-composition.mjs +25 -1
- package/client.js +25 -3
- package/composition.mjs +296 -18
- package/index.mjs +60 -5
- package/journal.mjs +32 -5
- package/locales.mjs +8 -0
- package/meta.mjs +77 -10
- package/package.json +1 -1
package/README.i18n.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# 双语配对一致性记录:两侧在「上次确认一致」时的 git blob 哈希。
|
|
2
2
|
# 两份文档权威相同——改完任意一侧,请把另一侧也改掉,然后重新记录:
|
|
3
3
|
# node tools/verify-translation-pairing.mjs --write
|
|
4
|
-
README.md:
|
|
5
|
-
README.zh.md:
|
|
4
|
+
README.md: 4e45a434fa7cfd4a939cda95c8d79d81395df7fe
|
|
5
|
+
README.zh.md: 22d9b905b579dab2c35faa095e71ffb211ab46f2
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ role-play). · **中文**:DeepSeek Harness(dsh)自定义模式插件 —
|
|
|
11
11
|
|
|
12
12
|
<!-- community badge row: required in every new repository (see the storefront template in the operator SOP/DSH Plugins folder) -->
|
|
13
13
|
|
|
14
|
-
[](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
|
+
[](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)
|
|
15
15
|
|
|
16
16
|
<!-- badge rows: same two-part structure (dark label + coloured value) in both rows; every new repo copies this block -->
|
|
17
17
|
[](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)
|
|
@@ -59,7 +59,7 @@ One command installs everything — the settings-page plugin, and the preset it
|
|
|
59
59
|
activation:
|
|
60
60
|
|
|
61
61
|
```sh
|
|
62
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
62
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.0 # pin the version to get this one for sure
|
|
63
63
|
# A bare `add dsh-custom-mode` is subject to pnpm's release cooldown (`minimumReleaseAge`, 1 day by
|
|
64
64
|
# default): for hours after a release it can silently install an OLDER version — measured: a bare
|
|
65
65
|
# install 38 minutes after 1.3.0 shipped landed on 1.0.3. Check what you got with `npm ls
|
|
@@ -142,6 +142,24 @@ cd dsh-custom-mode
|
|
|
142
142
|
The mode needs a profile that ships `agent-presets` — the `web` profile does, `tui` and `headless` do
|
|
143
143
|
not.
|
|
144
144
|
|
|
145
|
+
### Trying it without installing anything
|
|
146
|
+
|
|
147
|
+
Every release carries a **`dsh-custom-mode.dshpreset`** beside the source
|
|
148
|
+
([latest](https://github.com/BOWLUNA/dsh-custom-mode/releases/latest/download/dsh-custom-mode.dshpreset)).
|
|
149
|
+
It is a zip holding one ready-to-use assistant: `manifest.json` plus `preset/` — the composition, a
|
|
150
|
+
`prompt.md`, the reader that re-reads it before every model step, and a model-facing tool that reads and
|
|
151
|
+
rewrites that prompt. Import it in the desktop app and you have a working「自定义模式」**without
|
|
152
|
+
installing a package**:
|
|
153
|
+
|
|
154
|
+
| | |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| **What you get** | one assistant whose system prompt is a plain file. Edit it in any editor — the agent loop re-reads it before every model step, so the change applies on the next step, with no restart. Or just ask the model to rewrite it: the preset ships exactly that tool. |
|
|
157
|
+
| **What you don't get** | the settings page. That is what the plugin adds — the page itself, plus managing **several** assistants side by side and picking one from the new-session menu. |
|
|
158
|
+
|
|
159
|
+
So the preset is the 30-second way to see what this is, and the plugin is the way to live with it.
|
|
160
|
+
Importing the preset does not conflict with installing the plugin afterwards: the plugin seeds its own
|
|
161
|
+
preset under `$DSH_HOME/.agent-presets/` and never overwrites a `prompt.md` you wrote.
|
|
162
|
+
|
|
145
163
|
## Usage
|
|
146
164
|
|
|
147
165
|
The settings page (Settings → "Custom mode") is an **assistant manager**: the top of the page lists
|
|
@@ -187,6 +205,23 @@ plugin switches, system prompt) edit **whichever one is selected**.
|
|
|
187
205
|
this plugin always replaces the base's `persona` row with its own reader (`complete: false`), so the
|
|
188
206
|
base's *prompt* semantics are **not** inherited. Minimal is the visible case: you get minimal's tool set,
|
|
189
207
|
not minimal's prompt.
|
|
208
|
+
- **A fifth base mode: Custom — the union of every shipped row.** The first four are dsh's own; the fifth is
|
|
209
|
+
**synthesised by this plugin**. Its row set is the union of those four, and a row declared by more than one
|
|
210
|
+
mode keeps the text and shipped default of whichever mode comes first in `standard → ptc → minimal →
|
|
211
|
+
cordis` order — so standard's shipped state wins where there is a conflict.
|
|
212
|
+
Why it has to exist: a switch can only rewrite a row the *base text already has*, so on Standard the rows
|
|
213
|
+
only PTC declares (`tool-presentation`), only Minimal declares (the persistent-terminal group) and only
|
|
214
|
+
Cordis declares (`tool-cordis`) were **unreachable** — not hidden in the UI, just absent from the composer.
|
|
215
|
+
Measured on `0.2.0-rc.2`: standard 32 rows, PTC 33, Cordis 33, Minimal 7, and the union **40**. Groups
|
|
216
|
+
travel whole with their `isolate` realms and `!!js` conditions intact, so the union cannot invent a row
|
|
217
|
+
that `dsh-agent-presets` would refuse to mount.
|
|
218
|
+
- **The two shells cannot both be on, and the plugin moves the switch for you.** The union contains one
|
|
219
|
+
mutually exclusive pair: Minimal's "persistent terminal" set and standard's `tool-bash` / `tool-pwsh` are
|
|
220
|
+
two implementations of the same thing — both register a tool called `bash`. Enabling both makes
|
|
221
|
+
`dsh-agent-presets` mark the whole mode **broken**, and a broken mode is silently dropped from **every**
|
|
222
|
+
picker while the settings page stays green. So in Custom mode, turning one side on turns the other off,
|
|
223
|
+
and the save message says which row moved. A conflicting combination written outside the page (hand edit,
|
|
224
|
+
another tool) is named by a warning instead of failing silently.
|
|
190
225
|
- **Switching assistants never discards drafts** — each keeps its own unsaved edits, marked
|
|
191
226
|
"Unsaved" in the list; the only path that throws edits away is the reload button, which renames
|
|
192
227
|
itself to say so.
|
|
@@ -385,7 +420,7 @@ installing again — your data is not touched:
|
|
|
385
420
|
|
|
386
421
|
```sh
|
|
387
422
|
# the pinned form: what you ask for is what you get
|
|
388
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
423
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.0
|
|
389
424
|
# then restart the DSH process that serves the web profile
|
|
390
425
|
```
|
|
391
426
|
|
package/README.zh.md
CHANGED
|
@@ -10,7 +10,7 @@ base mode / plugin switches / multi-assistant / multi-persona。
|
|
|
10
10
|
|
|
11
11
|
<!-- 社区入口:第二排徽章在每个新仓库里都要加(见 SOP/DSH Plugins/仓库门面模板.md) -->
|
|
12
12
|
|
|
13
|
-
[](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)
|
|
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
14
|
|
|
15
15
|
<!-- 徽章:两排统一"深色标签 + 彩色值"的两段式;新仓库照抄这一块 -->
|
|
16
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)
|
|
@@ -48,7 +48,7 @@ base mode / plugin switches / multi-assistant / multi-persona。
|
|
|
48
48
|
一条命令装完——设置页插件,以及它在首次激活时自动播种的 preset:
|
|
49
49
|
|
|
50
50
|
```sh
|
|
51
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
51
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.0 # 钉版本才能确定拿到这一版
|
|
52
52
|
# 不带版本号会受 pnpm 的发布冷却期影响(`minimumReleaseAge`,默认一天):发布后数小时内按名安装
|
|
53
53
|
# 可能**静默装到旧版** —— 实测 1.3.0 发布 38 分钟后按名安装装到了 1.0.3。用 profile 里的
|
|
54
54
|
# `npm ls dsh-custom-mode` 核对实际装到的版本,或像上面那样钉版本。
|
|
@@ -117,6 +117,22 @@ cd dsh-custom-mode
|
|
|
117
117
|
|
|
118
118
|
模式需要带 `agent-presets` 的 profile:`web` 有,`tui` 与 `headless` 没有。
|
|
119
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
|
+
|
|
120
136
|
## 使用
|
|
121
137
|
|
|
122
138
|
设置页(设置 → 「自定义模式」)是一个**助手管理器**:上面列出你所有的自定义模式,可以新增、切换、删除,下面四个区块(模式名称 / 基础模式 / 插件开关 / 系统提示词)编辑的是**当前选中的那一个**。
|
|
@@ -141,6 +157,19 @@ cd dsh-custom-mode
|
|
|
141
157
|
- **基础模式是"行集合",不是提示词** —— 底子决定有哪些行、这个模式有哪些工具;本插件始终把底子的
|
|
142
158
|
`persona` 行替换成自己的读取器(`complete: false`),所以底子的**提示词语义不会被继承**。最明显的是
|
|
143
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
|
+
页面之外写进去的冲突组合(手工编辑、别的工具)会在告警里被点名。
|
|
144
173
|
- **切换助手不会丢草稿** —— 每个助手各自留着未保存的修改,列表上用「未保存」标出来;唯一会放弃修改的是「放弃修改并重新读取」(有草稿时按钮会改名说明)。
|
|
145
174
|
- **删除** —— 先确认,再由插件自己动手删。确认形态取决于这条线:壳把 `RiskConfirmation` 交给第三方客户端插件时,是壳自己的弹窗(需勾选);而 `0.1.7-rc.2` 的客户端种子表只暴露 `Button/Input/Switch/Tag/Pill`,于是退化为原生 `confirm`(护栏不变,少一个勾选框)。删除两条线都能用:`0.1.6` 及更早有 `agentPresets.remove()`,`0.1.7`+ 根本没有这个调用 —— 那里由插件自己注销注册表项并删掉目录。删除只移除磁盘上的模式目录:**正在使用它的会话不受影响**(组成在会话创建时就已读取),新建会话时不再出现。
|
|
146
175
|
- **直接对 agent 说** —— 每个助手都自带 `custom_prompt` 工具,会话里可以读取或改写**它自己**的提示词。
|
|
@@ -294,7 +323,7 @@ MIT
|
|
|
294
323
|
|
|
295
324
|
```sh
|
|
296
325
|
# 钉版本的写法:要哪版就是哪版
|
|
297
|
-
dsh plugin --profile web add dsh-custom-mode@1.
|
|
326
|
+
dsh plugin --profile web add dsh-custom-mode@1.11.0
|
|
298
327
|
# 然后重启为该 profile 提供服务的 DSH 进程
|
|
299
328
|
```
|
|
300
329
|
|
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
|
*
|
package/client.js
CHANGED
|
@@ -124,6 +124,7 @@ try {
|
|
|
124
124
|
"meta.version": "插件版本",
|
|
125
125
|
"meta.versionHint": "安装时不钉版本号会受 pnpm 发布冷却期影响(默认 24 小时),可能装到较旧的版本。要换版本请按 README 的钉版本命令重装,然后重启 DSH。",
|
|
126
126
|
"api.saved": "已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。",
|
|
127
|
+
"api.savedWithExclusiveRows": "已保存({name},基础模式 {mode})。两套壳注册同名工具、不能同时启用 —— 已自动关掉:{rows}。新建会话即生效,当前会话保持原配置。",
|
|
127
128
|
"api.created": "已创建「{name}」。现在可以为它写系统提示词。",
|
|
128
129
|
"api.duplicated": "已复制自「{from}」。两份从此各改各的。",
|
|
129
130
|
"api.deleted": "已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。",
|
|
@@ -156,6 +157,7 @@ try {
|
|
|
156
157
|
"api.baseCompositionUnavailable": "本机取不到基础模式「{mode}」的出厂组成,无法改动插件开关或基础模式(系统提示词可以单独保存)。",
|
|
157
158
|
"warn.approvalGateMissing": "审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。",
|
|
158
159
|
"warn.unresolvableRows": "有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。",
|
|
160
|
+
"warn.exclusiveRowsActive": "两条互斥的壳同时开着(它们注册同名工具):平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。把其中一条关掉即可 —— 在设置页保存一次也会自动避让。",
|
|
159
161
|
"warn.baseCompositionUnavailable": "取不到本机这条 DSH 线的出厂组成:基础模式与插件开关暂时不能改(系统提示词不受影响,照常可保存)。已尝试的途径写在宿主日志里。",
|
|
160
162
|
"warn.approvalGateMissing.label": "审批闸门未启用",
|
|
161
163
|
"warn.approvalGateMissing.hint": "这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。",
|
|
@@ -269,7 +271,9 @@ try {
|
|
|
269
271
|
"base.minimal.label": "极简模式",
|
|
270
272
|
"base.minimal.note": "只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理。系统提示词不是极简那一套(persona 行被本模式替换)",
|
|
271
273
|
"base.cordis.label": "Cordis 模式",
|
|
272
|
-
"base.cordis.note": "标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness"
|
|
274
|
+
"base.cordis.note": "标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness",
|
|
275
|
+
"base.all.label": "自定义模式",
|
|
276
|
+
"base.all.note": "不继承任何单一原生模式:以标准模式的全套行为底,补上只有 PTC / 极简 / 创造模式才有的行,全部都能逐行开关"
|
|
273
277
|
}
|
|
274
278
|
|
|
275
279
|
const EN = {
|
|
@@ -338,6 +342,7 @@ try {
|
|
|
338
342
|
"meta.version": "Plugin version",
|
|
339
343
|
"meta.versionHint": "Installing without a pinned version is subject to pnpm’s release cooldown (24 h by default) and can land on an older release. To change version, reinstall with the pinned command from the README and restart DSH.",
|
|
340
344
|
"api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
|
|
345
|
+
"api.savedWithExclusiveRows": "Saved ({name}, base mode {mode}). The two shell implementations register the same tool names and cannot both be on — turned off automatically: {rows}. A new session picks it up; the current one keeps its configuration.",
|
|
341
346
|
"api.created": "Created {name}. You can write its system prompt now.",
|
|
342
347
|
"api.duplicated": "Copied from {from}. The two are independent from now on.",
|
|
343
348
|
"api.deleted": "Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.",
|
|
@@ -373,6 +378,7 @@ try {
|
|
|
373
378
|
"warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
|
|
374
379
|
"warn.approvalGateMissing": "The approval gate is off: this DSH build has no tools/pre-execute event, so in-session prompt rewrites do not ask for approval. See the details.",
|
|
375
380
|
"warn.unresolvableRows": "Some rows cannot be resolved on this DSH line: the platform marks the whole mode broken and silently drops it from the new-session picker. Click Fix for this line — it only turns those rows off and leaves your other choices alone.",
|
|
381
|
+
"warn.exclusiveRowsActive": "Both shells are on, and they register the same tool names: the platform marks the whole mode broken and silently drops it from the new-session picker. Turn one of them off — saving from the settings page also resolves it automatically.",
|
|
376
382
|
"warn.approvalGateMissing.label": "Approval gate is off",
|
|
377
383
|
"warn.approvalGateMissing.hint": "This DSH build has no tools/pre-execute event, so in-session prompt rewrites do NOT ask for approval. The settings page is unaffected; upgrade DSH or turn the custom_prompt tool row off to restore the gate.",
|
|
378
384
|
"warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
|
|
@@ -483,7 +489,9 @@ try {
|
|
|
483
489
|
"base.minimal.label": "Minimal",
|
|
484
490
|
"base.minimal.note": "Shell and terminal only, 7 rows; no files, search, skills or subagents. The system prompt is not minimal's either (this mode replaces the persona row)",
|
|
485
491
|
"base.cordis.label": "Cordis",
|
|
486
|
-
"base.cordis.note": "Standard plus the Cordis toolset, letting the agent modify its own harness"
|
|
492
|
+
"base.cordis.note": "Standard plus the Cordis toolset, letting the agent modify its own harness",
|
|
493
|
+
"base.all.label": "Custom",
|
|
494
|
+
"base.all.note": "Inherits no single shipped mode: standard's full row list plus the rows only PTC / Minimal / Cordis declare, every one of them switchable"
|
|
487
495
|
}
|
|
488
496
|
|
|
489
497
|
const TRANSLATIONS = { zh: ZH, en: EN }
|
|
@@ -1067,7 +1075,21 @@ try {
|
|
|
1067
1075
|
const code = result !== null && typeof result === "object" && typeof result.code === "string" ? result.code : ""
|
|
1068
1076
|
if (code !== "") {
|
|
1069
1077
|
const template = translate("api." + code, "", shellT)
|
|
1070
|
-
if (template !== "")
|
|
1078
|
+
if (template !== "") {
|
|
1079
|
+
/**
|
|
1080
|
+
* `{mode}` 是**基础模式**,宿主只能交出机器名(`standard` / `ptc` / `minimal` /
|
|
1081
|
+
* `cordis` / `all`)。直接印出来就是「基础模式 all」—— 中文读者看不懂,
|
|
1082
|
+
* 而且和壳自己的叫法(标准模式 / PTC 模式 / 极简模式 / 创造模式 / 自定义模式)对不上,
|
|
1083
|
+
* 一条原生质感的提示不该长这样。页面手上有 `base.<id>.label`,用它;
|
|
1084
|
+
* 词典里没有这个 id 时(旧宿主 / 未来新增)退回机器名,宁可难看也不要空。
|
|
1085
|
+
*/
|
|
1086
|
+
const params = result.params
|
|
1087
|
+
const shown =
|
|
1088
|
+
params !== null && typeof params === "object" && typeof params.mode === "string"
|
|
1089
|
+
? { ...params, mode: translate("base." + params.mode + ".label", params.mode, shellT) }
|
|
1090
|
+
: params
|
|
1091
|
+
return fillPlaceholders(template, shown)
|
|
1092
|
+
}
|
|
1071
1093
|
}
|
|
1072
1094
|
if (result !== null && typeof result === "object") {
|
|
1073
1095
|
if (typeof result.error === "string" && result.error !== "") return result.error
|
package/composition.mjs
CHANGED
|
@@ -30,12 +30,16 @@ import { join } from 'node:path'
|
|
|
30
30
|
// 本模块只消费它 —— 合成与排版手术仍然全部发生在这里。
|
|
31
31
|
import {
|
|
32
32
|
baseCompositionPath,
|
|
33
|
+
BASE_MODE_IDS,
|
|
34
|
+
BaseCompositionUnavailableError,
|
|
33
35
|
readBaseCompositionText,
|
|
34
36
|
setShippedPresetsDir,
|
|
35
37
|
shippedPresetsDir,
|
|
38
|
+
UNION_MODE_ID,
|
|
36
39
|
} from './base-composition.mjs'
|
|
37
40
|
|
|
38
41
|
export { baseCompositionPath, setShippedPresetsDir, shippedPresetsDir } from './base-composition.mjs'
|
|
42
|
+
export { BASE_MODE_IDS, UNION_MODE_ID } from './base-composition.mjs'
|
|
39
43
|
export {
|
|
40
44
|
BaseCompositionUnavailableError,
|
|
41
45
|
isBaseCompositionUnavailable,
|
|
@@ -50,16 +54,27 @@ export {
|
|
|
50
54
|
*/
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
|
-
* Base modes a user may build on.
|
|
57
|
+
* Base modes a user may build on — the picker's own list, in order.
|
|
58
|
+
*
|
|
59
|
+
* The first four are the **shipped** modes (`BASE_MODE_IDS`); a fifth is synthesised here
|
|
60
|
+
* ({@link UNION_MODE_ID}), so "which rows can I switch at all" stops being decided by whichever single
|
|
61
|
+
* shipped mode the user happened to pick.
|
|
54
62
|
*
|
|
55
|
-
* `custom` is deliberately absent: it is this feature's own
|
|
56
|
-
*
|
|
63
|
+
* `custom` is still deliberately absent, and for the same reason as before: it is this feature's own
|
|
64
|
+
* output, and a base mode by that name would make a preset recursively include itself. The union mode
|
|
65
|
+
* sidesteps that by being a **different id** (`all`) whose text is built from the four *shipped* modes —
|
|
66
|
+
* it never asks the host for the user's own preset.
|
|
57
67
|
*/
|
|
58
68
|
export const BASE_MODES = [
|
|
59
69
|
{ id: 'standard', label: '标准模式', note: '完整编码能力:Shell、文件、检索、技能、计划、目标、子代理、工作流' },
|
|
60
70
|
{ id: 'ptc', label: 'PTC 模式', note: '在标准模式基础上启用 PTC 工具呈现(tool-presentation)' },
|
|
61
71
|
{ id: 'minimal', label: '极简模式', note: '只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理' },
|
|
62
72
|
{ id: 'cordis', label: 'Cordis 模式', note: '标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness' },
|
|
73
|
+
{
|
|
74
|
+
id: UNION_MODE_ID,
|
|
75
|
+
label: '自定义模式',
|
|
76
|
+
note: '不继承任何单一原生模式:以标准模式的全套行为底,补上只有 PTC / 极简 / 创造模式才有的行,全部都能逐行开关',
|
|
77
|
+
},
|
|
63
78
|
]
|
|
64
79
|
|
|
65
80
|
/**
|
|
@@ -117,6 +132,50 @@ export const ROW_META = {
|
|
|
117
132
|
'persistent-pwsh': { label: '持久 pwsh' },
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 出厂行里的**互斥组**:同一份组成里同时启用,会让挂载方把整个预设判为 **broken**,
|
|
137
|
+
* 而 broken 的预设被从**所有**选择器里静默丢掉 —— 设置页却照常工作(本仓记录过两次的最阴的一种失败)。
|
|
138
|
+
*
|
|
139
|
+
* 实测依据(2026-10-01,dsh `0.2.0-rc.2`):把并集组成真的写进 `.agent-presets/custom/agent.cordis.yml`
|
|
140
|
+
* 再启动,然后读实例自己的 `/api/custom-mode`:
|
|
141
|
+
*
|
|
142
|
+
* broken = "persistent-bash (@deepseek-ai/dsh-tool-bash-persistent):
|
|
143
|
+
* tool \"bash\" is already registered in this scope"
|
|
144
|
+
*
|
|
145
|
+
* 两套壳注册的工具名相同(`bash` / `pwsh`),所以它们是**二选一**,不是叠加。极简模式里不冲突,
|
|
146
|
+
* 只是因为它**没有**标准那一套。逐行开关本身表达不了"二选一",所以互斥必须在这里显式声明。
|
|
147
|
+
*
|
|
148
|
+
* 同一轮实测的对照(每行都是"写组成 → 真启动 → 读 broken"):
|
|
149
|
+
*
|
|
150
|
+
* | 组成 | broken |
|
|
151
|
+
* | --- | --- |
|
|
152
|
+
* | 并集原样 | ✗(上面那条) |
|
|
153
|
+
* | 只关掉 `persistent-shell` | ✓ null |
|
|
154
|
+
* | 只关掉 `tool-bash` + `tool-pwsh` | ✓ null |
|
|
155
|
+
* | 把 `persistent-shell` 整组删掉 | ✓ null |
|
|
156
|
+
* | 删掉 `tool-presentation` / `tool-cordis`(两个附加行) | ✗ 仍 broken ⇒ 附加行无罪 |
|
|
157
|
+
*
|
|
158
|
+
* ★ 这条表**必须被维护**:上游调整行时它就是唯一的真相。`composition.test.mjs` 会断言表里每个 id
|
|
159
|
+
* 都真的出现在某份出厂组成里 —— 上游把它删了,CI 就红,而不是让这里静默失效。
|
|
160
|
+
*/
|
|
161
|
+
export const EXCLUSIVE_ROW_SETS = [
|
|
162
|
+
{
|
|
163
|
+
id: 'shell',
|
|
164
|
+
sides: [
|
|
165
|
+
{ label: '标准壳', rows: ['tool-bash', 'tool-pwsh'] },
|
|
166
|
+
{ label: '持久终端壳', rows: ['persistent-shell'] },
|
|
167
|
+
],
|
|
168
|
+
/**
|
|
169
|
+
* 并集模式里默认关掉哪一侧。
|
|
170
|
+
*
|
|
171
|
+
* 必须**显式关**,不能指望"取那位模式的出厂默认":`persistent-shell` 在极简模式里出厂就是**启用**的
|
|
172
|
+
* (那正是极简模式的全部内容),所以照搬出厂状态合出来的并集是**非法**的。实测:`overrides: {}`
|
|
173
|
+
* 保存并集模式,写完之后不重启,roster 里立刻就是 `broken`。
|
|
174
|
+
*/
|
|
175
|
+
unionDefaultOff: ['persistent-shell'],
|
|
176
|
+
},
|
|
177
|
+
]
|
|
178
|
+
|
|
120
179
|
/** 出厂组成的路径与解析链都在 base-composition.mjs(下面按名字再导出,调用点不必分支)。 */
|
|
121
180
|
|
|
122
181
|
/**
|
|
@@ -126,27 +185,145 @@ export const ROW_META = {
|
|
|
126
185
|
* directory → packaged `dsh-web-app` patch — lives in base-composition.mjs, so every route can be
|
|
127
186
|
* tested on its own and the failure carries a `code` instead of a bare message.
|
|
128
187
|
*
|
|
188
|
+
* {@link UNION_MODE_ID} is the one mode with no route: it is synthesised here (see
|
|
189
|
+
* {@link unionCompositionText}), so this function stays the single entry point every caller uses and no
|
|
190
|
+
* caller has to know which modes ship a file.
|
|
191
|
+
*
|
|
129
192
|
* @param {string} modeId - one of {@link BASE_MODES}.
|
|
130
193
|
* @returns {string} the entry-list YAML of that base mode.
|
|
131
194
|
* @throws {BaseCompositionUnavailableError} when no route yields text (callers degrade, see index.mjs).
|
|
132
195
|
*/
|
|
133
196
|
export function readBaseComposition(modeId) {
|
|
197
|
+
if (modeId === UNION_MODE_ID) return unionCompositionText()
|
|
134
198
|
return readBaseCompositionText(modeId).text
|
|
135
199
|
}
|
|
136
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The union of the four shipped compositions — the text behind {@link UNION_MODE_ID}.
|
|
203
|
+
*
|
|
204
|
+
* ## Why this exists
|
|
205
|
+
*
|
|
206
|
+
* The row list used to be whatever the *single* shipped mode the user picked happened to contain, and a
|
|
207
|
+
* switch can only rewrite rows the base text already has — so a row declared by one mode and not by the
|
|
208
|
+
* chosen one was not "hidden in the UI", it was **unreachable through the composer**. Measured on
|
|
209
|
+
* `0.2.0-rc.2`: `standard` / `ptc` / `cordis` each declare 33 rows and `minimal` declares 7, but their
|
|
210
|
+
* union is 40. Picking `standard` therefore leaves `tool-presentation` (only in ptc), the whole
|
|
211
|
+
* `persistent-shell` group (only in minimal) and `tool-cordis` (only in cordis) permanently unswitchable.
|
|
212
|
+
*
|
|
213
|
+
* ## How it is built
|
|
214
|
+
*
|
|
215
|
+
* Text surgery, exactly like the rest of this module: the four shipped texts are split at the TOP level and
|
|
216
|
+
* concatenated, skipping a row id that is already present. A group therefore travels **whole** — children,
|
|
217
|
+
* its `isolate` realm, its comments and its `!!js` conditions included. That is the only way a service row
|
|
218
|
+
* can be carried into this mode without inventing a realm for it, and inventing one is not cosmetic:
|
|
219
|
+
* `dsh-agent-presets` rejects a service row that has none, and the platform then drops the entire preset
|
|
220
|
+
* from every picker.
|
|
221
|
+
*
|
|
222
|
+
* Only top-level ids are compared. Children ride along inside their group, so a nested id can never be
|
|
223
|
+
* half-merged; if two modes both declare a group, the group is taken from whichever mode wins below, and
|
|
224
|
+
* its children come with it.
|
|
225
|
+
*
|
|
226
|
+
* ## Which mode wins
|
|
227
|
+
*
|
|
228
|
+
* First writer, in `BASE_MODE_IDS` order (`standard` → `ptc` → `minimal` → `cordis`). The order is not
|
|
229
|
+
* cosmetic: `standard` is the mode this product's own preset is derived from, so a row declared in more
|
|
230
|
+
* than one mode keeps **standard's text and standard's shipped default**. The visible consequence is
|
|
231
|
+
* deliberate — `tool-plugin-manager` ships disabled in `standard` and enabled in `cordis`, and the union
|
|
232
|
+
* keeps it disabled (the user turns it on per assistant if they want it). The alternative rule, "enabled
|
|
233
|
+
* if any mode enables it", would silently arm the model's plugin manager in a mode the user never chose.
|
|
234
|
+
*
|
|
235
|
+
* ## Nothing is cached
|
|
236
|
+
*
|
|
237
|
+
* The four source texts are not stable for the lifetime of a process: the tests swap
|
|
238
|
+
* `DSH_SHIPPED_PRESETS_DIR` between cases, and the host may re-declare a preset on any registry sync. A
|
|
239
|
+
* cache here would be a stale answer that looks authoritative. Four extra reads per call is the honest
|
|
240
|
+
* price, and this is not a hot path.
|
|
241
|
+
*
|
|
242
|
+
* ## One source failing is fatal, not skipped
|
|
243
|
+
*
|
|
244
|
+
* A union that quietly dropped `minimal` when that one route broke would still render, still look complete,
|
|
245
|
+
* and be missing the rows the user was told they could switch — precisely the silent lie this module's
|
|
246
|
+
* typed failure exists to prevent. So a failure propagates as {@link BaseCompositionUnavailableError} with
|
|
247
|
+
* the failing mode named, and the page degrades honestly: the prompt stays editable while the base mode and
|
|
248
|
+
* the switches are marked unavailable.
|
|
249
|
+
*
|
|
250
|
+
* @returns {string} entry-list YAML: the union, behind a banner recording where each part came from.
|
|
251
|
+
* @throws {BaseCompositionUnavailableError} when any shipped mode cannot be read.
|
|
252
|
+
*/
|
|
253
|
+
function unionCompositionText() {
|
|
254
|
+
const seen = new Set()
|
|
255
|
+
const parts = []
|
|
256
|
+
const sources = []
|
|
257
|
+
for (const modeId of BASE_MODE_IDS) {
|
|
258
|
+
let resolved
|
|
259
|
+
try {
|
|
260
|
+
resolved = readBaseCompositionText(modeId)
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// 逐条记下**是哪一位**读不到,再抛类型化失败 —— 页面据此说出具体原因,而不是笼统的"取不到"。
|
|
263
|
+
throw new BaseCompositionUnavailableError(UNION_MODE_ID, [
|
|
264
|
+
...sources.map((each) => each.modeId + ':可用(' + each.source + ')'),
|
|
265
|
+
modeId + ':' + String((error && error.message) || error),
|
|
266
|
+
])
|
|
267
|
+
}
|
|
268
|
+
sources.push({ modeId, source: resolved.source })
|
|
269
|
+
for (const segment of splitSegments(resolved.text, true).segments) {
|
|
270
|
+
if (seen.has(segment.id)) continue
|
|
271
|
+
seen.add(segment.id)
|
|
272
|
+
parts.push(segment.text)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (parts.length === 0) {
|
|
276
|
+
throw new BaseCompositionUnavailableError(UNION_MODE_ID, ['四个出厂模式都没有任何行'])
|
|
277
|
+
}
|
|
278
|
+
const banner = [
|
|
279
|
+
'# 本段是四个出厂组成的并集(' + BASE_MODE_IDS.join(' → ') + ')。',
|
|
280
|
+
'# 同一行在多个模式里都有时,取**先出现的那个模式**的原文与出厂默认(顺序即上面这一行)。',
|
|
281
|
+
'# 分组是整体搬运的,isolate realm 与 !!js 平台条件因此原样保留。',
|
|
282
|
+
...sources.map((each) => '# ' + each.modeId + ' ← ' + each.source),
|
|
283
|
+
'',
|
|
284
|
+
].join('\n')
|
|
285
|
+
// 分段自带尾换行、拼接时不加分隔符(splitSegments 的不变量),所以这里同样用 '' 连接。
|
|
286
|
+
const merged = banner + parts.join('')
|
|
287
|
+
// ★ 同名工具的两套壳不能同时启用(见 EXCLUSIVE_ROW_SETS 的实测):并集把"另一侧"**显式关掉**,
|
|
288
|
+
// 让默认组成是合法的。不关的后果实测过 —— 预设判 broken、从所有选择器里消失,而设置页全绿。
|
|
289
|
+
const off = EXCLUSIVE_ROW_SETS.flatMap((set) => set.unionDefaultOff)
|
|
290
|
+
return off.length === 0 ? merged : disableRowsInPlace(merged, off)
|
|
291
|
+
}
|
|
292
|
+
|
|
137
293
|
/**
|
|
138
294
|
* Whether a segment line declares a row id at the expected indentation.
|
|
139
295
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
296
|
+
* A top-level row is recognised only at column 0. A nested row is recognised at **the indentation
|
|
297
|
+
* that level actually uses** (`indent`), not at a hard-coded depth — see {@link nestedIndent} for
|
|
298
|
+
* why that distinction is load-bearing.
|
|
143
299
|
*/
|
|
144
|
-
function rowIdAt(line, topLevel) {
|
|
145
|
-
const pattern = topLevel ? /^- id: (.+?)\s*$/ :
|
|
300
|
+
function rowIdAt(line, topLevel, indent) {
|
|
301
|
+
const pattern = topLevel ? /^- id: (.+?)\s*$/ : new RegExp(`^ {${String(indent)}}- id: (.+?)\\s*$`)
|
|
146
302
|
const match = pattern.exec(line)
|
|
147
303
|
return match === null ? undefined : match[1].replace(/^['"]|['"]$/g, '')
|
|
148
304
|
}
|
|
149
305
|
|
|
306
|
+
/**
|
|
307
|
+
* The indentation a nested level actually uses, measured from its first row line.
|
|
308
|
+
*
|
|
309
|
+
* The shipped compositions indent nested rows by four spaces, and this module used to hard-code
|
|
310
|
+
* that. **YAML does not require it** — any consistent indentation is legal. Measured
|
|
311
|
+
* (`probes/probe-fuzz.mjs` T): on a file indented by two spaces, every child of a group became
|
|
312
|
+
* **invisible in the page and impossible to toggle**, while the lines were still in the file. The
|
|
313
|
+
* user sees rows they cannot reach; the file keeps rows the page denies exist.
|
|
314
|
+
*
|
|
315
|
+
* Measuring costs one pass over the level's lines and removes the assumption.
|
|
316
|
+
*/
|
|
317
|
+
function nestedIndent(lines) {
|
|
318
|
+
for (const line of lines) {
|
|
319
|
+
const match = /^( +)- id: /.exec(line)
|
|
320
|
+
if (match !== null) return match[1].length
|
|
321
|
+
}
|
|
322
|
+
// No nested row in this level at all: the value is never used (nothing matches), so return the
|
|
323
|
+
// shipped shape rather than inventing one.
|
|
324
|
+
return 4
|
|
325
|
+
}
|
|
326
|
+
|
|
150
327
|
/**
|
|
151
328
|
* Split composition text into a leading preamble plus one segment per row.
|
|
152
329
|
*
|
|
@@ -163,9 +340,10 @@ function rowIdAt(line, topLevel) {
|
|
|
163
340
|
*/
|
|
164
341
|
function splitSegments(text, topLevel) {
|
|
165
342
|
const lines = text.split('\n')
|
|
343
|
+
const indent = topLevel ? 0 : nestedIndent(lines)
|
|
166
344
|
const starts = []
|
|
167
345
|
for (let i = 0; i < lines.length; i += 1) {
|
|
168
|
-
const id = rowIdAt(lines[i], topLevel)
|
|
346
|
+
const id = rowIdAt(lines[i], topLevel, indent)
|
|
169
347
|
if (id !== undefined) starts.push({ index: i, id })
|
|
170
348
|
}
|
|
171
349
|
if (starts.length === 0) return { lead: text, segments: [] }
|
|
@@ -257,24 +435,31 @@ function setDisabled(segmentText, disabled) {
|
|
|
257
435
|
const indent = ownKeyIndent(segmentText)
|
|
258
436
|
const own = disabledLineAt(indent)
|
|
259
437
|
const index = lines.findIndex((line) => own.test(line))
|
|
438
|
+
// ★ 行尾必须跟着输入走。
|
|
439
|
+
//
|
|
440
|
+
// `lines` 来自 `split('\n')`,所以 CRLF 文件里每一行的末尾都还带着 `\r`。这个函数**替换**
|
|
441
|
+
// 或**插入**的行如果只以 `\n` 结尾,就会在文件里留下一个**裸 LF** —— 整个文件从此混行尾,
|
|
442
|
+
// 下一个读它的工具会看到不一致的内容(实测 `probes/probe-fuzz.mjs` R:266 个 CRLF 里混进 1 个裸 LF)。
|
|
443
|
+
// 同样的理由适用于下面两处替换:原行的 `\r` 会随替换一起丢掉。
|
|
444
|
+
const carriage = segmentText.includes('\r\n') ? '\r' : ''
|
|
260
445
|
if (index !== -1) {
|
|
261
446
|
const shipped = lines[index].trim().replace(/^disabled:\s*/, '')
|
|
262
447
|
if (disabled) {
|
|
263
|
-
lines[index] = `${' '.repeat(indent)}disabled: true`
|
|
448
|
+
lines[index] = `${' '.repeat(indent)}disabled: true${carriage}`
|
|
264
449
|
} else if (shipped.startsWith('!!js') && evalDisabledExpression(shipped) !== true) {
|
|
265
450
|
// 显式打开,但出厂那行是平台表达式、且它在本机求值就是"开":这不是覆盖,而是**撤销覆盖** ——
|
|
266
451
|
// 保持出厂表达式原样,这一行就回到"跟随平台"。(原先这里删掉整行,结果是文件既不是出厂原样、
|
|
267
452
|
// 也不是显式覆盖,页面却显示"未拨过" —— 文件与显示同时失真。)
|
|
268
453
|
return segmentText
|
|
269
454
|
} else {
|
|
270
|
-
lines[index] = `${' '.repeat(indent)}disabled: false`
|
|
455
|
+
lines[index] = `${' '.repeat(indent)}disabled: false${carriage}`
|
|
271
456
|
}
|
|
272
457
|
return lines.join('\n')
|
|
273
458
|
}
|
|
274
459
|
if (!disabled) return segmentText
|
|
275
460
|
const nameIndex = lines.findIndex((line) => new RegExp(`^ {${String(indent)}}name:\\s`).test(line))
|
|
276
461
|
if (nameIndex === -1) return segmentText
|
|
277
|
-
lines.splice(nameIndex + 1, 0, `${' '.repeat(indent)}disabled: true`)
|
|
462
|
+
lines.splice(nameIndex + 1, 0, `${' '.repeat(indent)}disabled: true${carriage}`)
|
|
278
463
|
return lines.join('\n')
|
|
279
464
|
}
|
|
280
465
|
|
|
@@ -424,12 +609,26 @@ export function collectRows(text) {
|
|
|
424
609
|
* @param {boolean} topLevel - level selector for {@link splitSegments}.
|
|
425
610
|
* @param {Map<string, boolean>} overrides - explicit per-row states.
|
|
426
611
|
* @param {boolean} nested - whether this call rewrites a group's contents.
|
|
612
|
+
* @param {boolean} replacePersona - whether the persona row may be replaced wholesale.
|
|
613
|
+
* @param {Set<string>} consumed - ids already applied at this call site; see below.
|
|
427
614
|
* @returns {string} the rewritten level.
|
|
428
615
|
*/
|
|
429
|
-
function applyLevel(text, topLevel, overrides, nested, replacePersona = true) {
|
|
616
|
+
function applyLevel(text, topLevel, overrides, nested, replacePersona = true, consumed = new Set()) {
|
|
430
617
|
const { lead, segments } = splitSegments(text, topLevel)
|
|
431
618
|
if (segments.length === 0) return text
|
|
432
619
|
const rendered = segments.map((segment) => {
|
|
620
|
+
// ★ 一个 id 只作用于**第一个**匹配的行。
|
|
621
|
+
//
|
|
622
|
+
// 组成文件里出现重复 id 时(手写、或两次手术叠加),旧实现按 id 查 `overrides`,
|
|
623
|
+
// 于是两个同 id 的行**一起被改**。实测(`probes/probe-fuzz.mjs` S):3 行
|
|
624
|
+
// (alpha, alpha, beta)里关掉 alpha 会关掉**两处** —— 用户拨的是一个开关,
|
|
625
|
+
// 文件里被改的是两处。那不是"多改了一点",是**改了他没指的那一行**。
|
|
626
|
+
//
|
|
627
|
+
// 取第一个匹配(页面渲染的也是第一处),其余同 id 行保持原样。
|
|
628
|
+
// `consumed` 跨层共享:分组内的子行与外层同名时同理,只有第一处被改。
|
|
629
|
+
const state = consumed.has(segment.id) ? undefined : overrides.get(segment.id)
|
|
630
|
+
if (state !== undefined) consumed.add(segment.id)
|
|
631
|
+
|
|
433
632
|
// The persona row is always replaced by this feature's own reader row: the
|
|
434
633
|
// shipped one is a static-string persona whose text cannot be edited, so
|
|
435
634
|
// keeping it would silently disable the editable prompt.
|
|
@@ -437,27 +636,32 @@ function applyLevel(text, topLevel, overrides, nested, replacePersona = true) {
|
|
|
437
636
|
// 但"按本线修复"是**就地**手术,它必须连 persona 段里的注释都原样保留 ——
|
|
438
637
|
// 之前这里无条件替换,导致每次修复都会丢注释 / 或多复制一行身份注释(外部评审实测)。
|
|
439
638
|
if (!nested && segment.id === 'persona' && replacePersona === true) {
|
|
440
|
-
return setDisabled(PERSONA_ROW,
|
|
639
|
+
return setDisabled(PERSONA_ROW, state)
|
|
441
640
|
}
|
|
442
641
|
let body = segment.text
|
|
443
642
|
if (nested) {
|
|
444
643
|
// Nested rows: rewrite only the row's own `disabled`, never recurse.
|
|
445
|
-
return setDisabled(body,
|
|
644
|
+
return setDisabled(body, state)
|
|
446
645
|
}
|
|
447
646
|
const isGroup = /^\s*group:\s*true\s*$/m.test(body)
|
|
448
647
|
if (isGroup) {
|
|
449
648
|
const children = splitSegments(body, false).segments
|
|
450
649
|
if (children.length > 0) {
|
|
451
650
|
// Rewrite the group's nested block, then the group's own `disabled`.
|
|
452
|
-
|
|
651
|
+
//
|
|
652
|
+
// 缩进**用量出来的**,不是写死的 4 —— 同 {@link nestedIndent} 的理由:
|
|
653
|
+
// 写死时,缩进不是 4 格的合法组成文件里 `cut` 恒为 -1,整个分组的子行**一次都不会被重写**,
|
|
654
|
+
// 而函数照样返回一份"看起来改过了"的文本。
|
|
655
|
+
const pad = ' '.repeat(nestedIndent(body.split('\n')))
|
|
656
|
+
const cut = body.indexOf(`\n${pad}- id: ${children[0].id}`)
|
|
453
657
|
if (cut !== -1) {
|
|
454
658
|
const head = body.slice(0, cut + 1)
|
|
455
659
|
const tail = body.slice(cut + 1)
|
|
456
|
-
body = head + applyLevel(tail, false, overrides, true)
|
|
660
|
+
body = head + applyLevel(tail, false, overrides, true, true, consumed)
|
|
457
661
|
}
|
|
458
662
|
}
|
|
459
663
|
}
|
|
460
|
-
return setDisabled(body,
|
|
664
|
+
return setDisabled(body, state)
|
|
461
665
|
})
|
|
462
666
|
// Empty join: each segment already carries the newline that followed it, so
|
|
463
667
|
// `lead + rendered.join('')` reproduces the input byte-for-byte.
|
|
@@ -794,3 +998,77 @@ export function overridesOf(text, modeId) {
|
|
|
794
998
|
export function extraRowIds() {
|
|
795
999
|
return EXTRA_ROWS.map((extra) => extra.id)
|
|
796
1000
|
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 把一组开关调到**每组互斥里只剩一侧启用** —— 保存路径上的「自动互斥」。
|
|
1004
|
+
*
|
|
1005
|
+
* 为什么必须做这一步(实测 2026-10-01,`0.2.0-rc.2`):两套壳注册同名工具,同时启用会让挂载方把整个
|
|
1006
|
+
* 预设判为 **broken**,而 broken 的预设被从所有选择器里静默丢掉 —— 设置页却全绿。用户可以容忍开关
|
|
1007
|
+
* 自己动一下,不能容忍模式凭空消失,所以这里替他做掉那一步,而不是事后报错。
|
|
1008
|
+
*
|
|
1009
|
+
* 判定的是**生效状态**(显式开关优先,否则取该基础模式的出厂态),不是"请求里带了什么":
|
|
1010
|
+
* 用户没碰过的行也该参与互斥判断。
|
|
1011
|
+
*
|
|
1012
|
+
* 保留哪一侧,按这个次序:
|
|
1013
|
+
* 1. 这一次**被显式打开**的那一侧 —— 用户要的就是它(正常情况就落在这里);
|
|
1014
|
+
* 2. 同一次请求里显式打开了多侧(或都没显式开)⇒ 退回避让 `unionDefaultOff` 的那一侧。
|
|
1015
|
+
*
|
|
1016
|
+
* 只把**当前确实启用着**的行写成 `false`。给本来就关着的行写一遍,会在 `overridesOf` 里变成一条
|
|
1017
|
+
* 用户从未做过的"改动",历史和往返都会说谎。
|
|
1018
|
+
*
|
|
1019
|
+
* @param {string} modeId - one of {@link BASE_MODES}.
|
|
1020
|
+
* @param {Map<string, boolean>} overrides - `rowId → enabled`,会被当作不可变输入。
|
|
1021
|
+
* @returns {{overrides: Map<string, boolean>, moved: string[]}} 调整后的开关,以及被自动关掉的行 id。
|
|
1022
|
+
* @throws {BaseCompositionUnavailableError} 取不到出厂组成时(调用方走既有的降级路径)。
|
|
1023
|
+
*/
|
|
1024
|
+
export function applyRowExclusivity(modeId, overrides) {
|
|
1025
|
+
const rows = flattenRowObjects(collectRows(readBaseComposition(modeId)))
|
|
1026
|
+
const effective = (id) => {
|
|
1027
|
+
if (overrides.has(id)) return overrides.get(id) === true
|
|
1028
|
+
const row = rows.get(id)
|
|
1029
|
+
return row === undefined ? false : row.disabled !== true
|
|
1030
|
+
}
|
|
1031
|
+
const next = new Map(overrides)
|
|
1032
|
+
const moved = []
|
|
1033
|
+
for (const set of EXCLUSIVE_ROW_SETS) {
|
|
1034
|
+
const live = set.sides.filter((side) => side.rows.some((id) => effective(id)))
|
|
1035
|
+
if (live.length <= 1) continue
|
|
1036
|
+
const explicit = live.filter((side) => side.rows.some((id) => overrides.get(id) === true))
|
|
1037
|
+
const keep =
|
|
1038
|
+
explicit.length === 1
|
|
1039
|
+
? explicit[0]
|
|
1040
|
+
: live.find((side) => side.rows.some((id) => set.unionDefaultOff.includes(id)) === false) ?? live[0]
|
|
1041
|
+
for (const side of live) {
|
|
1042
|
+
if (side === keep) continue
|
|
1043
|
+
for (const id of side.rows) {
|
|
1044
|
+
if (effective(id)) {
|
|
1045
|
+
next.set(id, false)
|
|
1046
|
+
moved.push(id)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return { overrides: next, moved }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* 一份**已经写在磁盘上**的组成有没有踩着互斥(两条路同时启用)。
|
|
1056
|
+
*
|
|
1057
|
+
* 保存路径已经自动避让了,所以这条只可能由三种情况命中:手工编辑、直接调 HTTP API、或者上一次是用
|
|
1058
|
+
* 本插件之外的什么东西写的。它们的共同后果都是**模式从选择器里静默消失**,所以必须被点名而不是沉默。
|
|
1059
|
+
*
|
|
1060
|
+
* @param {string} text - an installed composition.
|
|
1061
|
+
* @returns {string[]} 同时启用了多侧的互斥组 id。
|
|
1062
|
+
*/
|
|
1063
|
+
export function exclusiveSetsActive(text) {
|
|
1064
|
+
const rows = flattenRowObjects(collectRows(text))
|
|
1065
|
+
return EXCLUSIVE_ROW_SETS.filter((set) => {
|
|
1066
|
+
const on = set.sides.filter((side) =>
|
|
1067
|
+
side.rows.some((id) => {
|
|
1068
|
+
const row = rows.get(id)
|
|
1069
|
+
return row !== undefined && row.disabled !== true
|
|
1070
|
+
}),
|
|
1071
|
+
)
|
|
1072
|
+
return on.length > 1
|
|
1073
|
+
}).map((set) => set.id)
|
|
1074
|
+
}
|
package/index.mjs
CHANGED
|
@@ -52,9 +52,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
52
52
|
import { dirname, join } from 'node:path'
|
|
53
53
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
|
|
54
54
|
import {
|
|
55
|
+
applyRowExclusivity,
|
|
56
|
+
BASE_MODE_IDS,
|
|
55
57
|
BASE_MODES,
|
|
56
58
|
collectRows,
|
|
57
59
|
disableRowsInPlace,
|
|
60
|
+
exclusiveSetsActive,
|
|
58
61
|
isBaseCompositionUnavailable,
|
|
59
62
|
modeOf,
|
|
60
63
|
overridesOf,
|
|
@@ -280,6 +283,10 @@ export function configWarnings(text, prompt, meta) {
|
|
|
280
283
|
if (find('custom-prompt-tool')?.disabled === true) warnings.push('toolOff')
|
|
281
284
|
if (typeof meta?.description !== 'string' || meta.description.trim() === '') warnings.push('noDescription')
|
|
282
285
|
if (typeof meta?.name !== 'string' || meta.name.trim() === '') warnings.push('noName')
|
|
286
|
+
// 两条互斥的路同时开着 ⇒ 平台会把整个模式判 broken 并**从所有选择器里静默丢弃**。
|
|
287
|
+
// 保存路径已经会自动避让(见 applyRowExclusivity),所以走到这里只可能是手工编辑、直接调 HTTP API,
|
|
288
|
+
// 或者上一次是别的工具写的 —— 三种都该被点名("配置了却不生效"是本页明面上的承诺)。
|
|
289
|
+
if (exclusiveSetsActive(text).length > 0) warnings.push('exclusiveRowsActive')
|
|
283
290
|
return warnings
|
|
284
291
|
}
|
|
285
292
|
|
|
@@ -488,6 +495,20 @@ export function saveState(rows, input) {
|
|
|
488
495
|
}
|
|
489
496
|
}
|
|
490
497
|
|
|
498
|
+
// ★ 自动互斥(见 applyRowExclusivity 的实测依据):两套壳注册同名工具,同时启用会让平台把整个模式
|
|
499
|
+
// 判 broken,并**从所有选择器里静默丢掉** —— 设置页却全绿。开关自己动一下用户能接受,模式凭空
|
|
500
|
+
// 消失不能,所以这里替他做掉那一步,并在响应里如实说出被关掉的是哪几行。
|
|
501
|
+
// 取不到出厂组成时会抛:交给下面同一条降级路径,不额外编一套错误。
|
|
502
|
+
let autoOff = []
|
|
503
|
+
try {
|
|
504
|
+
const adjusted = applyRowExclusivity(mode, overrides)
|
|
505
|
+
autoOff = adjusted.moved
|
|
506
|
+
overrides.clear()
|
|
507
|
+
for (const [rowId, enabled] of adjusted.overrides) overrides.set(rowId, enabled)
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (!isBaseCompositionUnavailable(error)) throw error
|
|
510
|
+
}
|
|
511
|
+
|
|
491
512
|
let composition
|
|
492
513
|
try {
|
|
493
514
|
composition = renderComposition(mode, overrides, { modeName: effectiveName, assistantId: id })
|
|
@@ -551,13 +572,27 @@ export function saveState(rows, input) {
|
|
|
551
572
|
/* 审计记录失败不影响保存本身 */
|
|
552
573
|
}
|
|
553
574
|
|
|
575
|
+
const displayName = effectiveName === '' ? id : effectiveName
|
|
576
|
+
if (autoOff.length > 0) {
|
|
577
|
+
return {
|
|
578
|
+
ok: true,
|
|
579
|
+
id,
|
|
580
|
+
mode,
|
|
581
|
+
code: 'savedWithExclusiveRows',
|
|
582
|
+
// `rows` 用行 id:页面自己按 `row.<id>.label` 渲染,英文界面不会因为这条掉回中文。
|
|
583
|
+
params: { name: displayName, mode, rows: autoOff.join(', ') },
|
|
584
|
+
note:
|
|
585
|
+
'已保存(' + displayName + ',基础模式 ' + mode + ')。这两套壳注册同名工具、不能同时启用 —— ' +
|
|
586
|
+
'已自动关掉:' + autoOff.join('、') + '。新建会话即生效,当前会话保持原配置。',
|
|
587
|
+
}
|
|
588
|
+
}
|
|
554
589
|
return {
|
|
555
590
|
ok: true,
|
|
556
591
|
id,
|
|
557
592
|
mode,
|
|
558
593
|
code: 'saved',
|
|
559
|
-
params: { name:
|
|
560
|
-
note: '已保存(' +
|
|
594
|
+
params: { name: displayName, mode },
|
|
595
|
+
note: '已保存(' + displayName + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
|
|
561
596
|
}
|
|
562
597
|
}
|
|
563
598
|
|
|
@@ -627,10 +662,28 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
627
662
|
}
|
|
628
663
|
const sourceMode = modeOf(text)
|
|
629
664
|
const sourcePrompt = readPrompt(fromDir)
|
|
665
|
+
// 与上面组成文件**同一条纪律**:源提示词读不出来时返回类型化错误,不要静默用空串。
|
|
666
|
+
//
|
|
667
|
+
// 实测(`probes/probe-io.mjs` N):旧代码写 `sourcePrompt.ok === true ? sourcePrompt.text : ''`,
|
|
668
|
+
// 于是一次复制会返回 `ok / duplicated`,而新助手的 `prompt.md` 长度是 **0** ——
|
|
669
|
+
// 用户以为自己复制出了一个助手,实际拿到的是**空的系统提示词**,而源里那份提示词还好端端躺着。
|
|
670
|
+
// 保存路径有 `promptEmpty` 这道闸,复制路径没有;这个不对称就是缺陷本身。
|
|
671
|
+
//
|
|
672
|
+
// `readPrompt` 的失败形状已经是带 code 的类型化错误(页面按词典渲染),所以直接透传。
|
|
673
|
+
if (sourcePrompt.ok !== true) return sourcePrompt
|
|
674
|
+
// 复制走的是"照抄源助手的开关再重渲染",所以源里若踩着互斥,抄过来还是踩 —— 与保存路径同一条
|
|
675
|
+
// 自动避让(见 applyRowExclusivity)。不这么做的话,从这里能造出一个**必然 broken** 的新助手,
|
|
676
|
+
// 而 broken 的模式会被静默丢掉。取不到出厂组成时保持原样:渲染那一步会走既有的降级/自检。
|
|
677
|
+
let sourceOverrides = overridesOf(text, sourceMode)
|
|
678
|
+
try {
|
|
679
|
+
sourceOverrides = Object.fromEntries(applyRowExclusivity(sourceMode, new Map(Object.entries(sourceOverrides))).overrides)
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (!isBaseCompositionUnavailable(error)) throw error
|
|
682
|
+
}
|
|
630
683
|
source = {
|
|
631
684
|
mode: sourceMode,
|
|
632
|
-
overrides:
|
|
633
|
-
prompt: sourcePrompt.
|
|
685
|
+
overrides: sourceOverrides,
|
|
686
|
+
prompt: sourcePrompt.text,
|
|
634
687
|
description: readPresetMeta(fromDir).description,
|
|
635
688
|
}
|
|
636
689
|
}
|
|
@@ -984,7 +1037,9 @@ export function apply(ctx) {
|
|
|
984
1037
|
// 降级路径。两种情况都不阻塞下面的注册表同步 —— 模式可用优先于设置页完整。
|
|
985
1038
|
if (declarative !== null) {
|
|
986
1039
|
try {
|
|
987
|
-
|
|
1040
|
+
// 只问**出厂那四位**:并集模式(BASE_MODES 的第五项)在宿主那里没有文档,
|
|
1041
|
+
// 拿它去 readDocument 只会每次都换来一条"取不到"的错误日志,把真正的故障淹没掉。
|
|
1042
|
+
const bases = await declarative.fetchBaseCompositions(BASE_MODE_IDS)
|
|
988
1043
|
setBaseCompositions(bases.fetched)
|
|
989
1044
|
if (bases.fetched.size > 0) {
|
|
990
1045
|
console.log(
|
package/journal.mjs
CHANGED
|
@@ -65,16 +65,16 @@ export function readEntries(directory) {
|
|
|
65
65
|
// 模块自己的承诺是"坏一行不该赔上全部版本",那么坏一个文件更不该赔上这个助手。
|
|
66
66
|
return []
|
|
67
67
|
}
|
|
68
|
-
const
|
|
68
|
+
const parsedLines = []
|
|
69
69
|
for (const line of raw.split('\n')) {
|
|
70
70
|
if (line.trim() === '') continue
|
|
71
71
|
try {
|
|
72
72
|
const parsed = JSON.parse(line)
|
|
73
73
|
if (parsed === null || typeof parsed !== 'object') continue
|
|
74
74
|
if (typeof parsed.text !== 'string') continue
|
|
75
|
-
|
|
76
|
-
// 老日志没有 n
|
|
77
|
-
n: Number.isInteger(parsed.n) && parsed.n > 0 ? parsed.n :
|
|
75
|
+
parsedLines.push({
|
|
76
|
+
// 老日志没有 n;显式的 n 先原样带着,是否可用留到下一遍决定。
|
|
77
|
+
n: Number.isInteger(parsed.n) && parsed.n > 0 ? parsed.n : null,
|
|
78
78
|
at: typeof parsed.at === 'string' ? parsed.at : '',
|
|
79
79
|
by: typeof parsed.by === 'string' ? parsed.by : HISTORY_SOURCE.external,
|
|
80
80
|
text: parsed.text,
|
|
@@ -83,6 +83,30 @@ export function readEntries(directory) {
|
|
|
83
83
|
continue
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
// 第二遍:把序号分配成**互不重复**的。
|
|
88
|
+
//
|
|
89
|
+
// 旧实现是单遍的 `entries.length + 1`,只能看见"已经读到几条",看不见**后面**才出现的显式号码,
|
|
90
|
+
// 于是会撞车。实测(`probes/probe-io.mjs` I)日志 `[n=5, 无, n=2]` 读出 `[5, 2, 2]` ——
|
|
91
|
+
// 两条记录共用一个 n,而 `readVersion(n)` 只返回第一个匹配,
|
|
92
|
+
// 于是用户点"回到某一版"拿到的是隔壁那条的内容,且界面上两行还都显示同一个版本号。
|
|
93
|
+
//
|
|
94
|
+
// 规则:显式且**尚未被占用**的号码原样保留;其余(缺失的,以及显式但撞车的)在一张
|
|
95
|
+
// 已占用表里找最小的空闲号。唯一性优先于"按行序" —— 序号错乱只是显示不好看,
|
|
96
|
+
// 序号重复会让用户取到**别人的内容**。
|
|
97
|
+
const taken = new Set()
|
|
98
|
+
let next = 1
|
|
99
|
+
const entries = []
|
|
100
|
+
for (const entry of parsedLines) {
|
|
101
|
+
if (entry.n !== null && taken.has(entry.n) === false) {
|
|
102
|
+
taken.add(entry.n)
|
|
103
|
+
entries.push({ ...entry, n: entry.n })
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
while (taken.has(next)) next += 1
|
|
107
|
+
taken.add(next)
|
|
108
|
+
entries.push({ ...entry, n: next })
|
|
109
|
+
}
|
|
86
110
|
return entries
|
|
87
111
|
}
|
|
88
112
|
|
|
@@ -104,7 +128,10 @@ export function recordPrompt(directory, text, by = HISTORY_SOURCE.settings) {
|
|
|
104
128
|
if (newest !== undefined && newest.text === text) return { recorded: false, at: newest.at, n: newest.n }
|
|
105
129
|
|
|
106
130
|
const at = new Date().toISOString()
|
|
107
|
-
|
|
131
|
+
// 新序号取**已有序号的最大值 + 1**,不是"最后一条 + 1":老日志的兜底序号可能让末条不是最大的那个
|
|
132
|
+
// (实测 [5, 2, 2] 的末条是 2,加一得到 3 —— 而 3 未必空闲)。取 max 并配合 readEntries 的唯一性
|
|
133
|
+
// 分配,保证新版本号不会与任何已有记录撞车。
|
|
134
|
+
const n = entries.length === 0 ? 1 : Math.max(...entries.map((entry) => entry.n)) + 1
|
|
108
135
|
// `bytes` 是给人看的体积,所以按 **UTF-8 字节**计(原先用 text.length = UTF-16 码元,
|
|
109
136
|
// CJK 内容下显示值只有真实字节的约 1/3,而界面标注是 "B")。
|
|
110
137
|
const lines = entries.slice(-(HISTORY_MAX - 1)).map((entry) =>
|
package/locales.mjs
CHANGED
|
@@ -84,6 +84,7 @@ export const zh = {
|
|
|
84
84
|
'meta.version': '插件版本',
|
|
85
85
|
'meta.versionHint': '安装时不钉版本号会受 pnpm 发布冷却期影响(默认 24 小时),可能装到较旧的版本。要换版本请按 README 的钉版本命令重装,然后重启 DSH。',
|
|
86
86
|
'api.saved': '已保存({name},基础模式 {mode})。新建会话即生效,当前会话保持原配置。',
|
|
87
|
+
'api.savedWithExclusiveRows': '已保存({name},基础模式 {mode})。两套壳注册同名工具、不能同时启用 —— 已自动关掉:{rows}。新建会话即生效,当前会话保持原配置。',
|
|
87
88
|
'api.created': '已创建「{name}」。现在可以为它写系统提示词。',
|
|
88
89
|
'api.duplicated': '已复制自「{from}」。两份从此各改各的。',
|
|
89
90
|
'api.deleted': '已删除「{name}」。正在使用它的会话不受影响;新建会话时它不再出现。',
|
|
@@ -116,6 +117,7 @@ export const zh = {
|
|
|
116
117
|
'api.baseCompositionUnavailable': '本机取不到基础模式「{mode}」的出厂组成,无法改动插件开关或基础模式(系统提示词可以单独保存)。',
|
|
117
118
|
'warn.approvalGateMissing': '审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。',
|
|
118
119
|
'warn.unresolvableRows': '有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。',
|
|
120
|
+
'warn.exclusiveRowsActive': '两条互斥的壳同时开着(它们注册同名工具):平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。把其中一条关掉即可 —— 在设置页保存一次也会自动避让。',
|
|
119
121
|
'warn.baseCompositionUnavailable': '取不到本机这条 DSH 线的出厂组成:基础模式与插件开关暂时不能改(系统提示词不受影响,照常可保存)。已尝试的途径写在宿主日志里。',
|
|
120
122
|
'warn.approvalGateMissing.label': '审批闸门未启用',
|
|
121
123
|
'warn.approvalGateMissing.hint': '这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。',
|
|
@@ -244,6 +246,8 @@ export const zh = {
|
|
|
244
246
|
'base.minimal.note': '只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理。系统提示词不是极简那一套(persona 行被本模式替换)',
|
|
245
247
|
'base.cordis.label': 'Cordis 模式',
|
|
246
248
|
'base.cordis.note': '标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness',
|
|
249
|
+
'base.all.label': '自定义模式',
|
|
250
|
+
'base.all.note': '不继承任何单一原生模式:以标准模式的全套行为底,补上只有 PTC / 极简 / 创造模式才有的行,全部都能逐行开关',
|
|
247
251
|
}
|
|
248
252
|
|
|
249
253
|
/** English dictionary; the parity test keeps its key set equal to {@link zh}. */
|
|
@@ -316,6 +320,7 @@ export const en = {
|
|
|
316
320
|
'meta.version': 'Plugin version',
|
|
317
321
|
'meta.versionHint': 'Installing without a pinned version is subject to pnpm’s release cooldown (24 h by default) and can land on an older release. To change version, reinstall with the pinned command from the README and restart DSH.',
|
|
318
322
|
'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
|
|
323
|
+
'api.savedWithExclusiveRows': 'Saved ({name}, base mode {mode}). The two shell implementations register the same tool names and cannot both be on — turned off automatically: {rows}. A new session picks it up; the current one keeps its configuration.',
|
|
319
324
|
'api.created': 'Created {name}. You can write its system prompt now.',
|
|
320
325
|
'api.duplicated': 'Copied from {from}. The two are independent from now on.',
|
|
321
326
|
'api.deleted': 'Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.',
|
|
@@ -351,6 +356,7 @@ export const en = {
|
|
|
351
356
|
'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
|
|
352
357
|
'warn.approvalGateMissing': 'The approval gate is off: this DSH build has no tools/pre-execute event, so in-session prompt rewrites do not ask for approval. See the details.',
|
|
353
358
|
'warn.unresolvableRows': 'Some rows cannot be resolved on this DSH line: the platform marks the whole mode broken and silently drops it from the new-session picker. Click Fix for this line — it only turns those rows off and leaves your other choices alone.',
|
|
359
|
+
'warn.exclusiveRowsActive': 'Both shells are on, and they register the same tool names: the platform marks the whole mode broken and silently drops it from the new-session picker. Turn one of them off — saving from the settings page also resolves it automatically.',
|
|
354
360
|
'warn.approvalGateMissing.label': 'Approval gate is off',
|
|
355
361
|
'warn.approvalGateMissing.hint': 'This DSH build has no tools/pre-execute event, so in-session prompt rewrites do NOT ask for approval. The settings page is unaffected; upgrade DSH or turn the custom_prompt tool row off to restore the gate.',
|
|
356
362
|
'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
|
|
@@ -477,4 +483,6 @@ export const en = {
|
|
|
477
483
|
'base.minimal.note': 'Shell and terminal only, 7 rows; no files, search, skills or subagents. The system prompt is not minimal\'s either (this mode replaces the persona row)',
|
|
478
484
|
'base.cordis.label': 'Cordis',
|
|
479
485
|
'base.cordis.note': 'Standard plus the Cordis toolset, letting the agent modify its own harness',
|
|
486
|
+
'base.all.label': 'Custom',
|
|
487
|
+
'base.all.note': "Inherits no single shipped mode: standard's full row list plus the rows only PTC / Minimal / Cordis declare, every one of them switchable",
|
|
480
488
|
}
|
package/meta.mjs
CHANGED
|
@@ -34,25 +34,92 @@ export function presetMetaPath(directory = PRESET_DIR) {
|
|
|
34
34
|
/** The legacy single-preset metadata path, kept for callers that address it directly. */
|
|
35
35
|
export const PRESET_META_PATH = presetMetaPath()
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
/**
|
|
38
|
+
* YAML double-quoted scalars use C-style escapes — a superset of JSON's, so `JSON.parse` is not enough
|
|
39
|
+
* (`\e`, `\N`, `\_`, `\L`, `\P`, `\xNN`, `\UNNNNNNNN` have no JSON equivalent).
|
|
40
|
+
*
|
|
41
|
+
* Measured (`probes/probe-io.mjs` G): the previous implementation was a plain backslash-stripper, so
|
|
42
|
+
* `"tab\there"` read back as `tabthere` — the name lost a character every round trip. Four control
|
|
43
|
+
* characters were checked and all four were wrong, which means a name the page shows and the name the
|
|
44
|
+
* platform stores drift apart permanently.
|
|
45
|
+
*/
|
|
46
|
+
const DOUBLE_QUOTED_ESCAPES = {
|
|
47
|
+
0: '\0',
|
|
48
|
+
a: '\x07',
|
|
49
|
+
b: '\b',
|
|
50
|
+
t: '\t',
|
|
51
|
+
n: '\n',
|
|
52
|
+
v: '\v',
|
|
53
|
+
f: '\f',
|
|
54
|
+
r: '\r',
|
|
55
|
+
e: '\x1b',
|
|
56
|
+
' ': ' ',
|
|
57
|
+
'"': '"',
|
|
58
|
+
'/': '/',
|
|
59
|
+
'\\': '\\',
|
|
60
|
+
N: '\u0085',
|
|
61
|
+
_: '\u00a0',
|
|
62
|
+
L: '\u2028',
|
|
63
|
+
P: '\u2029',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Decode the inside of a YAML double-quoted scalar. */
|
|
67
|
+
function decodeDoubleQuoted(inner) {
|
|
44
68
|
let out = ''
|
|
45
69
|
for (let i = 0; i < inner.length; i += 1) {
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
const ch = inner[i]
|
|
71
|
+
if (ch !== '\\') {
|
|
72
|
+
out += ch
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const next = inner[i + 1]
|
|
76
|
+
if (next === undefined) {
|
|
77
|
+
out += '\\'
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
if (next === 'x' || next === 'u' || next === 'U') {
|
|
81
|
+
const width = next === 'x' ? 2 : next === 'u' ? 4 : 8
|
|
82
|
+
const digits = inner.slice(i + 2, i + 2 + width)
|
|
83
|
+
const code = Number.parseInt(digits, 16)
|
|
84
|
+
if (digits.length === width && Number.isNaN(code) === false) {
|
|
85
|
+
out += String.fromCodePoint(code)
|
|
86
|
+
i += 1 + width
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
out += next
|
|
48
90
|
i += 1
|
|
49
91
|
continue
|
|
50
92
|
}
|
|
51
|
-
|
|
93
|
+
if (Object.prototype.hasOwnProperty.call(DOUBLE_QUOTED_ESCAPES, next)) {
|
|
94
|
+
out += DOUBLE_QUOTED_ESCAPES[next]
|
|
95
|
+
i += 1
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
// An unknown escape is a YAML error; taking the character itself keeps the round trip lossless
|
|
99
|
+
// rather than silently dropping the backslash and the character after it.
|
|
100
|
+
out += next
|
|
101
|
+
i += 1
|
|
52
102
|
}
|
|
53
103
|
return out
|
|
54
104
|
}
|
|
55
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Strip one layer of matching quotes and undo the escaping that the quoting style actually implies.
|
|
108
|
+
*
|
|
109
|
+
* The two styles are **not** interchangeable, which the previous version did not model:
|
|
110
|
+
* - **double quotes** carry C-style escapes (see above);
|
|
111
|
+
* - **single quotes** carry exactly one escape, `''` → `'`; a backslash inside them is a literal.
|
|
112
|
+
*/
|
|
113
|
+
function unquote(value) {
|
|
114
|
+
const quoted =
|
|
115
|
+
value.length >= 2 &&
|
|
116
|
+
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
117
|
+
if (!quoted) return value
|
|
118
|
+
const inner = value.slice(1, -1)
|
|
119
|
+
if (value.startsWith('"')) return decodeDoubleQuoted(inner)
|
|
120
|
+
return inner.replace(/''/g, "'")
|
|
121
|
+
}
|
|
122
|
+
|
|
56
123
|
/**
|
|
57
124
|
* Read one preset directory's display name and description.
|
|
58
125
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), pick its base mode, switch plugin rows one by one, and keep several assistants side by side — coding, chat or roleplay (RP) personas (multi-mode / multi-agent).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|