dsh-custom-mode 1.9.12 → 1.9.14
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/LICENSE +21 -0
- package/assistants.mjs +20 -3
- package/base-composition.mjs +419 -0
- package/client.js +47 -26
- package/composition.mjs +47 -74
- package/index.mjs +144 -18
- package/locales.mjs +10 -0
- package/meta.mjs +32 -11
- package/package.json +3 -1
- package/preset-backend/declarative.mjs +36 -0
- package/preset-backend/detect.mjs +9 -1
- package/preset-backend/index.mjs +10 -2
package/composition.mjs
CHANGED
|
@@ -22,51 +22,32 @@
|
|
|
22
22
|
* output always carries a timestamp comment.
|
|
23
23
|
*/
|
|
24
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'
|
|
25
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
29
26
|
import { join } from 'node:path'
|
|
30
|
-
|
|
27
|
+
|
|
28
|
+
// 出厂组成「从哪来」是两条 dsh 线唯一还没收进后端的一处:旧线是文件,0.1.7 是
|
|
29
|
+
// agentPresets.readDocument()。解析链整个搬到了 base-composition.mjs(含它的理由与实测),
|
|
30
|
+
// 本模块只消费它 —— 合成与排版手术仍然全部发生在这里。
|
|
31
|
+
import {
|
|
32
|
+
baseCompositionPath,
|
|
33
|
+
readBaseCompositionText,
|
|
34
|
+
setShippedPresetsDir,
|
|
35
|
+
shippedPresetsDir,
|
|
36
|
+
} from './base-composition.mjs'
|
|
37
|
+
|
|
38
|
+
export { baseCompositionPath, setShippedPresetsDir, shippedPresetsDir } from './base-composition.mjs'
|
|
39
|
+
export {
|
|
40
|
+
BaseCompositionUnavailableError,
|
|
41
|
+
isBaseCompositionUnavailable,
|
|
42
|
+
setBaseCompositions,
|
|
43
|
+
} from './base-composition.mjs'
|
|
31
44
|
|
|
32
45
|
/**
|
|
33
|
-
*
|
|
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
|
|
46
|
+
* 出厂组成的解析在 base-composition.mjs(见那里的解析顺序与实测依据)。
|
|
41
47
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* is deferred to first use so importing this module never throws.
|
|
48
|
+
* 这里曾经只有一条路:`require.resolve('@deepseek-ai/dsh-agent-presets')`。0.1.7 起那个包不再发布,
|
|
49
|
+
* 于是这条路必然抛错,而调用方把它变成了整页 500 —— 设置页在官方桌面端内置的那条线上整体不可用。
|
|
45
50
|
*/
|
|
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
51
|
|
|
71
52
|
/**
|
|
72
53
|
* Base modes a user may build on.
|
|
@@ -136,44 +117,21 @@ export const ROW_META = {
|
|
|
136
117
|
'persistent-pwsh': { label: '持久 pwsh' },
|
|
137
118
|
}
|
|
138
119
|
|
|
139
|
-
/**
|
|
140
|
-
export function baseCompositionPath(modeId) {
|
|
141
|
-
return join(shippedPresetsDir(), modeId, 'agent.cordis.yml')
|
|
142
|
-
}
|
|
120
|
+
/** 出厂组成的路径与解析链都在 base-composition.mjs(下面按名字再导出,调用点不必分支)。 */
|
|
143
121
|
|
|
144
122
|
/**
|
|
145
|
-
*
|
|
123
|
+
* Read one base mode's shipped composition text.
|
|
146
124
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*/
|
|
151
|
-
/** Directory injected by the host half, which resolves it via the agentPresets service. */
|
|
152
|
-
let injectedShippedDir
|
|
153
|
-
/**
|
|
154
|
-
* Point the compiler at a shipped-preset directory discovered at runtime.
|
|
125
|
+
* The chain — explicit override → text handed over by the host (`readDocument`) → legacy presets
|
|
126
|
+
* directory → packaged `dsh-web-app` patch — lives in base-composition.mjs, so every route can be
|
|
127
|
+
* tested on its own and the failure carries a `code` instead of a bare message.
|
|
155
128
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* install directory, not from inside a preset, so no amount of walking up finds
|
|
160
|
-
* the shipped presets.
|
|
129
|
+
* @param {string} modeId - one of {@link BASE_MODES}.
|
|
130
|
+
* @returns {string} the entry-list YAML of that base mode.
|
|
131
|
+
* @throws {BaseCompositionUnavailableError} when no route yields text (callers degrade, see index.mjs).
|
|
161
132
|
*/
|
|
162
|
-
export function setShippedPresetsDir(dir) {
|
|
163
|
-
if (typeof dir === 'string' && dir !== '') injectedShippedDir = dir
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function shippedPresetsDir() {
|
|
167
|
-
const override = process.env.DSH_SHIPPED_PRESETS_DIR
|
|
168
|
-
if (override !== undefined && override !== '') return override
|
|
169
|
-
if (injectedShippedDir !== undefined) return injectedShippedDir
|
|
170
|
-
if (shippedPresetsCache === undefined) shippedPresetsCache = discoverShippedPresets()
|
|
171
|
-
return shippedPresetsCache
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Read one base mode's shipped composition text. */
|
|
175
133
|
export function readBaseComposition(modeId) {
|
|
176
|
-
return
|
|
134
|
+
return readBaseCompositionText(modeId).text
|
|
177
135
|
}
|
|
178
136
|
|
|
179
137
|
/**
|
|
@@ -701,9 +659,11 @@ export function knownModuleNamesInjected() {
|
|
|
701
659
|
}
|
|
702
660
|
|
|
703
661
|
export function unresolvableRows(text) {
|
|
704
|
-
|
|
662
|
+
/** 可能装着生态包的那些 `node_modules`(等价于根,见下面的嵌套形态)。 */
|
|
663
|
+
let roots = []
|
|
705
664
|
let fromFilesystem = false
|
|
706
665
|
if (knownModuleNames === null) {
|
|
666
|
+
let root
|
|
707
667
|
try {
|
|
708
668
|
root = join(shippedPresetsDir(), '..', '..', '..')
|
|
709
669
|
fromFilesystem = true
|
|
@@ -713,6 +673,19 @@ export function unresolvableRows(text) {
|
|
|
713
673
|
// **判断不了就不要报警**:如果这个根下根本没有 node_modules(例如测试用的是一个临时出厂目录),
|
|
714
674
|
// 那么"查不到某个包"只说明我们不知道,不说明那行坏了。误报的代价是用户被引导去关掉本来正常的行。
|
|
715
675
|
if (existsSync(join(root, '@deepseek-ai')) === false) return []
|
|
676
|
+
roots = [root]
|
|
677
|
+
// ★ 依赖**嵌套**安装时(实测:npm 装 @deepseek-ai/dsh@0.1.7-rc.2,81 个生态包落在
|
|
678
|
+
// `node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/…` 而不是仓库根),只看根会把
|
|
679
|
+
// **每一行**都报成"本机装不了" —— 一个把健康预设说成 broken、并引导用户关掉正常行的假警报。
|
|
680
|
+
// 所以把根下每个包自己的 node_modules 也算进去。
|
|
681
|
+
try {
|
|
682
|
+
for (const entry of readdirSync(join(root, '@deepseek-ai'))) {
|
|
683
|
+
const nested = join(root, '@deepseek-ai', entry, 'node_modules')
|
|
684
|
+
if (existsSync(join(nested, '@deepseek-ai'))) roots.push(nested)
|
|
685
|
+
}
|
|
686
|
+
} catch {
|
|
687
|
+
/* 读不了目录就只用根,与从前一致 */
|
|
688
|
+
}
|
|
716
689
|
}
|
|
717
690
|
const out = []
|
|
718
691
|
const lines = text.split('\n')
|
|
@@ -752,7 +725,7 @@ export function unresolvableRows(text) {
|
|
|
752
725
|
// 注入了集合就用集合(新线:来自 compositionInventory 的 moduleName)——
|
|
753
726
|
// 子路径说明符(`@scope/pkg/sub`)与它的包名都算命中,否则会把健康行误报成坏行。
|
|
754
727
|
const present = fromFilesystem
|
|
755
|
-
? existsSync(join(
|
|
728
|
+
? roots.some((each) => existsSync(join(each, pkg)))
|
|
756
729
|
: knownModuleNames.has(name) || knownModuleNames.has(pkg)
|
|
757
730
|
if (present === false) out.push({ id: row[2], name })
|
|
758
731
|
}
|
package/index.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host half: the 「自定义模式」 settings page — an ASSISTANT MANAGER.
|
|
3
3
|
*
|
|
4
|
-
* It registers
|
|
5
|
-
* with `fetch`:
|
|
4
|
+
* It registers seven exact routes on the platform's shared `/api` channel; the browser half calls
|
|
5
|
+
* them with `fetch`:
|
|
6
6
|
*
|
|
7
7
|
* GET /api/custom-mode — every assistant this feature manages.
|
|
8
8
|
* GET /api/custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
|
|
9
|
+
* GET /api/custom-mode/history?id=…&n=… — one prompt revision (metadata comes with the state).
|
|
9
10
|
* POST /api/custom-mode/state — { id, mode, overrides, prompt, name, description }:
|
|
10
11
|
* validate, render a fresh `agent.cordis.yml`, write both files.
|
|
11
12
|
* POST /api/custom-mode/create — { name, description }: seed a new assistant from the
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
* POST /api/custom-mode/delete — { id }: remove a locally authored assistant.
|
|
14
15
|
* POST /api/custom-mode/reorder — { id, direction }: move one assistant up/down in the
|
|
15
16
|
* picker order by writing `order` into each `preset.yml`.
|
|
17
|
+
* POST /api/custom-mode/repair — { id }: turn off the rows this dsh line cannot resolve.
|
|
16
18
|
*
|
|
17
19
|
* **Why `/api` and not the raw `webServer` table**: the carrier that owns the `/api` channel applies the
|
|
18
20
|
* platform's trust and authentication policy — loopback/`trustedHosts` Host check, `Sec-Fetch-Site`,
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
* route answered 401; a plain form post needs no preflight, so CORS would not have helped either). The
|
|
25
27
|
* registered paths are absolute *including* `/api`, which is what the platform's own packages pass.
|
|
26
28
|
*
|
|
27
|
-
* **Why exact routes rather than one prefix**: the Fetch registry matches exact paths.
|
|
29
|
+
* **Why exact routes rather than one prefix**: the Fetch registry matches exact paths. Seven registrations
|
|
28
30
|
* cost nothing and each declares the methods it owns, so the method table doubles as the guarantee that a
|
|
29
31
|
* prefetched `GET …/create` cannot create anything — that method is simply not registered for that path.
|
|
30
32
|
*
|
|
@@ -53,15 +55,17 @@ import {
|
|
|
53
55
|
BASE_MODES,
|
|
54
56
|
collectRows,
|
|
55
57
|
disableRowsInPlace,
|
|
58
|
+
isBaseCompositionUnavailable,
|
|
56
59
|
modeOf,
|
|
57
60
|
overridesOf,
|
|
58
61
|
readBaseComposition,
|
|
59
62
|
setKnownModuleNames,
|
|
60
63
|
renderComposition,
|
|
64
|
+
setBaseCompositions,
|
|
61
65
|
setShippedPresetsDir,
|
|
62
66
|
unresolvableRows,
|
|
63
67
|
} from './composition.mjs'
|
|
64
|
-
import { readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
|
|
68
|
+
import { presetMetaText, readPresetMeta, writePresetMeta, presetMetaPath, PRESET_META_PATH } from './meta.mjs'
|
|
65
69
|
import { listHistory, readVersion, recordExternalChange, recordPrompt, HISTORY_SOURCE } from './journal.mjs'
|
|
66
70
|
import { packagedPresetDir, starterComposition } from './seed.mjs'
|
|
67
71
|
import {
|
|
@@ -303,13 +307,27 @@ export function readState(rows, id, options = {}) {
|
|
|
303
307
|
/* 审计留痕失败不影响读取 */
|
|
304
308
|
}
|
|
305
309
|
}
|
|
310
|
+
// 本机这条线给不给得出「出厂组成」?给不出来时**降级**而不是把整页打成 500:
|
|
311
|
+
// 提示词与名字照常可读可写,只有基础模式与插件开关不可编辑(0.1.7 上曾经整页不可用)。
|
|
312
|
+
let rowTree = []
|
|
313
|
+
let overrides = {}
|
|
314
|
+
let baseUnavailable = null
|
|
315
|
+
try {
|
|
316
|
+
rowTree = collectRows(text)
|
|
317
|
+
overrides = overridesOf(text, mode)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (!isBaseCompositionUnavailable(error)) throw error
|
|
320
|
+
baseUnavailable = { code: error.code, params: { mode, detail: describe(error) } }
|
|
321
|
+
}
|
|
306
322
|
return {
|
|
307
323
|
ok: true,
|
|
308
324
|
id,
|
|
309
325
|
mode,
|
|
310
326
|
modes: BASE_MODES,
|
|
311
|
-
rows:
|
|
312
|
-
overrides
|
|
327
|
+
rows: rowTree,
|
|
328
|
+
overrides,
|
|
329
|
+
// 页面据此把「基础模式 / 插件开关」两块显示成不可用,并把原因说成人话。
|
|
330
|
+
...baseUnavailable === null ? {} : { baseUnavailable },
|
|
313
331
|
prompt: prompt.ok === true ? prompt.text : '',
|
|
314
332
|
...prompt.ok === true ? {} : { promptError: prompt.error },
|
|
315
333
|
name: meta.name,
|
|
@@ -333,6 +351,8 @@ export function readState(rows, id, options = {}) {
|
|
|
333
351
|
...(existsSync(join(directory, 'approval-gate-missing')) ? ['approvalGateMissing'] : []),
|
|
334
352
|
// 本线无法解析的启用行 = 平台会把整个预设判为 broken 并从选择器里丢掉(曾经的 P0)。
|
|
335
353
|
...(unresolvableRows(text).length > 0 ? ['unresolvableRows'] : []),
|
|
354
|
+
// 出厂组成取不到:提示词能改,基础模式与插件开关不能。
|
|
355
|
+
...(baseUnavailable === null ? [] : ['baseCompositionUnavailable']),
|
|
336
356
|
],
|
|
337
357
|
}
|
|
338
358
|
}
|
|
@@ -373,6 +393,52 @@ function serializedWrite(key, work) {
|
|
|
373
393
|
return next
|
|
374
394
|
}
|
|
375
395
|
|
|
396
|
+
/**
|
|
397
|
+
* 降级保存:只写提示词与元数据,组成文件一字不动。
|
|
398
|
+
*
|
|
399
|
+
* 什么时候会走到这里:本机这条 dsh 线既没有旧线的 presets 目录,也没有可用的
|
|
400
|
+
* `readDocument()`(见 base-composition.mjs 的解析链)。此时「基础模式 / 插件开关」在页面上已经
|
|
401
|
+
* 不可编辑,而用户真正想保存的那段提示词没有任何理由跟着一起失败 —— 组成文件不动,模式在平台上
|
|
402
|
+
* 的行集合也就不变,这是所有选项里破坏性最小的一个。
|
|
403
|
+
*
|
|
404
|
+
* @param {string} directory - the assistant's preset directory.
|
|
405
|
+
* @param {{id: string, mode: string, prompt: string, name: string, description: string, displayName?: string}} input
|
|
406
|
+
* - the validated save; `name` decides whether `preset.yml` is written, `displayName` is what the response
|
|
407
|
+
* reports (the disk name when the request omitted one — issue #9).
|
|
408
|
+
* @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
|
|
409
|
+
*/
|
|
410
|
+
export function savePromptOnly(directory, { id, mode, prompt, name, description, displayName }) {
|
|
411
|
+
// 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
|
|
412
|
+
let metaText = null
|
|
413
|
+
if (name !== '') {
|
|
414
|
+
const rendered = presetMetaText(name, description, directory)
|
|
415
|
+
if (rendered.ok !== true) return rendered
|
|
416
|
+
metaText = rendered.text
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
writeAtomicPair([
|
|
420
|
+
[promptFile(directory), prompt],
|
|
421
|
+
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
422
|
+
])
|
|
423
|
+
} catch (error) {
|
|
424
|
+
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
|
|
428
|
+
} catch {
|
|
429
|
+
/* 审计记录失败不影响保存本身 */
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ok: true,
|
|
433
|
+
id,
|
|
434
|
+
mode,
|
|
435
|
+
code: 'savedPromptOnly',
|
|
436
|
+
// 响应里的名字用**磁盘上的那个**(issue #9):请求省略 name 时不该把 params.name 报成裸目录 id。
|
|
437
|
+
params: { name: (displayName === undefined || displayName === '' ? name : displayName) === '' ? id : (displayName === undefined || displayName === '' ? name : displayName), mode },
|
|
438
|
+
note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。新建会话即生效。',
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
376
442
|
export function saveState(rows, input) {
|
|
377
443
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
378
444
|
const directory = assistantDir(rows, id)
|
|
@@ -399,6 +465,12 @@ export function saveState(rows, input) {
|
|
|
399
465
|
}
|
|
400
466
|
// 请求里**没带** description 时保留原值:API 调用方只改提示词,不该顺手把描述清空(外部评审实测)。
|
|
401
467
|
const meta = readPresetMeta(directory)
|
|
468
|
+
// ★ 请求里没带 name(或页面清空)时,渲染组成仍要用**磁盘上那个名字**(issue #9)。
|
|
469
|
+
// 组成里 custom-prompt-tool 行的 config.modeName 是"会话内工具知道自己在改哪个助手"的唯一凭据;
|
|
470
|
+
// 用空串渲染会把它整块删掉,而 preset.yml 又(有意)保留旧名字 —— 两处就此不一致,
|
|
471
|
+
// 同时成功响应还会把 params.name 报成裸目录 id。请求**没带** name 与"用户把名字清空"在这里
|
|
472
|
+
// 是同一个语义:保持现状,而不是把它抹掉。
|
|
473
|
+
const effectiveName = name !== '' ? name : typeof meta.name === 'string' ? meta.name : ''
|
|
402
474
|
const rawDescription =
|
|
403
475
|
input !== null && typeof input === 'object' && typeof input.description === 'string'
|
|
404
476
|
? input.description
|
|
@@ -418,8 +490,23 @@ export function saveState(rows, input) {
|
|
|
418
490
|
|
|
419
491
|
let composition
|
|
420
492
|
try {
|
|
421
|
-
composition = renderComposition(mode, overrides, { modeName:
|
|
493
|
+
composition = renderComposition(mode, overrides, { modeName: effectiveName, assistantId: id })
|
|
422
494
|
} catch (error) {
|
|
495
|
+
// 拿不到出厂组成:这一页**降级**但不能全废 —— 提示词仍可单独保存(组成文件原样不动)。
|
|
496
|
+
// 页面在降级态下把基础模式与开关都设为不可编辑,所以开关集合必为空;HTTP API 直接调用
|
|
497
|
+
// 还想改开关时明确拒绝,而不是悄悄丢掉用户的意图。
|
|
498
|
+
if (isBaseCompositionUnavailable(error)) {
|
|
499
|
+
if (overrides.size === 0 && existsSync(compositionFile(directory))) {
|
|
500
|
+
return savePromptOnly(directory, { id, mode, prompt, name, description, displayName: effectiveName })
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
code: error.code,
|
|
505
|
+
params: { mode, detail: describe(error) },
|
|
506
|
+
error:
|
|
507
|
+
'本机取不到基础模式「' + mode + '」的出厂组成,无法改动插件开关或基础模式(提示词可以单独保存)。',
|
|
508
|
+
}
|
|
509
|
+
}
|
|
423
510
|
return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
|
|
424
511
|
}
|
|
425
512
|
|
|
@@ -432,12 +519,21 @@ export function saveState(rows, input) {
|
|
|
432
519
|
return { ok: false, code: 'selfCheckFailed', params: { detail: describe(error) }, error: '生成结果自检失败,已放弃写入:' + describe(error) }
|
|
433
520
|
}
|
|
434
521
|
|
|
522
|
+
// 三个文件必须一起换名(issue #5):组成、提示词、preset.yml。此前 preset.yml 是**单独**写的,
|
|
523
|
+
// 它失败时磁盘上已经是"新提示词 + 旧名字",而页面报"保存失败"—— 页面又不会重读,于是它永远停在
|
|
524
|
+
// 旧草稿上,用户以为没保存成功。现在三份先各自写进临时文件,再一起 rename;准备阶段失败则一个都不动。
|
|
525
|
+
// 名字为空时**不动** preset.yml:空的 name 标量会让这个模式在所有选择器里退化成裸目录 id。
|
|
526
|
+
let metaText = null
|
|
527
|
+
if (name !== '') {
|
|
528
|
+
const rendered = presetMetaText(name, description, directory)
|
|
529
|
+
if (rendered.ok !== true) return rendered
|
|
530
|
+
metaText = rendered.text
|
|
531
|
+
}
|
|
435
532
|
try {
|
|
436
|
-
// 组成文件与提示词必须一起更新:先都写进临时文件再一起换名,失败时不会留下"新组成 + 旧提示词"
|
|
437
|
-
// 这种混合状态(审阅指出原先两次独立原子写之间存在这个窗口)。
|
|
438
533
|
writeAtomicPair([
|
|
439
534
|
[compositionFile(directory), composition],
|
|
440
535
|
[promptFile(directory), prompt],
|
|
536
|
+
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
441
537
|
])
|
|
442
538
|
} catch (error) {
|
|
443
539
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
@@ -455,19 +551,12 @@ export function saveState(rows, input) {
|
|
|
455
551
|
/* 审计记录失败不影响保存本身 */
|
|
456
552
|
}
|
|
457
553
|
|
|
458
|
-
// A name the user cleared is left alone rather than written as an empty scalar:
|
|
459
|
-
// an empty `preset.yml` name is what makes a mode render as its bare id.
|
|
460
|
-
if (name !== '') {
|
|
461
|
-
const metaResult = writePresetMeta(name, description, directory)
|
|
462
|
-
if (metaResult.ok !== true) return metaResult
|
|
463
|
-
}
|
|
464
|
-
|
|
465
554
|
return {
|
|
466
555
|
ok: true,
|
|
467
556
|
id,
|
|
468
557
|
mode,
|
|
469
558
|
code: 'saved',
|
|
470
|
-
params: { name:
|
|
559
|
+
params: { name: effectiveName === '' ? id : effectiveName, mode },
|
|
471
560
|
note: '已保存(' + (name === '' ? id : name) + ',基础模式 ' + mode + ')。新建会话即生效,当前会话保持原配置。',
|
|
472
561
|
}
|
|
473
562
|
}
|
|
@@ -555,6 +644,14 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
555
644
|
// 与保存路径的 `renderFailed` 同一套:**每个**用户可见的结果都要带 `code`,否则页面无从本地化
|
|
556
645
|
// —— client.js 的 apiText 在没有 code 时直接退回下面这串中文,英文界面会在这一刻掉回中文
|
|
557
646
|
// (AGENTS.md 第 13 条)。这条曾经是宿主半唯一漏掉 code 的普通返回。
|
|
647
|
+
if (isBaseCompositionUnavailable(error)) {
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
code: error.code,
|
|
651
|
+
params: { mode: 'standard', detail: describe(error) },
|
|
652
|
+
error: '本机取不到出厂组成,暂时无法新建助手。',
|
|
653
|
+
}
|
|
654
|
+
}
|
|
558
655
|
return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
|
|
559
656
|
}
|
|
560
657
|
|
|
@@ -749,6 +846,9 @@ export function apply(ctx) {
|
|
|
749
846
|
['connection.fetch.register()', () => typeof scope.connection?.fetch?.register === 'function'],
|
|
750
847
|
]
|
|
751
848
|
const OPTIONAL_APIS = [
|
|
849
|
+
// 出厂组成的来源(新线):由宿主交出声明 YAML。缺了它插件仍能注册/同步,只是设置页里
|
|
850
|
+
// 「基础模式 / 插件开关」会降级为不可编辑(提示词照常)—— 所以它是可选能力。
|
|
851
|
+
['agentPresets.readDocument()', () => typeof scope.agentPresets?.readDocument === 'function'],
|
|
752
852
|
// 旧线的文件系统语义;新线由 preset-backend 的 declarative 后端用 disposer 补上。
|
|
753
853
|
['agentPresets.remove()', () => typeof scope.agentPresets?.remove === 'function'],
|
|
754
854
|
['agentPresets.copy()', () => typeof scope.agentPresets?.copy === 'function'],
|
|
@@ -785,7 +885,7 @@ export function apply(ctx) {
|
|
|
785
885
|
let shippedReady = null
|
|
786
886
|
const ensureShipped = () => {
|
|
787
887
|
if (shippedReady === null) {
|
|
788
|
-
|
|
888
|
+
const attempt = (async () => {
|
|
789
889
|
try {
|
|
790
890
|
const rows = await scope.agentPresets.list()
|
|
791
891
|
const system = rows.find((row) => row.trust === 'system' && typeof row.path === 'string')
|
|
@@ -795,6 +895,10 @@ export function apply(ctx) {
|
|
|
795
895
|
console.error('custom-mode: 无法从 roster 解析出厂预设目录:' + describe(error))
|
|
796
896
|
}
|
|
797
897
|
})()
|
|
898
|
+
// 失败不缓存:旧线之外这条路本就可能没有 system 行,但一次**抛错**不该变成
|
|
899
|
+
// "整个进程生命周期内都不再解析"。
|
|
900
|
+
attempt.catch(() => { shippedReady = null })
|
|
901
|
+
shippedReady = attempt
|
|
798
902
|
}
|
|
799
903
|
return shippedReady
|
|
800
904
|
}
|
|
@@ -853,6 +957,28 @@ export function apply(ctx) {
|
|
|
853
957
|
console.error('custom-mode: 读取 compositionInventory 失败(回退文件系统判定): ' + describe(error))
|
|
854
958
|
}
|
|
855
959
|
}
|
|
960
|
+
// 出厂组成:新线上唯一的来源是宿主自己(readDocument)。取到就交给 composer;取不到就交给
|
|
961
|
+
// 降级路径。两种情况都不阻塞下面的注册表同步 —— 模式可用优先于设置页完整。
|
|
962
|
+
if (declarative !== null) {
|
|
963
|
+
try {
|
|
964
|
+
const bases = await declarative.fetchBaseCompositions(BASE_MODES.map((mode) => mode.id))
|
|
965
|
+
setBaseCompositions(bases.fetched)
|
|
966
|
+
if (bases.fetched.size > 0) {
|
|
967
|
+
console.log(
|
|
968
|
+
'custom-mode: 已取回 ' + String(bases.fetched.size) + ' 个基础模式的出厂组成(' +
|
|
969
|
+
[...bases.fetched.keys()].join('、') + ')',
|
|
970
|
+
)
|
|
971
|
+
}
|
|
972
|
+
if (bases.problems.length > 0) {
|
|
973
|
+
console.error(
|
|
974
|
+
'custom-mode: 取不到这些基础模式的出厂组成 —— ' + bases.problems.join(';') +
|
|
975
|
+
'。对应模式仍可编辑提示词,基础模式与插件开关会显示为不可用。',
|
|
976
|
+
)
|
|
977
|
+
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
console.error('custom-mode: 取回出厂组成时出错(已忽略): ' + describe(error))
|
|
980
|
+
}
|
|
981
|
+
}
|
|
856
982
|
if (declarative === null) return
|
|
857
983
|
try {
|
|
858
984
|
const rows = await rawRoster()
|
package/locales.mjs
CHANGED
|
@@ -60,6 +60,8 @@ export const zh = {
|
|
|
60
60
|
'assistant.short': '每个助手是一个独立模式:自己的系统提示词、基础模式与插件开关。',
|
|
61
61
|
'name.short': '改名只影响显示,内部标识与已有会话不受影响。',
|
|
62
62
|
'mode.short': '底子决定「行集合」与工具能力;persona 行始终由本模式替换。',
|
|
63
|
+
'mode.unavailable': '本机这条 DSH 线没有可读的出厂组成,基础模式暂时不能切换。',
|
|
64
|
+
'rows.unavailable': '出厂组成取不到,插件开关暂时不能编辑;磁盘上原有的行状态仍然生效。',
|
|
63
65
|
'mode.pendingRows': '底子已改为「{mode}」:保存后,下面的行列表会按新底子重算。',
|
|
64
66
|
'rows.short': '逐行控制挂载哪些插件;没拨过的行保持官方默认。',
|
|
65
67
|
'prompt.short': '这段文本就是本模式的系统提示词,保存后下一步生效。',
|
|
@@ -109,8 +111,11 @@ export const zh = {
|
|
|
109
111
|
'api.deleteFailed': '删除失败:{detail}',
|
|
110
112
|
'api.versionMissing': '找不到这个版本(历史可能已被上限裁剪)。',
|
|
111
113
|
'api.badJson': '请求体不是合法 JSON',
|
|
114
|
+
'api.savedPromptOnly': '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。',
|
|
115
|
+
'api.baseCompositionUnavailable': '本机取不到基础模式「{mode}」的出厂组成,无法改动插件开关或基础模式(系统提示词可以单独保存)。',
|
|
112
116
|
'warn.approvalGateMissing': '审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。',
|
|
113
117
|
'warn.unresolvableRows': '有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。',
|
|
118
|
+
'warn.baseCompositionUnavailable': '取不到本机这条 DSH 线的出厂组成:基础模式与插件开关暂时不能改(系统提示词不受影响,照常可保存)。已尝试的途径写在宿主日志里。',
|
|
114
119
|
'warn.approvalGateMissing.label': '审批闸门未启用',
|
|
115
120
|
'warn.approvalGateMissing.hint': '这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。',
|
|
116
121
|
'warn.personaOffWithPrompt': '「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。',
|
|
@@ -286,6 +291,8 @@ export const en = {
|
|
|
286
291
|
'assistant.short': 'Each assistant is its own mode: its own system prompt, base mode and plugin switches.',
|
|
287
292
|
'name.short': 'Renaming only changes what is displayed — not the internal id or existing sessions.',
|
|
288
293
|
'mode.short': 'The base decides the row set and tool abilities; the persona row is always replaced by this mode.',
|
|
294
|
+
'mode.unavailable': 'This DSH line exposes no readable shipped composition, so the base mode cannot be switched right now.',
|
|
295
|
+
'rows.unavailable': 'No shipped composition is available, so the plugin switches cannot be edited right now; the row states already on disk still apply.',
|
|
289
296
|
'mode.pendingRows': 'Base changed to {mode}: the row list below is recomputed from the new base when you save.',
|
|
290
297
|
'rows.short': 'Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.',
|
|
291
298
|
'prompt.short': 'Saving this text makes it the system prompt of this mode, and it takes effect on the next step.',
|
|
@@ -335,6 +342,8 @@ export const en = {
|
|
|
335
342
|
'api.deleteFailed': 'Delete failed: {detail}',
|
|
336
343
|
'api.versionMissing': 'That version is gone (the history is capped).',
|
|
337
344
|
'api.badJson': 'The request body is not valid JSON',
|
|
345
|
+
'api.savedPromptOnly': 'System prompt saved (this machine exposes no shipped composition, so the plugin switches and the base mode were left untouched).',
|
|
346
|
+
'api.baseCompositionUnavailable': 'No shipped composition for base mode "{mode}" on this machine, so its plugin switches and base mode cannot be changed (the system prompt can still be saved on its own).',
|
|
338
347
|
'warn.personaOffWithPrompt': 'The "Identity (system prompt)" row is off, so prompt.md is never injected — the prompt you wrote has no effect. Turn the row on, or clear the prompt.',
|
|
339
348
|
'warn.toolOff': 'The "custom_prompt tool" row is off: the agent cannot change the prompt from inside a session, only this page can.',
|
|
340
349
|
'warn.noDescription': 'No description: the new-session mode picker will show it as "no description yet".',
|
|
@@ -343,6 +352,7 @@ export const en = {
|
|
|
343
352
|
'warn.approvalGateMissing.label': 'Approval gate is off',
|
|
344
353
|
'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.',
|
|
345
354
|
'warn.noName': 'No name: the mode picker will show the directory id (e.g. custom).',
|
|
355
|
+
'warn.baseCompositionUnavailable': 'This DSH line exposes no shipped composition: the base mode and the plugin switches cannot be changed right now (the system prompt is unaffected and still saves). The routes that were tried are in the host log.',
|
|
346
356
|
'history.label': 'Change history',
|
|
347
357
|
'history.pick': 'Pick a version to load…',
|
|
348
358
|
'history.load': 'Load this version',
|
package/meta.mjs
CHANGED
|
@@ -95,19 +95,21 @@ function yamlScalar(value) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
*
|
|
98
|
+
* Render the exact bytes `writePresetMeta` would write, without writing them.
|
|
99
99
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* Why it is exported: the settings page writes the composition, the prompt **and** this file, and a review
|
|
101
|
+
* found the third one landing separately — so a failure while writing `preset.yml` left a new prompt with
|
|
102
|
+
* the old name on disk while the page reported "save failed" (issue #5). All three now go through one
|
|
103
|
+
* staged atomic write; that requires the text, not a second writer.
|
|
102
104
|
*
|
|
103
|
-
* @param {string} name - display name.
|
|
105
|
+
* @param {string} name - display name (required).
|
|
104
106
|
* @param {string|undefined} description - optional description.
|
|
105
|
-
* @param {string}
|
|
106
|
-
* @param {{order?: number}} [options] - roster position.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
107
|
+
* @param {string} directory - the preset directory (for the existing `order`).
|
|
108
|
+
* @param {{order?: number}} [options] - explicit roster position.
|
|
109
|
+
* @returns {{ok: true, text: string, name: string} | {ok: false, code: string, error: string}} the bytes and the
|
|
110
|
+
* normalized name, or why there are none.
|
|
109
111
|
*/
|
|
110
|
-
export function
|
|
112
|
+
export function presetMetaText(name, description, directory = PRESET_DIR, options = {}) {
|
|
111
113
|
const cleanName = typeof name === 'string' ? name.replace(/\r?\n/g, ' ').trim() : ''
|
|
112
114
|
if (cleanName === '') return { ok: false, code: 'nameRequired', error: '模式名称不能为空。' }
|
|
113
115
|
const lines = ['name: ' + yamlScalar(cleanName)]
|
|
@@ -118,10 +120,29 @@ export function writePresetMeta(name, description, directory = PRESET_DIR, optio
|
|
|
118
120
|
const order =
|
|
119
121
|
typeof requested === 'number' && Number.isFinite(requested) ? Math.trunc(requested) : readPresetMeta(directory).order
|
|
120
122
|
if (order !== undefined) lines.push('order: ' + String(order))
|
|
123
|
+
return { ok: true, text: lines.join('\n') + '\n', name: cleanName }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write `preset.yml` with a display name and optional description.
|
|
128
|
+
*
|
|
129
|
+
* The name is required: an empty one would render the mode as its bare directory
|
|
130
|
+
* id everywhere.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} name - display name.
|
|
133
|
+
* @param {string|undefined} description - optional description.
|
|
134
|
+
* @param {string} [directory] - the preset directory (defaults to the legacy `custom`).
|
|
135
|
+
* @param {{order?: number}} [options] - roster position. Omitted means "keep whatever is on
|
|
136
|
+
* disk": a rename must never reshuffle the pickers.
|
|
137
|
+
* @returns {{ok: true, name: string} | {ok: false, error: string}} the outcome.
|
|
138
|
+
*/
|
|
139
|
+
export function writePresetMeta(name, description, directory = PRESET_DIR, options = {}) {
|
|
140
|
+
const rendered = presetMetaText(name, description, directory, options)
|
|
141
|
+
if (rendered.ok !== true) return rendered
|
|
121
142
|
try {
|
|
122
143
|
// **非原子写的最坏后果在这里**:preset.yml 写坏 = 这个模式从所有选择器里消失(见本文件头注释)。
|
|
123
144
|
// 实测审阅指出这里原先用的是裸 writeFileSync,与设置页的纪律不一致;现在共用同一份实现。
|
|
124
|
-
writeAtomic(presetMetaPath(directory),
|
|
145
|
+
writeAtomic(presetMetaPath(directory), rendered.text)
|
|
125
146
|
} catch (error) {
|
|
126
147
|
// 带 `code`:这条是保存路径上最后一个没有 code 的用户可见失败,英文界面此前会在这里显示中文。
|
|
127
148
|
// 它发生得尤其难受 —— 组成文件与提示词**已经写成功**了,只有 preset.yml 没写成,所以页面
|
|
@@ -129,5 +150,5 @@ export function writePresetMeta(name, description, directory = PRESET_DIR, optio
|
|
|
129
150
|
const detail = String((error && error.message) || error)
|
|
130
151
|
return { ok: false, code: 'metaWriteFailed', params: { detail }, error: '写入 preset.yml 失败:' + detail }
|
|
131
152
|
}
|
|
132
|
-
return { ok: true, name:
|
|
153
|
+
return { ok: true, name: rendered.name }
|
|
133
154
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.14",
|
|
4
4
|
"description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), pick its base mode, switch plugin rows one by one, and keep several assistants side by side — coding, chat or roleplay (RP) personas (multi-mode / multi-agent).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,11 +53,13 @@
|
|
|
53
53
|
"atomic.mjs",
|
|
54
54
|
"client.js",
|
|
55
55
|
"composition.mjs",
|
|
56
|
+
"base-composition.mjs",
|
|
56
57
|
"meta.mjs",
|
|
57
58
|
"paths.mjs",
|
|
58
59
|
"locales.mjs",
|
|
59
60
|
"journal.mjs",
|
|
60
61
|
"cordis.patch.yml",
|
|
62
|
+
"LICENSE",
|
|
61
63
|
"preset-backend/",
|
|
62
64
|
"preset/"
|
|
63
65
|
],
|
|
@@ -163,6 +163,41 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
163
163
|
for (const id of [...mounted.keys()]) await unmount(id)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Fetch the shipped base compositions the host declares, as entry-list YAML.
|
|
168
|
+
*
|
|
169
|
+
* `readDocument()` is the 0.1.7 replacement for the presets directory the composer used to read:
|
|
170
|
+
* it returns the declaration **including** the `isolate` groups and `!!js` predicates that
|
|
171
|
+
* `compositionInventory()` flattens away (measured 2026-09-25 on 0.1.7-rc.2:
|
|
172
|
+
* `readDocument('standard').content` is the same row list, verbatim YAML).
|
|
173
|
+
*
|
|
174
|
+
* Missing API, or a mode that fails to read, is **not** fatal: the host half falls back to the
|
|
175
|
+
* packaged patch files and, if those are missing too, to a typed "unavailable" state in which the
|
|
176
|
+
* prompt stays editable. So this never throws — it reports.
|
|
177
|
+
*
|
|
178
|
+
* @param {Iterable<string>} modeIds - the base modes to fetch.
|
|
179
|
+
* @returns {{ok: boolean, fetched: Map<string,string>, problems: string[]}}
|
|
180
|
+
*/
|
|
181
|
+
async function fetchBaseCompositions(modeIds) {
|
|
182
|
+
const fetched = new Map()
|
|
183
|
+
const problems = []
|
|
184
|
+
if (typeof scope.agentPresets?.readDocument !== 'function') {
|
|
185
|
+
return { ok: false, fetched, problems: ['agentPresets.readDocument() 不可用'] }
|
|
186
|
+
}
|
|
187
|
+
for (const id of modeIds) {
|
|
188
|
+
try {
|
|
189
|
+
const document = await scope.agentPresets.readDocument(id)
|
|
190
|
+
const text = document !== null && typeof document === 'object' ? document.content : undefined
|
|
191
|
+
if (typeof text !== 'string' || text.trim() === '') problems.push(`${id}: 声明为空`)
|
|
192
|
+
else fetched.set(id, text)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// 不为某一条基础模式的成功而重试整轮:读不到的交给降级路径,并指名它。
|
|
195
|
+
problems.push(`${id}: ${describe(error)}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { ok: problems.length === 0, fetched, problems }
|
|
199
|
+
}
|
|
200
|
+
|
|
166
201
|
return {
|
|
167
202
|
id: BACKEND_ID,
|
|
168
203
|
/** 新线不把 preset 落到磁盘,注册表就是唯一真相。 */
|
|
@@ -171,6 +206,7 @@ export function createDeclarativeBackend({ scope, log = console.log, warn = cons
|
|
|
171
206
|
mountOne,
|
|
172
207
|
unmount,
|
|
173
208
|
disposeAll,
|
|
209
|
+
fetchBaseCompositions,
|
|
174
210
|
mountedIds: () => [...mounted.keys()],
|
|
175
211
|
}
|
|
176
212
|
}
|