dsh-custom-mode 1.10.0 → 1.11.1
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 +49 -4
- package/README.zh.md +40 -4
- package/assistants.mjs +23 -3
- package/atomic.mjs +93 -7
- package/base-composition.mjs +25 -1
- package/client.js +38 -9
- package/composition.mjs +296 -18
- package/index.mjs +128 -10
- package/journal.mjs +32 -5
- package/locales.mjs +14 -4
- package/meta.mjs +77 -10
- package/package.json +1 -1
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,9 +283,39 @@ 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
|
|
|
293
|
+
/**
|
|
294
|
+
* 从宿主给的那条 `broken` 里抠出**行 id**。
|
|
295
|
+
*
|
|
296
|
+
* 平台把"这个预设为什么起不来"写成一行行 `<rowId> (<module>): <reason>`,例如
|
|
297
|
+
*
|
|
298
|
+
* workflow-worker-thread (@deepseek-ai/dsh-workflow-worker-thread): never started
|
|
299
|
+
* tool-workflow (@deepseek-ai/dsh-tool-workflow): waiting for workflowEngine
|
|
300
|
+
*
|
|
301
|
+
* 这是**唯一**能知道"是哪一行害的"的来源:`unresolvableRows()` 只答"模块装没装",答不了
|
|
302
|
+
* "上游服务等不到"。实测(2026-10-01,官方桌面端 0.2.0-rc.2):关掉那个装不上的引擎行之后
|
|
303
|
+
* `unresolvable` 变空、页面**全绿**,而预设**仍然 broken**(依赖引擎的两个工具在等它)——
|
|
304
|
+
* 于是用户再也走不到可用状态,模式一直不出现。见 docs/MEASUREMENTS.md §34。
|
|
305
|
+
*
|
|
306
|
+
* @param {unknown} message - the roster row's `broken` string.
|
|
307
|
+
* @returns {string[]} row ids, de-duplicated, in the order they appear.
|
|
308
|
+
*/
|
|
309
|
+
export function brokenRowIds(message) {
|
|
310
|
+
if (typeof message !== 'string') return []
|
|
311
|
+
const ids = []
|
|
312
|
+
for (const line of message.split('\n')) {
|
|
313
|
+
const match = /^\s*([A-Za-z0-9][A-Za-z0-9_-]*)\s+\(/.exec(line)
|
|
314
|
+
if (match !== null) ids.push(match[1])
|
|
315
|
+
}
|
|
316
|
+
return [...new Set(ids)]
|
|
317
|
+
}
|
|
318
|
+
|
|
286
319
|
export function readState(rows, id, options = {}) {
|
|
287
320
|
const directory = assistantDir(rows, id)
|
|
288
321
|
if (directory === undefined) return unknownAssistant(id)
|
|
@@ -319,6 +352,15 @@ export function readState(rows, id, options = {}) {
|
|
|
319
352
|
if (!isBaseCompositionUnavailable(error)) throw error
|
|
320
353
|
baseUnavailable = { code: error.code, params: { mode, detail: describe(error) } }
|
|
321
354
|
}
|
|
355
|
+
// 宿主给的那条 broken(若这条线上有)。**必须在 return 之前算**:warnings 要用它。
|
|
356
|
+
const selfRow = Array.isArray(rows) ? rows.find((item) => item !== null && typeof item === 'object' && item.id === id) : undefined
|
|
357
|
+
const hostBroken = typeof selfRow?.broken === 'string' ? selfRow.broken.trim() : ''
|
|
358
|
+
const unresolvable = unresolvableRows(text)
|
|
359
|
+
const knownUnresolvable = new Set(unresolvable.map((row) => row.id))
|
|
360
|
+
const hostBrokenIds = brokenRowIds(hostBroken)
|
|
361
|
+
// 宿主说坏了,而我们**解释不了**(不是"模块装不上")—— 那就要单独点名,否则页面全绿、
|
|
362
|
+
// 模式却在选择器里不出现。抠不出行 id 时(宿主换了措辞)也照样点名:宁可笼统,不可沉默。
|
|
363
|
+
const unexplainedBroken = hostBroken !== '' && (hostBrokenIds.length === 0 || hostBrokenIds.some((rowId) => knownUnresolvable.has(rowId) === false))
|
|
322
364
|
return {
|
|
323
365
|
ok: true,
|
|
324
366
|
id,
|
|
@@ -342,7 +384,10 @@ export function readState(rows, id, options = {}) {
|
|
|
342
384
|
history: listHistory(directory),
|
|
343
385
|
// 本插件版本 + 本机装的那条 dsh 线上无法解析的行(页面据此显示版本与"按本线修复")。
|
|
344
386
|
version: PLUGIN_VERSION,
|
|
345
|
-
unresolvable
|
|
387
|
+
unresolvable,
|
|
388
|
+
// 宿主自己给的「这个预设为什么起不来」原文。它比 `unresolvable` 宽:除了"模块装不上",
|
|
389
|
+
// 还包括"上游服务等不到"这类**装配期**失败。页面据此点名,而不是在坏着的时候显示全绿。
|
|
390
|
+
broken: hostBroken === '' ? null : hostBroken,
|
|
346
391
|
// 「配置了却不生效」的告警码(文案在页面侧按语言渲染)。
|
|
347
392
|
warnings: [
|
|
348
393
|
...configWarnings(text, prompt.ok === true ? prompt.text : '', meta),
|
|
@@ -350,7 +395,11 @@ export function readState(rows, id, options = {}) {
|
|
|
350
395
|
// 诚实地把它变成页面上的告警,而不是只留在 console.error 里。
|
|
351
396
|
...(existsSync(join(directory, 'approval-gate-missing')) ? ['approvalGateMissing'] : []),
|
|
352
397
|
// 本线无法解析的启用行 = 平台会把整个预设判为 broken 并从选择器里丢掉(曾经的 P0)。
|
|
353
|
-
...(
|
|
398
|
+
...(unresolvable.length > 0 ? ['unresolvableRows'] : []),
|
|
399
|
+
// ★ 宿主报了 broken,而**我们自己的检查解释不了**(不是"模块装不上",而是"服务等不到"之类)。
|
|
400
|
+
// 不点名的话,页面上 `unresolvable` 空、告警空 —— 一切看起来正常,而模式**根本没出现在
|
|
401
|
+
// 选择器里**。这正是本仓记录过两次的那种"静默失败",只是换了个触发面。
|
|
402
|
+
...(unexplainedBroken ? ['presetBroken'] : []),
|
|
354
403
|
// 出厂组成取不到:提示词能改,基础模式与插件开关不能。
|
|
355
404
|
...(baseUnavailable === null ? [] : ['baseCompositionUnavailable']),
|
|
356
405
|
],
|
|
@@ -488,6 +537,20 @@ export function saveState(rows, input) {
|
|
|
488
537
|
}
|
|
489
538
|
}
|
|
490
539
|
|
|
540
|
+
// ★ 自动互斥(见 applyRowExclusivity 的实测依据):两套壳注册同名工具,同时启用会让平台把整个模式
|
|
541
|
+
// 判 broken,并**从所有选择器里静默丢掉** —— 设置页却全绿。开关自己动一下用户能接受,模式凭空
|
|
542
|
+
// 消失不能,所以这里替他做掉那一步,并在响应里如实说出被关掉的是哪几行。
|
|
543
|
+
// 取不到出厂组成时会抛:交给下面同一条降级路径,不额外编一套错误。
|
|
544
|
+
let autoOff = []
|
|
545
|
+
try {
|
|
546
|
+
const adjusted = applyRowExclusivity(mode, overrides)
|
|
547
|
+
autoOff = adjusted.moved
|
|
548
|
+
overrides.clear()
|
|
549
|
+
for (const [rowId, enabled] of adjusted.overrides) overrides.set(rowId, enabled)
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (!isBaseCompositionUnavailable(error)) throw error
|
|
552
|
+
}
|
|
553
|
+
|
|
491
554
|
let composition
|
|
492
555
|
try {
|
|
493
556
|
composition = renderComposition(mode, overrides, { modeName: effectiveName, assistantId: id })
|
|
@@ -551,13 +614,27 @@ export function saveState(rows, input) {
|
|
|
551
614
|
/* 审计记录失败不影响保存本身 */
|
|
552
615
|
}
|
|
553
616
|
|
|
617
|
+
const displayName = effectiveName === '' ? id : effectiveName
|
|
618
|
+
if (autoOff.length > 0) {
|
|
619
|
+
return {
|
|
620
|
+
ok: true,
|
|
621
|
+
id,
|
|
622
|
+
mode,
|
|
623
|
+
code: 'savedWithExclusiveRows',
|
|
624
|
+
// `rows` 用行 id:页面自己按 `row.<id>.label` 渲染,英文界面不会因为这条掉回中文。
|
|
625
|
+
params: { name: displayName, mode, rows: autoOff.join(', ') },
|
|
626
|
+
note:
|
|
627
|
+
'已保存(' + displayName + ',基础模式 ' + mode + ')。这两套壳注册同名工具、不能同时启用 —— ' +
|
|
628
|
+
'已自动关掉:' + autoOff.join('、') + '。新建会话即生效,当前会话保持原配置。',
|
|
629
|
+
}
|
|
630
|
+
}
|
|
554
631
|
return {
|
|
555
632
|
ok: true,
|
|
556
633
|
id,
|
|
557
634
|
mode,
|
|
558
635
|
code: 'saved',
|
|
559
|
-
params: { name:
|
|
560
|
-
note: '已保存(' +
|
|
636
|
+
params: { name: displayName, mode },
|
|
637
|
+
note: '已保存(' + displayName + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
|
|
561
638
|
}
|
|
562
639
|
}
|
|
563
640
|
|
|
@@ -627,10 +704,28 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
627
704
|
}
|
|
628
705
|
const sourceMode = modeOf(text)
|
|
629
706
|
const sourcePrompt = readPrompt(fromDir)
|
|
707
|
+
// 与上面组成文件**同一条纪律**:源提示词读不出来时返回类型化错误,不要静默用空串。
|
|
708
|
+
//
|
|
709
|
+
// 实测(`probes/probe-io.mjs` N):旧代码写 `sourcePrompt.ok === true ? sourcePrompt.text : ''`,
|
|
710
|
+
// 于是一次复制会返回 `ok / duplicated`,而新助手的 `prompt.md` 长度是 **0** ——
|
|
711
|
+
// 用户以为自己复制出了一个助手,实际拿到的是**空的系统提示词**,而源里那份提示词还好端端躺着。
|
|
712
|
+
// 保存路径有 `promptEmpty` 这道闸,复制路径没有;这个不对称就是缺陷本身。
|
|
713
|
+
//
|
|
714
|
+
// `readPrompt` 的失败形状已经是带 code 的类型化错误(页面按词典渲染),所以直接透传。
|
|
715
|
+
if (sourcePrompt.ok !== true) return sourcePrompt
|
|
716
|
+
// 复制走的是"照抄源助手的开关再重渲染",所以源里若踩着互斥,抄过来还是踩 —— 与保存路径同一条
|
|
717
|
+
// 自动避让(见 applyRowExclusivity)。不这么做的话,从这里能造出一个**必然 broken** 的新助手,
|
|
718
|
+
// 而 broken 的模式会被静默丢掉。取不到出厂组成时保持原样:渲染那一步会走既有的降级/自检。
|
|
719
|
+
let sourceOverrides = overridesOf(text, sourceMode)
|
|
720
|
+
try {
|
|
721
|
+
sourceOverrides = Object.fromEntries(applyRowExclusivity(sourceMode, new Map(Object.entries(sourceOverrides))).overrides)
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (!isBaseCompositionUnavailable(error)) throw error
|
|
724
|
+
}
|
|
630
725
|
source = {
|
|
631
726
|
mode: sourceMode,
|
|
632
|
-
overrides:
|
|
633
|
-
prompt: sourcePrompt.
|
|
727
|
+
overrides: sourceOverrides,
|
|
728
|
+
prompt: sourcePrompt.text,
|
|
634
729
|
description: readPresetMeta(fromDir).description,
|
|
635
730
|
}
|
|
636
731
|
}
|
|
@@ -709,9 +804,30 @@ export function repairComposition(rows, input) {
|
|
|
709
804
|
const file = compositionFile(directory)
|
|
710
805
|
if (!existsSync(file)) return { ok: false, code: 'compositionMissing', params: { path: file }, error: '找不到组成文件:' + file }
|
|
711
806
|
const text = readFileSync(file, 'utf8')
|
|
712
|
-
|
|
807
|
+
// 两类"这一行在本机起不来",必须**都**关掉:
|
|
808
|
+
// ① 模块装不上 —— 我们自己在 node_modules 里查得出来(`unresolvableRows`);
|
|
809
|
+
// ② 装配期失败("服务等不到"之类)—— 只有宿主知道,写在它给的 `broken` 里。
|
|
810
|
+
// 只修 ① 会留下一个**页面全绿、预设却仍然 broken**的状态(桌面端实测,见 brokenRowIds 的注释):
|
|
811
|
+
// `unresolvable` 变空 ⇒ 告警消失 ⇒ 用户以为修好了,而模式始终没出现在选择器里。
|
|
812
|
+
const selfRow = Array.isArray(rows) ? rows.find((item) => item !== null && typeof item === 'object' && item.id === id) : undefined
|
|
813
|
+
// 只关**这份文本里真的有**的行:宿主报的可能是别处的行,而 `disableRowsInPlace` 对不存在的 id
|
|
814
|
+
// 是无操作 —— 计数会因此说谎(说关了 3 个其实只关了 1 个)。
|
|
815
|
+
const present = new Set()
|
|
816
|
+
const collectIds = (list) => {
|
|
817
|
+
for (const row of list) {
|
|
818
|
+
present.add(row.id)
|
|
819
|
+
if (Array.isArray(row.children)) collectIds(row.children)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
collectIds(collectRows(text))
|
|
824
|
+
} catch {
|
|
825
|
+
/* 文本解析不了就只按 unresolvableRows 走(它自己会兜底) */
|
|
826
|
+
}
|
|
827
|
+
const wanted = [...new Set([...unresolvableRows(text).map((row) => row.id), ...brokenRowIds(selfRow?.broken)])]
|
|
828
|
+
const bad = wanted.map((rowId) => ({ id: rowId })).filter((row) => present.has(row.id))
|
|
713
829
|
if (bad.length === 0) {
|
|
714
|
-
return { ok: true, id, code: 'repairNotNeeded', note: '
|
|
830
|
+
return { ok: true, id, code: 'repairNotNeeded', note: '这个助手在本机没有起不来的行,无需修复。' }
|
|
715
831
|
}
|
|
716
832
|
// **就地**关闭那几行,不重渲染:重渲染会顺手丢掉 base 之外的自有行,而用户的数据不该被这样动。
|
|
717
833
|
const rendered = disableRowsInPlace(text, bad.map((row) => row.id))
|
|
@@ -729,7 +845,7 @@ export function repairComposition(rows, input) {
|
|
|
729
845
|
// 草稿,否则下一次保存会按草稿重渲染,把刚才的修复原样撤销(issue #8 实测:
|
|
730
846
|
// 修复写进了磁盘,但草稿里那几行仍是启用的,于是保存后 ghost 行又回来了)。
|
|
731
847
|
params: { count: bad.length, ids, repairedIds: bad.map((row) => row.id) },
|
|
732
|
-
note: '已按本机这条 dsh 线关闭 ' + String(bad.length) + '
|
|
848
|
+
note: '已按本机这条 dsh 线关闭 ' + String(bad.length) + ' 个起不来的行(' + ids + ')。现在这个模式能重新出现在选择器里。',
|
|
733
849
|
}
|
|
734
850
|
}
|
|
735
851
|
|
|
@@ -984,7 +1100,9 @@ export function apply(ctx) {
|
|
|
984
1100
|
// 降级路径。两种情况都不阻塞下面的注册表同步 —— 模式可用优先于设置页完整。
|
|
985
1101
|
if (declarative !== null) {
|
|
986
1102
|
try {
|
|
987
|
-
|
|
1103
|
+
// 只问**出厂那四位**:并集模式(BASE_MODES 的第五项)在宿主那里没有文档,
|
|
1104
|
+
// 拿它去 readDocument 只会每次都换来一条"取不到"的错误日志,把真正的故障淹没掉。
|
|
1105
|
+
const bases = await declarative.fetchBaseCompositions(BASE_MODE_IDS)
|
|
988
1106
|
setBaseCompositions(bases.fetched)
|
|
989
1107
|
if (bases.fetched.size > 0) {
|
|
990
1108
|
console.log(
|