dsh-custom-mode 0.1.6-alpha.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.
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Composition compiler for the 自定义模式 settings page.
3
+ *
4
+ * Turns「base mode + per-row switches」into an `agent.cordis.yml`, by rewriting a
5
+ * shipped preset's composition text rather than reserialising parsed YAML.
6
+ *
7
+ * Why text surgery instead of a YAML round-trip:
8
+ *
9
+ * - The shipped compositions carry explanatory comments that are genuinely
10
+ * useful; a parse/serialise round-trip destroys all of them.
11
+ * - Rows carry `!!js` expressions (`disabled: !!js process.platform === 'win32'`).
12
+ * A round-trip would either lose them or re-emit them differently, silently
13
+ * changing platform behaviour.
14
+ *
15
+ * So a row is treated as an opaque text segment: we keep it byte-for-byte and
16
+ * only ever insert or replace its `disabled:` line. An untouched row therefore
17
+ * stays EXACTLY as shipped, including its platform condition.
18
+ *
19
+ * Timestamp: `agent-presets` decides whether to re-mount a preset by comparing
20
+ * only `mtimeMs` and `size` of the composition file. A regeneration that
21
+ * happened to produce identical bytes would not take effect, so the rendered
22
+ * output always carries a timestamp comment.
23
+ */
24
+
25
+ import { readFileSync, existsSync } from 'node:fs'
26
+ import { createRequire } from 'node:module'
27
+ import { dirname } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { join } from 'node:path'
30
+ import { dshHome } from './paths.mjs'
31
+
32
+ /**
33
+ * Directory holding the shipped preset folders (`standard`, `ptc`, …),
34
+ * discovered once and cached.
35
+ *
36
+ * Resolved from the installed `dsh-agent-presets` package rather than a
37
+ * hard-coded install path, so this works on any machine layout. The chain is:
38
+ *
39
+ * <harness>/dsh-agent-presets/presets/custom/prompt-reader.mjs ← this preset
40
+ * <harness>/dsh-agent-presets/presets/ ← what we want
41
+ *
42
+ * The preset directory is the FIRST place `prompt-reader.mjs` exists walking up
43
+ * from here: `preset/` in a source checkout does not carry that file. Discovery
44
+ * is deferred to first use so importing this module never throws.
45
+ */
46
+ let shippedPresetsCache
47
+ function discoverShippedPresets() {
48
+ const require = createRequire(import.meta.url)
49
+ // 1) Node 解析:插件安装在能看见 harness 依赖的位置时直接命中。
50
+ try {
51
+ const manifest = require.resolve('@deepseek-ai/dsh-agent-presets/package.json')
52
+ return join(dirname(manifest), 'presets')
53
+ } catch {
54
+ /* fall through */
55
+ }
56
+ // 2) profile 的 node_modules:dsh 把 harness 包放在 <dshHome>/profiles/node_modules,
57
+ // 这里独立于「roster 是否给出 system 行」的形状,是 agentPresets 之外的第二条路。
58
+ try {
59
+ const fromProfile = createRequire(join(dshHome(), 'profiles', 'package.json'))
60
+ const manifest = fromProfile.resolve('@deepseek-ai/dsh-agent-presets/package.json')
61
+ return join(dirname(manifest), 'presets')
62
+ } catch {
63
+ /* fall through */
64
+ }
65
+ throw new Error(
66
+ '无法定位出厂基础模式。已尝试:agentPresets 的 system 预设路径、Node 解析、profile 的 node_modules。' +
67
+ '若 DSH 改了包布局,请用 DSH_SHIPPED_PRESETS_DIR 指向 presets 目录。',
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Base modes a user may build on.
73
+ *
74
+ * `custom` is deliberately absent: it is this feature's own output, and offering
75
+ * it as a base would let a preset recursively include itself.
76
+ */
77
+ export const BASE_MODES = [
78
+ { id: 'standard', label: '标准模式', note: '完整编码能力:Shell、文件、检索、技能、计划、目标、子代理、工作流' },
79
+ { id: 'ptc', label: 'PTC 模式', note: '在标准模式基础上启用 PTC 工具呈现(tool-presentation)' },
80
+ { id: 'minimal', label: '极简模式', note: '只有 Shell 与终端,共 7 行;没有文件、检索、技能、子代理' },
81
+ { id: 'cordis', label: 'Cordis 模式', note: '标准模式 + 读写运行时的 Cordis 工具集,可让 agent 自己改 harness' },
82
+ ]
83
+
84
+ /**
85
+ * Row display metadata: a friendly label, and whether switching it off costs a
86
+ * basic capability. Rows absent from this table still render; they simply show
87
+ * their raw id.
88
+ */
89
+ export const ROW_META = {
90
+ persona: { label: '身份(系统提示词)', essential: true, note: '提示词注入点;关掉后本模式用回部署默认身份' },
91
+ 'agent-instructions': { label: '项目指令 AGENTS.md', note: '读取 AGENTS.md / CLAUDE.md' },
92
+ 'tool-bash': { label: 'Shell(bash)', essential: true },
93
+ 'tool-pwsh': { label: 'Shell(pwsh)', essential: true },
94
+ 'tool-fs': { label: '文件读写', essential: true, note: '关掉后 agent 无法读写文件' },
95
+ 'tool-fs-search': { label: '文件搜索(glob/grep)' },
96
+ 'tool-jobs': { label: '后台任务' },
97
+ 'tool-todo': { label: '待办清单' },
98
+ 'tool-ask-user': { label: '向用户提问' },
99
+ 'tool-goal': { label: '目标' },
100
+ 'command-goal': { label: '目标命令' },
101
+ planning: { label: '计划模式(分组)', note: '含 isolate realm,关掉等于移除整个计划能力' },
102
+ 'plan-mode': { label: '计划模式实现' },
103
+ compaction: { label: '上下文压缩(分组)', note: '含 isolate realm' },
104
+ 'compaction-basic': { label: '基础压缩' },
105
+ 'command-compact': { label: '/compact 命令' },
106
+ 'tool-result-pruner': { label: '工具结果裁剪' },
107
+ delegation: { label: '委派与工作流(分组)', note: '含 isolate realm;关掉等于移除子代理与工作流' },
108
+ 'tool-subagent': { label: '子代理(spawn)' },
109
+ 'tool-subagent-fork': { label: '子代理(fork)' },
110
+ 'tool-subagent-control': { label: '子代理控制' },
111
+ 'tool-subagent-list-agents': { label: '列出子代理' },
112
+ 'tool-subagent-codex': { label: 'Codex 子代理', note: '默认关闭:需要先安装对应 Bundle' },
113
+ 'tool-subagent-claude-code': { label: 'Claude Code 子代理', note: '默认关闭:需要先安装对应 Bundle' },
114
+ 'workflow-ptc': { label: '工作流引擎' },
115
+ 'tool-workflow': { label: '工作流工具' },
116
+ 'tool-ralph': { label: 'Ralph 工作流', note: '默认关闭' },
117
+ 'tool-web': { label: '网页检索与抓取' },
118
+ 'tool-skill': { label: '技能工具' },
119
+ 'skill-filesystem': { label: '技能发现' },
120
+ 'tool-cordis': { label: 'Cordis 运行时工具', note: '可读写 harness 运行时' },
121
+ 'tool-presentation': { label: 'PTC 工具呈现' },
122
+ present: { label: '交付文件(present)' },
123
+ 'custom-prompt-tool': { label: 'custom_prompt 工具', note: '关掉后无法用对话改提示词(设置页仍可用)' },
124
+ 'persistent-shell': { label: '持久 Shell' },
125
+ pty: { label: 'PTY 终端' },
126
+ 'terminal-bash': { label: '终端(bash)' },
127
+ 'persistent-bash': { label: '持久 bash' },
128
+ 'terminal-pwsh': { label: '终端(pwsh)' },
129
+ 'persistent-pwsh': { label: '持久 pwsh' },
130
+ }
131
+
132
+ /** Absolute path of the shipped composition for a base mode. */
133
+ export function baseCompositionPath(modeId) {
134
+ return join(shippedPresetsDir(), modeId, 'agent.cordis.yml')
135
+ }
136
+
137
+ /**
138
+ * Resolve the shipped-preset directory.
139
+ *
140
+ * A test (or a source checkout, where the shipped presets are not beside this
141
+ * module) may override it through `DSH_SHIPPED_PRESETS_DIR`; otherwise it is
142
+ * discovered from the installed `dsh-agent-presets` package.
143
+ */
144
+ /** Directory injected by the host half, which resolves it via the agentPresets service. */
145
+ let injectedShippedDir
146
+ /**
147
+ * Point the compiler at a shipped-preset directory discovered at runtime.
148
+ *
149
+ * The host half does this from `agentPresets.list()` (system-trust rows carry
150
+ * absolute paths), which is layout-independent. Deriving the directory from this
151
+ * module's own location does NOT work: this module is loaded from the plugin's
152
+ * install directory, not from inside a preset, so no amount of walking up finds
153
+ * the shipped presets.
154
+ */
155
+ export function setShippedPresetsDir(dir) {
156
+ if (typeof dir === 'string' && dir !== '') injectedShippedDir = dir
157
+ }
158
+
159
+ export function shippedPresetsDir() {
160
+ const override = process.env.DSH_SHIPPED_PRESETS_DIR
161
+ if (override !== undefined && override !== '') return override
162
+ if (injectedShippedDir !== undefined) return injectedShippedDir
163
+ if (shippedPresetsCache === undefined) shippedPresetsCache = discoverShippedPresets()
164
+ return shippedPresetsCache
165
+ }
166
+
167
+ /** Read one base mode's shipped composition text. */
168
+ export function readBaseComposition(modeId) {
169
+ return readFileSync(baseCompositionPath(modeId), 'utf8')
170
+ }
171
+
172
+ /**
173
+ * Whether a segment line declares a row id at the expected indentation.
174
+ *
175
+ * Nested group rows are indented four spaces in the shipped compositions, so a
176
+ * top-level row is recognised only at column 0 and a nested one only at that
177
+ * exact depth. Deeper indentation never carries a row id.
178
+ */
179
+ function rowIdAt(line, topLevel) {
180
+ const pattern = topLevel ? /^- id: (.+?)\s*$/ : /^ {4}- id: (.+?)\s*$/
181
+ const match = pattern.exec(line)
182
+ return match === null ? undefined : match[1].replace(/^['"]|['"]$/g, '')
183
+ }
184
+
185
+ /**
186
+ * Split composition text into a leading preamble plus one segment per row.
187
+ *
188
+ * A segment runs from its `- id:` line to just before the next row line, and —
189
+ * critically — the separating newline is INCLUDED in the segment it follows.
190
+ * Segments therefore concatenate back to the original text with NO separator:
191
+ * `lead + segments.join('') === text` exactly. Joining with `'\n'` instead would
192
+ * lose one newline at every boundary, because the last line of a `slice` has no
193
+ * trailing newline of its own.
194
+ *
195
+ * The `lead` keeps everything before the first row (the shipped header comment),
196
+ * and also has no trailing newline of its own — the first segment starts with
197
+ * one.
198
+ */
199
+ function splitSegments(text, topLevel) {
200
+ const lines = text.split('\n')
201
+ const starts = []
202
+ for (let i = 0; i < lines.length; i += 1) {
203
+ const id = rowIdAt(lines[i], topLevel)
204
+ if (id !== undefined) starts.push({ index: i, id })
205
+ }
206
+ if (starts.length === 0) return { lead: text, segments: [] }
207
+ /**
208
+ * Text from line `from` up to (not including) line `to`, carrying the newline
209
+ * that ended the last included line.
210
+ *
211
+ * The empty range is the case that matters: a nested level often begins AT
212
+ * line 0, so `slice(0, 0)` must yield `''`. Returning `'\n'` there injects a
213
+ * blank line before every group's first child.
214
+ */
215
+ const slice = (from, to) => {
216
+ if (from >= to) return ''
217
+ if (to >= lines.length) return lines.slice(from).join('\n')
218
+ return `${lines.slice(from, to).join('\n')}\n`
219
+ }
220
+ const lead = slice(0, starts[0].index)
221
+ const segments = starts.map((start, position) => {
222
+ const end = position + 1 < starts.length ? starts[position + 1].index : lines.length
223
+ return { id: start.id, text: slice(start.index, end) }
224
+ })
225
+ return { lead, segments }
226
+ }
227
+
228
+ /**
229
+ * Whether a row segment carries its OWN `disabled` key, and its raw value.
230
+ *
231
+ * Scoped to the row's own indentation (see {@link ownKeyIndent}) for the same
232
+ * reason {@link setDisabled} is: a group whose children ship disabled must not
233
+ * read as a disabled group.
234
+ */
235
+ function disabledOf(segmentText) {
236
+ const own = disabledLineAt(ownKeyIndent(segmentText))
237
+ for (const line of segmentText.split('\n')) {
238
+ const match = own.exec(line)
239
+ if (match !== null) return { present: true, value: match[1].trim() }
240
+ }
241
+ return { present: false, value: '' }
242
+ }
243
+
244
+ /**
245
+ * Indentation of a row's OWN keys, for a row segment or a whole row tree.
246
+ *
247
+ * The `disabled` key we may set belongs at `- id:`'s indentation plus two
248
+ * spaces. That distinction is load-bearing: group rows contain child rows that
249
+ * carry their own `disabled:` keys, so a naive "first `disabled:` line in this
250
+ * segment" search edits a CHILD's key and silently leaves the group itself
251
+ * unchanged — which is exactly the bug this function exists to prevent.
252
+ *
253
+ * For a whole-tree input (used by the UI description path) the minimum `- id:`
254
+ * indentation identifies the outermost row.
255
+ *
256
+ * @param {string} segmentText - one row segment, or a whole composition.
257
+ * @returns {number} the column the row's own keys start at.
258
+ */
259
+ function ownKeyIndent(segmentText) {
260
+ let minIndent = Infinity
261
+ for (const line of segmentText.split('\n')) {
262
+ const match = /^(\s*)- id:/.exec(line)
263
+ if (match !== null) minIndent = Math.min(minIndent, match[1].length)
264
+ }
265
+ return minIndent === Infinity ? 0 : minIndent + 2
266
+ }
267
+
268
+ /** Regex matching a `disabled:` key at exactly one indentation depth. */
269
+ function disabledLineAt(indent) {
270
+ return new RegExp(`^ {${String(indent)}}disabled:\\s*(.*)$`)
271
+ }
272
+
273
+ /**
274
+ * Rewrite the `disabled` key of one row segment.
275
+ *
276
+ * Only the row's OWN key is touched (see {@link ownKeyIndent}).
277
+ *
278
+ * Three states, and the distinction matters:
279
+ *
280
+ * - `undefined` — the user did not touch this row. The segment is returned
281
+ * untouched, which is what preserves a shipped `!!js` platform condition and
282
+ * the rows that ship switched off (`tool-subagent-codex`, …).
283
+ * - `false` — the user explicitly enabled the row. Any `disabled:` line at the
284
+ * row's own depth is removed, INCLUDING a platform expression: an explicit
285
+ * choice beats the platform default.
286
+ * - `true` — the user disabled it. The value is written as a literal `true`,
287
+ * replacing a platform expression, again because the explicit choice wins.
288
+ */
289
+ function setDisabled(segmentText, disabled) {
290
+ if (disabled === undefined) return segmentText
291
+ const lines = segmentText.split('\n')
292
+ const indent = ownKeyIndent(segmentText)
293
+ const own = disabledLineAt(indent)
294
+ const index = lines.findIndex((line) => own.test(line))
295
+ if (index !== -1) {
296
+ if (disabled) {
297
+ lines[index] = `${' '.repeat(indent)}disabled: true`
298
+ } else {
299
+ lines.splice(index, 1)
300
+ }
301
+ return lines.join('\n')
302
+ }
303
+ if (!disabled) return segmentText
304
+ const nameIndex = lines.findIndex((line) => new RegExp(`^ {${String(indent)}}name:\\s`).test(line))
305
+ if (nameIndex === -1) return segmentText
306
+ lines.splice(nameIndex + 1, 0, `${' '.repeat(indent)}disabled: true`)
307
+ return lines.join('\n')
308
+ }
309
+
310
+ /**
311
+ * Evaluate a row's shipped `!!js` disable predicate against THIS process.
312
+ *
313
+ * Rows carry conditions like `!!js process.platform === 'win32'`. Reporting such
314
+ * a row as "disabled" purely because a `disabled:` key exists would show every
315
+ * platform row as off on every platform, and would hide the user's own toggle of
316
+ * it (the diff against the shipped state comes out empty).
317
+ *
318
+ * Evaluating the predicate is what lets the page show the state that is actually
319
+ * in force here. The expression comes from a composition file installed on this
320
+ * machine, which the deployment already executes as a Cordis plugin, so this adds
321
+ * no trust that the file did not already have.
322
+ *
323
+ * @param {string} expression - the raw value after `disabled:`, starting with `!!js`.
324
+ * @returns {boolean|undefined} the evaluated result, or undefined when it cannot be evaluated.
325
+ */
326
+ function evalDisabledExpression(expression) {
327
+ if (typeof expression !== 'string' || !expression.startsWith('!!js')) return undefined
328
+ try {
329
+ // eslint-disable-next-line no-new-func -- evaluating the composition's own predicate is the point
330
+ const fn = new Function('process', `return (${expression.slice(4).trim()});`)
331
+ return fn(process) === true
332
+ } catch {
333
+ return undefined
334
+ }
335
+ }
336
+
337
+ /** Build one row's UI description from its segment text. */
338
+ function describeRow(id, segmentText, children) {
339
+ const own = disabledOf(segmentText)
340
+ const meta = ROW_META[id] ?? {}
341
+ // A literal `disabled: true` is off. A `!!js` predicate is resolved for THIS
342
+ // machine, so the page shows the state actually in force rather than "has a key".
343
+ const literalOff = own.present && !own.value.startsWith('!!js')
344
+ const fromExpression = own.present ? evalDisabledExpression(own.value) : undefined
345
+ return {
346
+ id,
347
+ group: children.length > 0,
348
+ disabled: literalOff || fromExpression === true,
349
+ disabledExpression: own.present && own.value.startsWith('!!js') ? own.value : null,
350
+ label: meta.label ?? id,
351
+ essential: meta.essential === true,
352
+ note: meta.note ?? null,
353
+ children,
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Collect the row tree of a composition, for rendering the settings UI.
359
+ *
360
+ * @param {string} text - composition file text.
361
+ * @returns {Array<object>} rows, groups carrying their nested rows.
362
+ */
363
+ export function collectRows(text) {
364
+ const { segments } = splitSegments(text, true)
365
+ return segments.map((segment) => {
366
+ const isGroup = /^\s*group:\s*true\s*$/m.test(segment.text)
367
+ const nested = isGroup ? splitSegments(segment.text, false).segments : []
368
+ const children = nested.map((child) => describeRow(child.id, child.text, []))
369
+ return describeRow(segment.id, segment.text, children)
370
+ })
371
+ }
372
+
373
+ /**
374
+ * Rewrite one level of rows, applying disabled overrides by id.
375
+ *
376
+ * `overrides` maps a row id to an explicit DISABLED state (already normalised). A row absent from the map is
377
+ * left byte-for-byte as shipped — that is how an untouched `!!js` platform
378
+ * condition and the rows that ship disabled survive a regeneration.
379
+ *
380
+ * @param {string} text - the level to rewrite.
381
+ * @param {boolean} topLevel - level selector for {@link splitSegments}.
382
+ * @param {Map<string, boolean>} overrides - explicit per-row states.
383
+ * @param {boolean} nested - whether this call rewrites a group's contents.
384
+ * @returns {string} the rewritten level.
385
+ */
386
+ function applyLevel(text, topLevel, overrides, nested) {
387
+ const { lead, segments } = splitSegments(text, topLevel)
388
+ if (segments.length === 0) return text
389
+ const rendered = segments.map((segment) => {
390
+ // The persona row is always replaced by this feature's own reader row: the
391
+ // shipped one is a static-string persona whose text cannot be edited, so
392
+ // keeping it would silently disable the editable prompt.
393
+ if (!nested && segment.id === 'persona') {
394
+ return setDisabled(PERSONA_ROW, overrides.get('persona'))
395
+ }
396
+ let body = segment.text
397
+ if (nested) {
398
+ // Nested rows: rewrite only the row's own `disabled`, never recurse.
399
+ return setDisabled(body, overrides.get(segment.id))
400
+ }
401
+ const isGroup = /^\s*group:\s*true\s*$/m.test(body)
402
+ if (isGroup) {
403
+ const children = splitSegments(body, false).segments
404
+ if (children.length > 0) {
405
+ // Rewrite the group's nested block, then the group's own `disabled`.
406
+ const cut = body.indexOf(`\n - id: ${children[0].id}`)
407
+ if (cut !== -1) {
408
+ const head = body.slice(0, cut + 1)
409
+ const tail = body.slice(cut + 1)
410
+ body = head + applyLevel(tail, false, overrides, true)
411
+ }
412
+ }
413
+ }
414
+ return setDisabled(body, overrides.get(segment.id))
415
+ })
416
+ // Empty join: each segment already carries the newline that followed it, so
417
+ // `lead + rendered.join('')` reproduces the input byte-for-byte.
418
+ return `${lead}${rendered.join('')}`
419
+ }
420
+
421
+ /**
422
+ * Normalise a caller's override input into `Map<rowId, boolean>`.
423
+ *
424
+ * A `Map` is the real input: only ids present in it are touched. A `Set` or
425
+ * array is accepted as shorthand for "these ids are off" (which is what a test
426
+ * or a quick script usually wants), and an empty one therefore means "leave
427
+ * every row exactly as shipped".
428
+ */
429
+ function normaliseOverrides(input) {
430
+ const disabled = new Map()
431
+ // Shorthand first: an array or Set means "these ids are off". Checked before
432
+ // the object branch because an array IS an object.
433
+ if (input instanceof Set || Array.isArray(input)) {
434
+ for (const id of input) disabled.set(id, true)
435
+ return disabled
436
+ }
437
+ // Encoding form: true means ENABLED, matching the settings checkbox.
438
+ const entries = input instanceof Map ? [...input.entries()] : input !== null && typeof input === 'object' ? Object.entries(input) : []
439
+ for (const [id, enabled] of entries) {
440
+ if (typeof enabled === 'boolean') disabled.set(id, enabled !== true)
441
+ }
442
+ return disabled
443
+ }
444
+
445
+ /**
446
+ * Render a complete `agent.cordis.yml` for one base mode and switch set.
447
+ *
448
+ * @param {string} modeId - one of {@link BASE_MODES}.
449
+ * @param {Map<string, boolean>|Set<string>|string[]} overrides - explicit per-row
450
+ * states; ids absent from a Map keep their shipped value.
451
+ * @returns {string} the composition text to install.
452
+ */
453
+ export function renderComposition(modeId, overrides) {
454
+ if (!BASE_MODES.some((mode) => mode.id === modeId)) throw new Error(`未知基础模式: ${modeId}`)
455
+ const explicit = normaliseOverrides(overrides)
456
+ const base = readBaseComposition(modeId)
457
+ const rewritten = applyLevel(base, true, explicit, false)
458
+ const header = [
459
+ '# 本文件由「自定义模式」设置页生成,请勿手工编辑——下次保存会覆盖。',
460
+ `# 基础模式: ${modeId}`,
461
+ `# 生成时间: ${new Date().toISOString()}`,
462
+ '#',
463
+ '# 每一行都是原样复制的官方 preset 行,只有 disable 状态会被改写;',
464
+ '# 未被你切换过的行保持出厂状态(含 !!js 平台条件与默认关闭行)。',
465
+ '# 生成时间戳同时用于让 agent-presets 检测到变化并重新挂载(它只比对 mtime 与 size)。',
466
+ '',
467
+ ].join('\n')
468
+ // Rows this feature owns are appended after the base mode's rows, so a
469
+ // regeneration cannot drop them.
470
+ const extras = EXTRA_ROWS.map((extra) => setDisabled(extra.text, explicit.get(extra.id))).join('')
471
+ return `${header}${rewritten}${extras}`
472
+ }
473
+
474
+ /**
475
+ * The persona row this feature REQUIRES, substituted for whatever the base mode
476
+ * ships.
477
+ *
478
+ * Without this substitution, regenerating from a shipped mode would restore
479
+ * `@deepseek-ai/dsh-persona` — whose `prefix` is a static string resolved at
480
+ * mount — and the editable prompt would silently stop working.
481
+ */
482
+ const PERSONA_ROW = [
483
+ '# 本模式的身份来自 prompt.md(由本插件的 prompt-reader.mjs 每步重新读取)。',
484
+ "- id: persona",
485
+ " name: './prompt-reader.mjs'",
486
+ ' config:',
487
+ ' # Omitted `path` defaults to prompt.md beside the module.',
488
+ ' complete: false',
489
+ '',
490
+ ].join('\n')
491
+
492
+ /**
493
+ * Rows this feature adds on top of any base mode: preset-relative modules that
494
+ * no shipped mode contains.
495
+ *
496
+ * They are always emitted, so a regeneration cannot drop them. `custom-prompt-tool`
497
+ * is the durable editing path for sessions with no browser.
498
+ */
499
+ const EXTRA_ROWS = [
500
+ {
501
+ id: 'custom-prompt-tool',
502
+ text: [
503
+ '# 无浏览器时的改提示词通道(模型工具 custom_prompt)。',
504
+ '- id: custom-prompt-tool',
505
+ " name: './prompt-tool.mjs'",
506
+ '',
507
+ ].join('\n'),
508
+ },
509
+ ]
510
+
511
+ /** Read the base mode recorded in a generated composition, defaulting to standard. */
512
+ export function modeOf(text) {
513
+ const match = /^# 基础模式: (\S+)\s*$/m.exec(text)
514
+ const id = match === null ? undefined : match[1]
515
+ return BASE_MODES.some((mode) => mode.id === id) ? id : 'standard'
516
+ }
517
+
518
+ /** Flatten a row tree into `Map<id, disabled>`. */
519
+ function flattenRows(rows, into = new Map()) {
520
+ for (const row of rows) {
521
+ into.set(row.id, row.disabled)
522
+ flattenRows(row.children, into)
523
+ }
524
+ return into
525
+ }
526
+
527
+ /**
528
+ * Derive the explicit overrides that turned `base` into `text`.
529
+ *
530
+ * The composition file is the single source of truth: instead of keeping a
531
+ * separate settings document that can drift, the page recomputes which rows the
532
+ * user has deviated from by diffing against the same base mode. A row whose
533
+ * state matches the shipped one is simply "not overridden", which is what keeps
534
+ * its `!!js` platform condition intact.
535
+ *
536
+ * @param {string} text - the installed composition.
537
+ * @param {string} modeId - its base mode.
538
+ * @returns {Record<string, boolean>} row id -> enabled.
539
+ */
540
+ export function overridesOf(text, modeId) {
541
+ const base = flattenRows(collectRows(readBaseComposition(modeId)))
542
+ for (const extra of EXTRA_ROWS) base.set(extra.id, false)
543
+ const current = flattenRows(collectRows(text))
544
+ const overrides = {}
545
+ for (const [id, disabled] of current) {
546
+ if (base.get(id) === disabled) continue
547
+ overrides[id] = disabled !== true
548
+ }
549
+ return overrides
550
+ }
551
+
552
+ /**
553
+ * Rows this feature always adds, for the settings UI to show alongside the base
554
+ * mode's own rows.
555
+ */
556
+ export function extraRowIds() {
557
+ return EXTRA_ROWS.map((extra) => extra.id)
558
+ }
@@ -0,0 +1,17 @@
1
+ # Bundle patch for dsh-custom-mode: inserts the 系统提示词 Settings page row.
2
+ #
3
+ # A bundle patch inserts new rows; the profile's own cordis.patch.yml can only
4
+ # replace rows that already exist by id, which is why this row has to originate
5
+ # here rather than in the profile file.
6
+ #
7
+ # The plugin provides no Cordis service. It registers one private HTTP route
8
+ # (`/custom-mode`), and naming the package in the root loader is also
9
+ # what makes `dsh-client-modules` discover this package's `dsh.client`
10
+ # declaration and serve `client.js` to the page.
11
+ #
12
+ # Deleting this file's bundle entry (or removing the package from the profile)
13
+ # removes the Settings page completely; nothing else in the harness references
14
+ # it, and no agent preset depends on it.
15
+ - insert:
16
+ - id: custom-mode
17
+ name: dsh-custom-mode