dsh-custom-mode 1.9.2 → 1.9.3

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/assistants.mjs CHANGED
@@ -30,6 +30,7 @@ import { dirname, join } from 'node:path'
30
30
  import { dshHome, PRESET_DIR } from './paths.mjs'
31
31
  import { readPresetMeta, writePresetMeta } from './meta.mjs'
32
32
  import { writeAtomic } from './atomic.mjs'
33
+ import { unresolvableRows } from './composition.mjs'
33
34
  import { seedPreset, seedPresetWithLog } from './seed.mjs'
34
35
 
35
36
  /**
@@ -303,6 +304,25 @@ export function seedOnActivation({ root, templateDir, composition, log = console
303
304
  for (const error of result.errors) log(`custom-mode: 补全 ${dir} 失败 —— ${error}`)
304
305
  }
305
306
 
307
+ // 启动告警:组成文件里有本机这条线解析不到的行时,平台会把整个预设判为 broken 并从选择器里**静默丢弃**。
308
+ // 以前这件事只在设置页可见 —— 用户从不打开设置页就完全无感知(外部评审实测)。这里至少在宿主日志里喊一声。
309
+ for (const dir of [...existing, join(root, LEGACY_ID)]) {
310
+ try {
311
+ const file = join(dir, 'agent.cordis.yml')
312
+ if (existsSync(file) === false) continue
313
+ const bad = unresolvableRows(readFileSync(file, 'utf8'))
314
+ if (bad.length > 0) {
315
+ log(
316
+ `custom-mode: ${dir} 的组成文件里有 ${String(bad.length)} 行在这条 dsh 线上无法解析` +
317
+ `(${bad.map((row) => row.id).join(', ')})—— 平台会把整个模式从新建会话的选择器里丢弃。` +
318
+ '打开 设置 → 自定义模式 并点「按本线修复」。',
319
+ )
320
+ }
321
+ } catch {
322
+ /* 启动告警失败不能影响激活 */
323
+ }
324
+ }
325
+
306
326
  if (isSeeded(root)) return { created: false, repaired, adopted: false }
307
327
  if (existing.length > 0) {
308
328
  markSeeded(root)
package/client.js CHANGED
@@ -293,15 +293,15 @@ try {
293
293
  "msg.imported": "Imported into the editor (not saved yet) — review it, then click Save.",
294
294
  "msg.importFailed": "Import failed",
295
295
  "api.badDirection": "Unknown reorder direction: {direction}",
296
- "api.unknownAssistant": "No assistant {id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).",
297
- "api.alreadyFirst": "{name} is already first.",
298
- "api.alreadyLast": "{name} is already last.",
296
+ "api.unknownAssistant": "No assistant {id}: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).",
297
+ "api.alreadyFirst": "{name} is already first.",
298
+ "api.alreadyLast": "{name} is already last.",
299
299
  "api.badVariableName": "{variable} is not a valid variable reference: names may use lower-case letters, digits and underscores, and must start with a letter. For a literal brace, use a single opening brace or an unclosed double brace.",
300
300
  "api.unknownVariable": "{variable} is not a registered variable — rendering would fail every request in this mode. Available: {known}.",
301
301
  "assistant.short": "Each assistant is its own mode: its own system prompt, base mode and plugin switches.",
302
302
  "name.short": "Renaming only changes what is displayed — not the internal id or existing sessions.",
303
303
  "mode.short": "The base decides the row set and tool abilities; the persona row is always replaced by this mode.",
304
- "mode.pendingRows": "Base changed to {mode}」: the row list below is recomputed from the new base when you save.",
304
+ "mode.pendingRows": "Base changed to {mode}: the row list below is recomputed from the new base when you save.",
305
305
  "rows.short": "Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.",
306
306
  "prompt.short": "Saving this text makes it the system prompt of this mode, and it takes effect on the next step.",
307
307
  "aria.expandHint": "Show the full explanation",
@@ -323,9 +323,9 @@ try {
323
323
  "meta.version": "Plugin version",
324
324
  "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.",
325
325
  "api.saved": "Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.",
326
- "api.created": "Created {name}」. You can write its system prompt now.",
327
- "api.duplicated": "Copied from {from}」. The two are independent from now on.",
328
- "api.deleted": "Deleted {name}」. Sessions already using it keep running; it no longer appears for new sessions.",
326
+ "api.created": "Created {name}. You can write its system prompt now.",
327
+ "api.duplicated": "Copied from {from}. The two are independent from now on.",
328
+ "api.deleted": "Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.",
329
329
  "api.reordered": "Order saved: the new-session mode picker follows it.",
330
330
  "api.nameRequired": "Give the new assistant a name first.",
331
331
  "api.nameTooLong": "That name is too long (limit {max} characters).",
@@ -347,9 +347,9 @@ try {
347
347
  "warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
348
348
  "warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
349
349
  "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.",
350
- "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.",
350
+ "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.",
351
351
  "warn.approvalGateMissing.label": "Approval gate is off",
352
- "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.",
352
+ "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.",
353
353
  "warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
354
354
  "history.label": "Change history",
355
355
  "history.pick": "Pick a version to load…",
package/composition.mjs CHANGED
@@ -397,14 +397,17 @@ export function collectRows(text) {
397
397
  * @param {boolean} nested - whether this call rewrites a group's contents.
398
398
  * @returns {string} the rewritten level.
399
399
  */
400
- function applyLevel(text, topLevel, overrides, nested) {
400
+ function applyLevel(text, topLevel, overrides, nested, replacePersona = true) {
401
401
  const { lead, segments } = splitSegments(text, topLevel)
402
402
  if (segments.length === 0) return text
403
403
  const rendered = segments.map((segment) => {
404
404
  // The persona row is always replaced by this feature's own reader row: the
405
405
  // shipped one is a static-string persona whose text cannot be edited, so
406
406
  // keeping it would silently disable the editable prompt.
407
- if (!nested && segment.id === 'persona') {
407
+ // `replacePersona` 只有**重新渲染**时才为真:那时整段 persona 换成我们的读取器行是对的。
408
+ // 但"按本线修复"是**就地**手术,它必须连 persona 段里的注释都原样保留 ——
409
+ // 之前这里无条件替换,导致每次修复都会丢注释 / 或多复制一行身份注释(外部评审实测)。
410
+ if (!nested && segment.id === 'persona' && replacePersona === true) {
408
411
  return setDisabled(PERSONA_ROW, overrides.get('persona'))
409
412
  }
410
413
  let body = segment.text
@@ -590,8 +593,8 @@ export function disableRowsInPlace(text, ids) {
590
593
  if (wanted.length === 0) return text
591
594
  // `applyLevel` 把 Map 的值直接交给 `setDisabled(...)`,所以值是**关闭**布尔(true = 关闭)。
592
595
  const off = new Map(wanted.map((id) => [id, true]))
593
- const top = applyLevel(text, true, off, false)
594
- return applyLevel(top, false, off, false)
596
+ const top = applyLevel(text, true, off, false, false)
597
+ return applyLevel(top, false, off, false, false)
595
598
  }
596
599
 
597
600
  export function unresolvableRows(text) {
package/index.mjs CHANGED
@@ -161,7 +161,7 @@ export function checkPromptText(text) {
161
161
  return {
162
162
  ok: false,
163
163
  code: 'unknownVariable',
164
- params: { variable: '{{' + variable + '}}', known: KNOWN_VARIABLES.map((item) => '{{' + item + '}}').join('') },
164
+ params: { variable: '{{' + variable + '}}', known: KNOWN_VARIABLES.map((item) => '{{' + item + '}}').join(', ') },
165
165
  error:
166
166
  '保存被拒绝:{{' +
167
167
  variable +
@@ -372,8 +372,12 @@ export function saveState(rows, input) {
372
372
  if (name.length > MAX_NAME) {
373
373
  return { ok: false, code: 'nameTooLong', params: { max: MAX_NAME }, error: '保存被拒绝:模式名称过长(上限 ' + String(MAX_NAME) + ' 个字符)。' }
374
374
  }
375
+ // 请求里**没带** description 时保留原值:API 调用方只改提示词,不该顺手把描述清空(外部评审实测)。
376
+ const meta = readPresetMeta(directory)
375
377
  const rawDescription =
376
- input !== null && typeof input === 'object' && typeof input.description === 'string' ? input.description : ''
378
+ input !== null && typeof input === 'object' && typeof input.description === 'string'
379
+ ? input.description
380
+ : (typeof meta.description === 'string' ? meta.description : '')
377
381
  const description = rawDescription.replace(/\r?\n/g, ' ').trim()
378
382
  if (description.length > MAX_DESCRIPTION) {
379
383
  return { ok: false, code: 'descriptionTooLong', params: { max: MAX_DESCRIPTION }, error: '保存被拒绝:模式描述过长(上限 ' + String(MAX_DESCRIPTION) + ' 个字符)。' }
package/locales.mjs CHANGED
@@ -268,15 +268,15 @@ export const en = {
268
268
  'msg.imported': 'Imported into the editor (not saved yet) — review it, then click Save.',
269
269
  'msg.importFailed': 'Import failed',
270
270
  'api.badDirection': 'Unknown reorder direction: {direction}',
271
- 'api.unknownAssistant': 'No assistant {id}」: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).',
272
- 'api.alreadyFirst': '{name} is already first.',
273
- 'api.alreadyLast': '{name} is already last.',
271
+ 'api.unknownAssistant': 'No assistant {id}: this page only manages the assistants it created (a directory with prompt.md whose composition injects the identity through prompt-reader.mjs).',
272
+ 'api.alreadyFirst': '{name} is already first.',
273
+ 'api.alreadyLast': '{name} is already last.',
274
274
  'api.badVariableName': '{variable} is not a valid variable reference: names may use lower-case letters, digits and underscores, and must start with a letter. For a literal brace, use a single opening brace or an unclosed double brace.',
275
275
  'api.unknownVariable': '{variable} is not a registered variable — rendering would fail every request in this mode. Available: {known}.',
276
276
  'assistant.short': 'Each assistant is its own mode: its own system prompt, base mode and plugin switches.',
277
277
  'name.short': 'Renaming only changes what is displayed — not the internal id or existing sessions.',
278
278
  'mode.short': 'The base decides the row set and tool abilities; the persona row is always replaced by this mode.',
279
- 'mode.pendingRows': 'Base changed to {mode}」: the row list below is recomputed from the new base when you save.',
279
+ 'mode.pendingRows': 'Base changed to {mode}: the row list below is recomputed from the new base when you save.',
280
280
  'rows.short': 'Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.',
281
281
  'prompt.short': 'Saving this text makes it the system prompt of this mode, and it takes effect on the next step.',
282
282
  'aria.expandHint': 'Show the full explanation',
@@ -298,9 +298,9 @@ export const en = {
298
298
  'meta.version': 'Plugin version',
299
299
  '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.',
300
300
  'api.saved': 'Saved ({name}, base mode {mode}). A new session picks it up; the current one keeps its configuration.',
301
- 'api.created': 'Created {name}」. You can write its system prompt now.',
302
- 'api.duplicated': 'Copied from {from}」. The two are independent from now on.',
303
- 'api.deleted': 'Deleted {name}」. Sessions already using it keep running; it no longer appears for new sessions.',
301
+ 'api.created': 'Created {name}. You can write its system prompt now.',
302
+ 'api.duplicated': 'Copied from {from}. The two are independent from now on.',
303
+ 'api.deleted': 'Deleted {name}. Sessions already using it keep running; it no longer appears for new sessions.',
304
304
  'api.reordered': 'Order saved: the new-session mode picker follows it.',
305
305
  'api.nameRequired': 'Give the new assistant a name first.',
306
306
  'api.nameTooLong': 'That name is too long (limit {max} characters).',
@@ -322,9 +322,9 @@ export const en = {
322
322
  'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
323
323
  'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
324
324
  '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.',
325
- '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.',
325
+ '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.',
326
326
  'warn.approvalGateMissing.label': 'Approval gate is off',
327
- '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.',
327
+ '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.',
328
328
  'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
329
329
  'history.label': 'Change history',
330
330
  'history.pick': 'Pick a version to load…',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-mode",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
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), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "publishConfig": {
67
67
  "access": "public",
68
- "tag": "alpha"
68
+ "tag": "latest"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@deepseek-ai/dsh": {
package/seed.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * 3. **Idempotent.** A second activation writes nothing and changes nothing.
22
22
  */
23
23
 
24
- import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
24
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
25
  import { renderComposition } from './composition.mjs'
26
26
  import { writeAtomic } from './atomic.mjs'
27
27
  import { dirname, join } from 'node:path'
@@ -91,8 +91,19 @@ export function starterComposition(options = {}) {
91
91
  * @param {{composition?: string|null}} [options] - a rendered composition to write instead of copying
92
92
  * `agent.cordis.yml`; see {@link starterComposition}.
93
93
  */
94
+ /**
95
+ * 我们自己的**代码**模块(相对于"用户数据")。
96
+ *
97
+ * 这两个文件是随助手目录一起分发的**代码**,不是用户内容:老版本创建的助手会一直留着旧副本,
98
+ * 于是 1.0.x / 1.1.x 首装的用户即使把插件升到最新,会话内改写提示词的**审批闸门仍然是缺的** ——
99
+ * 外部评审实测到了这一点(P1 安全)。所以它们在升级时**刷新**。
100
+ * `prompt.md` / `preset.yml` / `agent.cordis.yml` 是用户数据(提示词、开关、名字),保持"只补不缺"。
101
+ */
102
+ const REFRESHABLE_MODULES = ['prompt-reader.mjs', 'prompt-tool.mjs']
103
+
94
104
  export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options = {}) {
95
105
  const created = []
106
+ const refreshed = []
96
107
  const kept = []
97
108
  const errors = []
98
109
 
@@ -111,7 +122,22 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options =
111
122
 
112
123
  for (const name of PRESET_FILES) {
113
124
  const target = join(presetDir, name)
125
+ const source = join(sourceDir, name)
114
126
  if (existsSync(target)) {
127
+ // 代码模块:内容与包内不一致就升级(用原子写,带 Windows 重试)。
128
+ if (REFRESHABLE_MODULES.includes(name) && existsSync(source)) {
129
+ try {
130
+ const shipped = readFileSync(source, 'utf8')
131
+ if (readFileSync(target, 'utf8') !== shipped) {
132
+ writeAtomic(target, shipped)
133
+ refreshed.push(name)
134
+ continue
135
+ }
136
+ } catch (error) {
137
+ errors.push(`刷新 ${name} 失败: ${describe(error)}`)
138
+ continue
139
+ }
140
+ }
115
141
  kept.push(name)
116
142
  continue
117
143
  }
@@ -126,19 +152,19 @@ export function seedPreset(presetDir, sourceDir = packagedPresetDir(), options =
126
152
  continue
127
153
  }
128
154
  }
129
- const source = join(sourceDir, name)
130
155
  if (!existsSync(source)) {
131
156
  errors.push(`包内缺少 ${name}`)
132
157
  continue
133
158
  }
134
159
  try {
135
- copyFileSync(source, target)
160
+ // 原子写(内部带 Windows 重试):原先裸 copyFileSync 在两个实例共抢一个 DSH_HOME 首启时会 EBUSY。
161
+ writeAtomic(target, readFileSync(source, 'utf8'))
136
162
  created.push(name)
137
163
  } catch (error) {
138
164
  errors.push(`写入 ${name} 失败: ${describe(error)}`)
139
165
  }
140
166
  }
141
- return { created, kept, errors }
167
+ return { created, refreshed, kept, errors }
142
168
  }
143
169
 
144
170
  /**
@@ -163,6 +189,9 @@ export function seedPresetWithLog(presetDir, log = console.error, info = console
163
189
  log(`custom-mode: 播种 preset 时出现意外错误(已忽略): ${describe(error)}`)
164
190
  return { created: [], kept: [], errors: [describe(error)] }
165
191
  }
192
+ if (result.refreshed !== undefined && result.refreshed.length > 0) {
193
+ info(`custom-mode: 已刷新 ${presetDir} 里的代码模块(${result.refreshed.join(', ')})`)
194
+ }
166
195
  if (result.created.length > 0) {
167
196
  info(`custom-mode: 已播种 preset 到 ${presetDir}(新建 ${result.created.length} 个文件: ${result.created.join(', ')})`)
168
197
  }