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.
- package/client.js +652 -0
- package/composition.mjs +558 -0
- package/cordis.patch.yml +17 -0
- package/index.mjs +377 -0
- package/locales.mjs +219 -0
- package/meta.mjs +94 -0
- package/package.json +56 -0
- package/paths.mjs +38 -0
package/composition.mjs
ADDED
|
@@ -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
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -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
|