dsh-custom-mode 1.9.12 → 1.9.13
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/base-composition.mjs +419 -0
- package/client.js +47 -26
- package/composition.mjs +47 -74
- package/index.mjs +133 -16
- 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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BOWLUNA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a base mode's composition text comes from — **the third thing the two dsh lines disagree about**.
|
|
3
|
+
*
|
|
4
|
+
* The settings page composes a mode by editing a *shipped* base composition (base mode + row switches).
|
|
5
|
+
* That text has to come from somewhere, and the two dsh lines keep it in different places:
|
|
6
|
+
*
|
|
7
|
+
* | line | where the shipped composition lives |
|
|
8
|
+
* | --- | --- |
|
|
9
|
+
* | 0.1.2 … 0.1.6 | `@deepseek-ai/dsh-agent-presets/presets/<mode>/agent.cordis.yml` (a package that **no longer exists** from 0.1.7) |
|
|
10
|
+
* | 0.1.7 + | inside the host: `agentPresets.readDocument(<mode>).content` — the declared entry-list YAML |
|
|
11
|
+
*
|
|
12
|
+
* At 1.9.12 only the first route existed, so on 0.1.7 every read of a base composition threw
|
|
13
|
+
* (`无法定位出厂基础模式`) and the settings page answered **500**: no rows, no switches, and — worse —
|
|
14
|
+
* the prompt editor never rendered. That is the P0 an adversarial review reproduced on a stock
|
|
15
|
+
* `0.1.7-rc.2` instance (the exact line the official desktop app bundles).
|
|
16
|
+
*
|
|
17
|
+
* ## Resolution order (highest first)
|
|
18
|
+
*
|
|
19
|
+
* 1. `DSH_SHIPPED_PRESETS_DIR` — explicit override, **exclusive**: when set, nothing else is consulted.
|
|
20
|
+
* Tests use it, and an operator who points it somewhere gets exactly that.
|
|
21
|
+
* 2. the text the host handed over (`setBaseCompositions`, filled from `agentPresets.readDocument`).
|
|
22
|
+
* 3. `setShippedPresetsDir`, then Node/profile discovery of the legacy presets package.
|
|
23
|
+
* 4. `DSH_PRESET_PATCH_DIR`, then discovery of `@deepseek-ai/dsh-web-app/presets/<mode>.patch.yml`
|
|
24
|
+
* — the same declaration in its packaged form, for a host whose `readDocument` is unavailable.
|
|
25
|
+
* 5. nothing left → {@link BaseCompositionUnavailableError}, which callers turn into a **visible,
|
|
26
|
+
* typed** state instead of a 500.
|
|
27
|
+
*
|
|
28
|
+
* Why `readDocument` and not `compositionInventory()`: the inventory is **flattened**
|
|
29
|
+
* (`entryId`/`moduleName`/`enabled`/`condition`), so the `isolate` realms a service row must sit inside
|
|
30
|
+
* are gone (measured 2026-09-25; `parse-composition.mjs` rules the same route out for the same reason).
|
|
31
|
+
* `readDocument` returns the declaration as YAML **including** those groups and `!!js` expressions.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
35
|
+
import { createRequire } from 'node:module'
|
|
36
|
+
import { dirname, join } from 'node:path'
|
|
37
|
+
import { fileURLToPath } from 'node:url'
|
|
38
|
+
|
|
39
|
+
import { dshHome } from './paths.mjs'
|
|
40
|
+
|
|
41
|
+
/** Explicit override for the legacy file layout (a directory holding `<mode>/agent.cordis.yml`). */
|
|
42
|
+
export const LEGACY_DIR_ENV = 'DSH_SHIPPED_PRESETS_DIR'
|
|
43
|
+
|
|
44
|
+
/** Explicit override for the declarative file layout (a directory holding `<mode>.patch.yml`). */
|
|
45
|
+
export const PATCH_DIR_ENV = 'DSH_PRESET_PATCH_DIR'
|
|
46
|
+
|
|
47
|
+
/** The base modes a user may build on. Kept here so discovery has no opinion about the UI. */
|
|
48
|
+
export const BASE_MODE_IDS = ['standard', 'ptc', 'minimal', 'cordis']
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Thrown when no route yields a base composition.
|
|
52
|
+
*
|
|
53
|
+
* Carries `code` so the HTTP layer can answer with a typed, localizable result, and `attempts` so the
|
|
54
|
+
* operator sees *which* places were tried instead of a bare failure.
|
|
55
|
+
*/
|
|
56
|
+
export class BaseCompositionUnavailableError extends Error {
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} modeId - the base mode that could not be resolved.
|
|
59
|
+
* @param {string[]} attempts - one line per route tried, in order.
|
|
60
|
+
*/
|
|
61
|
+
constructor(modeId, attempts) {
|
|
62
|
+
super(
|
|
63
|
+
'无法取得基础模式「' + modeId + '」的出厂组成。已尝试:' +
|
|
64
|
+
(attempts.length > 0 ? attempts.join(';') : '(无可用途径)'),
|
|
65
|
+
)
|
|
66
|
+
this.name = 'BaseCompositionUnavailableError'
|
|
67
|
+
this.code = 'baseCompositionUnavailable'
|
|
68
|
+
this.modeId = modeId
|
|
69
|
+
this.attempts = attempts
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether a value is the typed "no base composition anywhere" failure. */
|
|
74
|
+
export function isBaseCompositionUnavailable(error) {
|
|
75
|
+
return error !== null && typeof error === 'object' && error.code === 'baseCompositionUnavailable'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read one candidate file, reporting a miss instead of throwing. */
|
|
79
|
+
function tryReadFile(file, attempts, label) {
|
|
80
|
+
try {
|
|
81
|
+
const text = readFileSync(file, 'utf8')
|
|
82
|
+
if (typeof text === 'string' && text.trim() !== '') return text
|
|
83
|
+
attempts.push(label + ':文件为空')
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const code = error !== null && typeof error === 'object' ? error.code : undefined
|
|
86
|
+
attempts.push(label + ':' + (code === 'ENOENT' ? '不存在' : String((error && error.message) || error)))
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── route 1 / 3: the legacy file layout ───────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Directory injected by the host half (resolved from the roster's system row). */
|
|
94
|
+
let injectedLegacyDir
|
|
95
|
+
|
|
96
|
+
/** Point the resolver at a legacy presets directory discovered at runtime. */
|
|
97
|
+
export function setShippedPresetsDir(dir) {
|
|
98
|
+
if (typeof dir === 'string' && dir !== '') injectedLegacyDir = dir
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Discovery result, cached **only when it succeeded** (a cold miss must stay retryable). */
|
|
102
|
+
let legacyCache
|
|
103
|
+
|
|
104
|
+
/** Resolve `@deepseek-ai/dsh-agent-presets` the two ways that ever worked on the legacy line. */
|
|
105
|
+
function discoverLegacyPresetsDir() {
|
|
106
|
+
const require = createRequire(import.meta.url)
|
|
107
|
+
try {
|
|
108
|
+
const manifest = require.resolve('@deepseek-ai/dsh-agent-presets/package.json')
|
|
109
|
+
return join(dirname(manifest), 'presets')
|
|
110
|
+
} catch {
|
|
111
|
+
/* fall through */
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const fromProfile = createRequire(join(dshHome(), 'profiles', 'package.json'))
|
|
115
|
+
const manifest = fromProfile.resolve('@deepseek-ai/dsh-agent-presets/package.json')
|
|
116
|
+
return join(dirname(manifest), 'presets')
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through */
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
'无法定位出厂基础模式(@deepseek-ai/dsh-agent-presets 是否已安装?)。' +
|
|
122
|
+
'0.1.7 起该包不再发布,那条线由 agentPresets.readDocument() 提供同一份声明。',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The legacy presets directory.
|
|
128
|
+
*
|
|
129
|
+
* `DSH_SHIPPED_PRESETS_DIR` is **exclusive**: an explicit override must not silently fall back to
|
|
130
|
+
* whatever else happens to be installed on the machine (that is how a test would stop testing).
|
|
131
|
+
*
|
|
132
|
+
* @returns {string} absolute directory.
|
|
133
|
+
*/
|
|
134
|
+
export function shippedPresetsDir() {
|
|
135
|
+
const override = process.env[LEGACY_DIR_ENV]
|
|
136
|
+
if (typeof override === 'string' && override !== '') return override
|
|
137
|
+
if (injectedLegacyDir !== undefined) return injectedLegacyDir
|
|
138
|
+
if (legacyCache === undefined) legacyCache = discoverLegacyPresetsDir()
|
|
139
|
+
return legacyCache
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Absolute path of one mode's shipped composition in the legacy layout. */
|
|
143
|
+
export function baseCompositionPath(modeId, directory = shippedPresetsDir()) {
|
|
144
|
+
return join(directory, modeId, 'agent.cordis.yml')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── route 2: the text was handed over by the host (dsh ≥ 0.1.7) ───────────────
|
|
148
|
+
|
|
149
|
+
/** `id → composition text`, as fetched from `agentPresets.readDocument()`. */
|
|
150
|
+
let injectedCompositions
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Hand over the base compositions the host declared.
|
|
154
|
+
*
|
|
155
|
+
* Called on every registry sync, so a host that gains or changes a preset is picked up without a restart.
|
|
156
|
+
* An empty/failed fetch clears the map rather than keeping a stale one — the caller logs the reason.
|
|
157
|
+
*
|
|
158
|
+
* @param {Map<string, string>|Record<string, string>|null} [compositions] - `id → entry-list YAML`.
|
|
159
|
+
*/
|
|
160
|
+
export function setBaseCompositions(compositions) {
|
|
161
|
+
if (compositions === null || compositions === undefined) {
|
|
162
|
+
injectedCompositions = undefined
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
const map = compositions instanceof Map ? compositions : new Map(Object.entries(compositions))
|
|
166
|
+
const clean = new Map()
|
|
167
|
+
for (const [id, text] of map) {
|
|
168
|
+
if (typeof id === 'string' && typeof text === 'string' && text.trim() !== '') clean.set(id, text)
|
|
169
|
+
}
|
|
170
|
+
injectedCompositions = clean.size > 0 ? clean : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The host-provided text for one mode, when there is one. */
|
|
174
|
+
export function injectedBaseComposition(modeId) {
|
|
175
|
+
return injectedCompositions === undefined ? undefined : injectedCompositions.get(modeId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Which modes the host handed over — for the startup log and for tests. */
|
|
179
|
+
export function injectedBaseCompositionIds() {
|
|
180
|
+
return injectedCompositions === undefined ? [] : [...injectedCompositions.keys()]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── route 4: the declarative declaration in its packaged form ─────────────────
|
|
184
|
+
|
|
185
|
+
/** Directory injected for the patch-file layout. */
|
|
186
|
+
let injectedPatchDir
|
|
187
|
+
|
|
188
|
+
/** Point the resolver at a directory holding `<mode>.patch.yml`. */
|
|
189
|
+
export function setPatchPresetsDir(dir) {
|
|
190
|
+
if (typeof dir === 'string' && dir !== '') injectedPatchDir = dir
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Discovery result, cached only on success. */
|
|
194
|
+
let patchCache
|
|
195
|
+
|
|
196
|
+
/** `{ prefix, kind }` candidates for a global npm install of dsh, from this process' own node. */
|
|
197
|
+
function executablePrefixes() {
|
|
198
|
+
const out = []
|
|
199
|
+
const dir = dirname(process.execPath)
|
|
200
|
+
// POSIX global install: <prefix>/lib/node_modules/@deepseek-ai/dsh
|
|
201
|
+
out.push(join(dir, '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
202
|
+
// Windows global install: <prefix>/node_modules/@deepseek-ai/dsh
|
|
203
|
+
out.push(join(dir, 'node_modules', '@deepseek-ai', 'dsh'))
|
|
204
|
+
// A node bundled inside an app (desktop shells): keep the app's own tree reachable.
|
|
205
|
+
out.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `dsh` on PATH → its install root, so a non-standard prefix still resolves. */
|
|
210
|
+
function pathDiscoveredDshRoots() {
|
|
211
|
+
const out = []
|
|
212
|
+
const entries = String(process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (entry === '') continue
|
|
215
|
+
for (const name of process.platform === 'win32' ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh']) {
|
|
216
|
+
const candidate = join(entry, name)
|
|
217
|
+
if (!existsSync(candidate)) continue
|
|
218
|
+
out.push(join(entry, '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
219
|
+
out.push(join(entry, '..', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Where `@deepseek-ai/dsh-web-app` ships `presets/<mode>.patch.yml`.
|
|
227
|
+
*
|
|
228
|
+
* Every candidate is verified by looking for a shipped base patch, so a wrong guess costs a stat call
|
|
229
|
+
* instead of a wrong answer.
|
|
230
|
+
*
|
|
231
|
+
* @returns {string|undefined} the presets directory, or undefined when nothing matched.
|
|
232
|
+
*/
|
|
233
|
+
export function discoverPatchPresetsDir() {
|
|
234
|
+
const candidates = []
|
|
235
|
+
// (a) Node resolution from this module: hits when the plugin sits in a tree that can see the host
|
|
236
|
+
// (a source checkout with the host installed beside it, a flat profile, CI).
|
|
237
|
+
for (const spec of ['@deepseek-ai/dsh-web-app/package.json', '@deepseek-ai/dsh/package.json']) {
|
|
238
|
+
try {
|
|
239
|
+
candidates.push(dirname(createRequire(import.meta.url).resolve(spec)))
|
|
240
|
+
} catch {
|
|
241
|
+
/* fall through */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// (b) Walk up from this module's own directory — the same shape, for trees Node's resolver skips.
|
|
245
|
+
{
|
|
246
|
+
let here = dirname(fileURLToPath(import.meta.url))
|
|
247
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
248
|
+
candidates.push(join(here, 'node_modules', '@deepseek-ai', 'dsh-web-app'))
|
|
249
|
+
candidates.push(join(here, 'node_modules', '@deepseek-ai', 'dsh'))
|
|
250
|
+
here = dirname(here)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// (c) The host's own module tree, reachable from the DSH home when the profile has real deps.
|
|
254
|
+
try {
|
|
255
|
+
const fromProfile = createRequire(join(dshHome(), 'profiles', 'package.json'))
|
|
256
|
+
candidates.push(dirname(fromProfile.resolve('@deepseek-ai/dsh-web-app/package.json')))
|
|
257
|
+
candidates.push(dirname(fromProfile.resolve('@deepseek-ai/dsh/package.json')))
|
|
258
|
+
} catch {
|
|
259
|
+
/* fall through */
|
|
260
|
+
}
|
|
261
|
+
for (const root of [...executablePrefixes(), ...pathDiscoveredDshRoots()]) {
|
|
262
|
+
candidates.push(join(root, 'node_modules', '@deepseek-ai', 'dsh-web-app'))
|
|
263
|
+
candidates.push(root)
|
|
264
|
+
}
|
|
265
|
+
for (const candidate of candidates) {
|
|
266
|
+
const presets = candidate.endsWith('dsh-web-app') ? join(candidate, 'presets') : join(candidate, 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets')
|
|
267
|
+
if (BASE_MODE_IDS.some((mode) => existsSync(join(presets, mode + '.patch.yml')))) return presets
|
|
268
|
+
}
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The patch-file presets directory.
|
|
274
|
+
*
|
|
275
|
+
* `DSH_PRESET_PATCH_DIR` is exclusive for the same reason as its legacy twin.
|
|
276
|
+
*
|
|
277
|
+
* @returns {string|undefined} undefined when no patch layout is present (not an error by itself).
|
|
278
|
+
*/
|
|
279
|
+
export function patchPresetsDir() {
|
|
280
|
+
const override = process.env[PATCH_DIR_ENV]
|
|
281
|
+
if (typeof override === 'string' && override !== '') return override
|
|
282
|
+
if (injectedPatchDir !== undefined) return injectedPatchDir
|
|
283
|
+
if (patchCache === undefined) {
|
|
284
|
+
const found = discoverPatchPresetsDir()
|
|
285
|
+
if (found !== undefined) patchCache = found
|
|
286
|
+
return found
|
|
287
|
+
}
|
|
288
|
+
return patchCache
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Lift the `plugins:` sequence out of a declarative preset patch.
|
|
293
|
+
*
|
|
294
|
+
* The patch (`@deepseek-ai/dsh-web-app/presets/<mode>.patch.yml`) wraps the same row list this feature
|
|
295
|
+
* composes against:
|
|
296
|
+
*
|
|
297
|
+
* - insert:
|
|
298
|
+
* - id: preset-standard
|
|
299
|
+
* name: '@deepseek-ai/dsh-agent-preset'
|
|
300
|
+
* config:
|
|
301
|
+
* id: standard
|
|
302
|
+
* plugins:
|
|
303
|
+
* - id: persona
|
|
304
|
+
* ...
|
|
305
|
+
*
|
|
306
|
+
* Text surgery, not a YAML round-trip — exactly like the composer it feeds: dedenting the block keeps
|
|
307
|
+
* comments, quoting and `!!js` byte-for-byte, and the result is already in the shape
|
|
308
|
+
* `splitSegments()`/`collectRows()` expect (top-level rows at column 0, nested rows at four spaces).
|
|
309
|
+
*
|
|
310
|
+
* @param {string} text - the patch file's contents.
|
|
311
|
+
* @returns {string|null} entry-list YAML, or null when the file carries no `plugins:` block.
|
|
312
|
+
*/
|
|
313
|
+
export function extractPluginsBlock(text) {
|
|
314
|
+
if (typeof text !== 'string' || text === '') return null
|
|
315
|
+
const lines = text.split('\n')
|
|
316
|
+
const start = lines.findIndex((line) => /^\s*plugins:\s*$/.test(line))
|
|
317
|
+
if (start === -1) return null
|
|
318
|
+
const base = lines[start].search(/\S/)
|
|
319
|
+
const out = []
|
|
320
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
321
|
+
const line = lines[index]
|
|
322
|
+
if (line.trim() === '') {
|
|
323
|
+
out.push('')
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
const indent = line.search(/\S/)
|
|
327
|
+
if (indent <= base) break
|
|
328
|
+
out.push(line.slice(Math.min(base + 2, indent)))
|
|
329
|
+
}
|
|
330
|
+
while (out.length > 0 && out[0].trim() === '') out.shift()
|
|
331
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
|
|
332
|
+
return out.length === 0 ? null : out.join('\n')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Absolute path of one mode's shipped patch, in the declarative layout. */
|
|
336
|
+
export function basePatchPath(modeId, directory = patchPresetsDir()) {
|
|
337
|
+
return directory === undefined ? undefined : join(directory, modeId + '.patch.yml')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── the chain ────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Read one base mode's shipped composition, from whichever route this installation has.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} modeId - one of {@link BASE_MODE_IDS}.
|
|
346
|
+
* @returns {{text: string, source: string}} the composition and where it came from (for the log).
|
|
347
|
+
* @throws {BaseCompositionUnavailableError} when no route yields text.
|
|
348
|
+
*/
|
|
349
|
+
export function readBaseCompositionText(modeId) {
|
|
350
|
+
const attempts = []
|
|
351
|
+
|
|
352
|
+
// 1. explicit legacy override: tried first, and it **suppresses discovery** (the point of an explicit
|
|
353
|
+
// override is that the answer cannot wander to whatever else is installed). It does *not* suppress
|
|
354
|
+
// step 2: a host that declares the composition knows better than a stale path in the environment.
|
|
355
|
+
const legacyOverride = process.env[LEGACY_DIR_ENV]
|
|
356
|
+
const legacyExplicit = typeof legacyOverride === 'string' && legacyOverride !== ''
|
|
357
|
+
if (legacyExplicit) {
|
|
358
|
+
const file = join(legacyOverride, modeId, 'agent.cordis.yml')
|
|
359
|
+
const text = tryReadFile(file, attempts, LEGACY_DIR_ENV + '=' + legacyOverride)
|
|
360
|
+
if (text !== null) return { text, source: LEGACY_DIR_ENV }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 2. text handed over by the host (dsh ≥ 0.1.7): the authoritative answer on that line.
|
|
364
|
+
const injected = injectedBaseComposition(modeId)
|
|
365
|
+
if (injected !== undefined) return { text: injected, source: 'agentPresets.readDocument()' }
|
|
366
|
+
|
|
367
|
+
const patchOverride = process.env[PATCH_DIR_ENV]
|
|
368
|
+
const patchExplicit = typeof patchOverride === 'string' && patchOverride !== ''
|
|
369
|
+
|
|
370
|
+
if (legacyExplicit === false) {
|
|
371
|
+
// 3. injected legacy dir, then discovery
|
|
372
|
+
if (injectedLegacyDir !== undefined) {
|
|
373
|
+
const text = tryReadFile(join(injectedLegacyDir, modeId, 'agent.cordis.yml'), attempts, 'setShippedPresetsDir()')
|
|
374
|
+
if (text !== null) return { text, source: 'agentPresets.list() → presets 目录' }
|
|
375
|
+
} else {
|
|
376
|
+
try {
|
|
377
|
+
const dir = discoverLegacyPresetsDir()
|
|
378
|
+
const text = tryReadFile(join(dir, modeId, 'agent.cordis.yml'), attempts, '@deepseek-ai/dsh-agent-presets')
|
|
379
|
+
if (text !== null) return { text, source: '@deepseek-ai/dsh-agent-presets' }
|
|
380
|
+
} catch (error) {
|
|
381
|
+
attempts.push('@deepseek-ai/dsh-agent-presets:' + String((error && error.message) || error))
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 4. the declarative declaration in its packaged form
|
|
387
|
+
const patchDir = patchExplicit ? patchOverride : patchPresetsDir()
|
|
388
|
+
if (patchDir !== undefined) {
|
|
389
|
+
const file = join(patchDir, modeId + '.patch.yml')
|
|
390
|
+
const raw = tryReadFile(file, attempts, PATCH_DIR_ENV + '/发现 dsh-web-app')
|
|
391
|
+
if (raw !== null) {
|
|
392
|
+
const text = extractPluginsBlock(raw)
|
|
393
|
+
if (text !== null) return { text, source: 'dsh-web-app/presets/' + modeId + '.patch.yml' }
|
|
394
|
+
attempts.push(file + ':没有 plugins: 区块')
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
attempts.push('dsh-web-app/presets:未找到(可用 ' + PATCH_DIR_ENV + ' 指定)')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
throw new BaseCompositionUnavailableError(modeId, attempts)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The same chain, one-line description of the outcome — for startup logs. */
|
|
404
|
+
export function describeBaseCompositionSource(modeId) {
|
|
405
|
+
try {
|
|
406
|
+
return readBaseCompositionText(modeId).source
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return '不可用(' + String((error && error.message) || error) + ')'
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Exported for tests: forget every cached discovery result. */
|
|
413
|
+
export function resetBaseCompositionCachesForTests() {
|
|
414
|
+
legacyCache = undefined
|
|
415
|
+
patchCache = undefined
|
|
416
|
+
injectedLegacyDir = undefined
|
|
417
|
+
injectedPatchDir = undefined
|
|
418
|
+
injectedCompositions = undefined
|
|
419
|
+
}
|
package/client.js
CHANGED
|
@@ -86,7 +86,6 @@ try {
|
|
|
86
86
|
"msg.createFailed": "创建失败",
|
|
87
87
|
"msg.deleteFailed": "删除失败",
|
|
88
88
|
"msg.nameRequired": "请先给新助手起个名字。",
|
|
89
|
-
"msg.unsaved": "有未保存的修改",
|
|
90
89
|
"msg.readOnlyHint": "这一页只管理本工具创建的助手;手写的 preset 不在这里,也不会被改写。",
|
|
91
90
|
"msg.reordered": "顺序已保存:新建会话时的模式选择器按这个顺序排列。",
|
|
92
91
|
"msg.reorderFailed": "调整顺序失败",
|
|
@@ -101,6 +100,8 @@ try {
|
|
|
101
100
|
"assistant.short": "每个助手是一个独立模式:自己的系统提示词、基础模式与插件开关。",
|
|
102
101
|
"name.short": "改名只影响显示,内部标识与已有会话不受影响。",
|
|
103
102
|
"mode.short": "底子决定「行集合」与工具能力;persona 行始终由本模式替换。",
|
|
103
|
+
"mode.unavailable": "本机这条 DSH 线没有可读的出厂组成,基础模式暂时不能切换。",
|
|
104
|
+
"rows.unavailable": "出厂组成取不到,插件开关暂时不能编辑;磁盘上原有的行状态仍然生效。",
|
|
104
105
|
"mode.pendingRows": "底子已改为「{mode}」:保存后,下面的行列表会按新底子重算。",
|
|
105
106
|
"rows.short": "逐行控制挂载哪些插件;没拨过的行保持官方默认。",
|
|
106
107
|
"prompt.short": "这段文本就是本模式的系统提示词,保存后下一步生效。",
|
|
@@ -150,8 +151,11 @@ try {
|
|
|
150
151
|
"api.deleteFailed": "删除失败:{detail}",
|
|
151
152
|
"api.versionMissing": "找不到这个版本(历史可能已被上限裁剪)。",
|
|
152
153
|
"api.badJson": "请求体不是合法 JSON",
|
|
154
|
+
"api.savedPromptOnly": "已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。",
|
|
155
|
+
"api.baseCompositionUnavailable": "本机取不到基础模式「{mode}」的出厂组成,无法改动插件开关或基础模式(系统提示词可以单独保存)。",
|
|
153
156
|
"warn.approvalGateMissing": "审批闸门未启用:本机这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词不会弹审批。见「详情」。",
|
|
154
157
|
"warn.unresolvableRows": "有行在本机这条 DSH 线上无法解析:平台会把整个模式判为 broken,并从新会话的选择器里**静默丢弃**。点右侧的「按本线修复」即可(只关掉那几行,其它选择不动)。",
|
|
158
|
+
"warn.baseCompositionUnavailable": "取不到本机这条 DSH 线的出厂组成:基础模式与插件开关暂时不能改(系统提示词不受影响,照常可保存)。已尝试的途径写在宿主日志里。",
|
|
155
159
|
"warn.approvalGateMissing.label": "审批闸门未启用",
|
|
156
160
|
"warn.approvalGateMissing.hint": "这个 DSH 版本没有 tools/pre-execute 事件,会话内改写系统提示词**不会**弹审批。设置页不受影响;要恢复保护请升级 DSH,或把「custom_prompt 工具」那一行关掉。",
|
|
157
161
|
"warn.personaOffWithPrompt": "「身份(系统提示词)」这一行是关的,所以 prompt.md 不会被注入 —— 你写的提示词现在不起作用。要么打开这一行,要么清空提示词。",
|
|
@@ -194,6 +198,7 @@ try {
|
|
|
194
198
|
"btn.saving": "处理中…",
|
|
195
199
|
"btn.reload": "重新读取",
|
|
196
200
|
"btn.reloadDiscard": "放弃修改并重新读取",
|
|
201
|
+
"msg.unsaved": "有未保存的修改",
|
|
197
202
|
"msg.loading": "正在读取…",
|
|
198
203
|
"msg.notLoaded": "(尚未读取)",
|
|
199
204
|
"msg.reread": "已重新读取",
|
|
@@ -294,7 +299,6 @@ try {
|
|
|
294
299
|
"msg.createFailed": "Could not create",
|
|
295
300
|
"msg.deleteFailed": "Could not delete",
|
|
296
301
|
"msg.nameRequired": "Give the new assistant a name first.",
|
|
297
|
-
"msg.unsaved": "Unsaved changes",
|
|
298
302
|
"msg.readOnlyHint": "This page manages only the assistants this tool created; a hand-written preset is not listed here and is never rewritten.",
|
|
299
303
|
"msg.reordered": "Order saved: the mode picker for new sessions follows it.",
|
|
300
304
|
"msg.reorderFailed": "Could not reorder",
|
|
@@ -309,6 +313,8 @@ try {
|
|
|
309
313
|
"assistant.short": "Each assistant is its own mode: its own system prompt, base mode and plugin switches.",
|
|
310
314
|
"name.short": "Renaming only changes what is displayed — not the internal id or existing sessions.",
|
|
311
315
|
"mode.short": "The base decides the row set and tool abilities; the persona row is always replaced by this mode.",
|
|
316
|
+
"mode.unavailable": "This DSH line exposes no readable shipped composition, so the base mode cannot be switched right now.",
|
|
317
|
+
"rows.unavailable": "No shipped composition is available, so the plugin switches cannot be edited right now; the row states already on disk still apply.",
|
|
312
318
|
"mode.pendingRows": "Base changed to {mode}: the row list below is recomputed from the new base when you save.",
|
|
313
319
|
"rows.short": "Control which plugins this mode mounts, row by row; untouched rows keep the shipped default.",
|
|
314
320
|
"prompt.short": "Saving this text makes it the system prompt of this mode, and it takes effect on the next step.",
|
|
@@ -358,6 +364,8 @@ try {
|
|
|
358
364
|
"api.deleteFailed": "Delete failed: {detail}",
|
|
359
365
|
"api.versionMissing": "That version is gone (the history is capped).",
|
|
360
366
|
"api.badJson": "The request body is not valid JSON",
|
|
367
|
+
"api.savedPromptOnly": "System prompt saved (this machine exposes no shipped composition, so the plugin switches and the base mode were left untouched).",
|
|
368
|
+
"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).",
|
|
361
369
|
"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.",
|
|
362
370
|
"warn.toolOff": "The \"custom_prompt tool\" row is off: the agent cannot change the prompt from inside a session, only this page can.",
|
|
363
371
|
"warn.noDescription": "No description: the new-session mode picker will show it as \"no description yet\".",
|
|
@@ -366,6 +374,7 @@ try {
|
|
|
366
374
|
"warn.approvalGateMissing.label": "Approval gate is off",
|
|
367
375
|
"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.",
|
|
368
376
|
"warn.noName": "No name: the mode picker will show the directory id (e.g. custom).",
|
|
377
|
+
"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.",
|
|
369
378
|
"history.label": "Change history",
|
|
370
379
|
"history.pick": "Pick a version to load…",
|
|
371
380
|
"history.load": "Load this version",
|
|
@@ -402,6 +411,7 @@ try {
|
|
|
402
411
|
"btn.saving": "Working…",
|
|
403
412
|
"btn.reload": "Reload",
|
|
404
413
|
"btn.reloadDiscard": "Discard edits and reload",
|
|
414
|
+
"msg.unsaved": "Unsaved changes",
|
|
405
415
|
"msg.loading": "Loading…",
|
|
406
416
|
"msg.notLoaded": "(not loaded)",
|
|
407
417
|
"msg.reread": "Reloaded",
|
|
@@ -770,6 +780,9 @@ try {
|
|
|
770
780
|
return {
|
|
771
781
|
id: state.id,
|
|
772
782
|
mode: state.mode,
|
|
783
|
+
// 出厂组成取不到时的降级标记(服务端给,只读)。它不是用户可改的字段,sameDraft 不比较它。
|
|
784
|
+
baseUnavailable:
|
|
785
|
+
state.baseUnavailable === undefined || state.baseUnavailable === null ? null : state.baseUnavailable,
|
|
773
786
|
overrides: { ...state.overrides },
|
|
774
787
|
prompt: state.prompt,
|
|
775
788
|
name: typeof state.name === "string" ? state.name : "",
|
|
@@ -1673,22 +1686,28 @@ try {
|
|
|
1673
1686
|
onToggleExpand: toggleRowExpanded,
|
|
1674
1687
|
t: t,
|
|
1675
1688
|
}),
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
{
|
|
1683
|
-
|
|
1689
|
+
// 降级态(本机这条线没有可读的出厂组成):不给可点的模式药丸,改说一句为什么 ——
|
|
1690
|
+
// 点了也只会拿到一个服务端错误,那比置灰更糟。
|
|
1691
|
+
draft.baseUnavailable !== null
|
|
1692
|
+
? react.createElement("p", { className: "cpfe-note" }, t("mode.unavailable"))
|
|
1693
|
+
: react.createElement(
|
|
1694
|
+
"div",
|
|
1695
|
+
{ className: "cpfe-pills" },
|
|
1696
|
+
payload.modes.map((mode) =>
|
|
1697
|
+
react.createElement(
|
|
1698
|
+
A.Pill,
|
|
1699
|
+
{ key: mode.id, active: draft.mode === mode.id, onClick: () => pickMode(mode.id) },
|
|
1700
|
+
t("base." + mode.id + ".label", mode.label),
|
|
1701
|
+
),
|
|
1702
|
+
),
|
|
1703
|
+
),
|
|
1704
|
+
draft.baseUnavailable !== null
|
|
1705
|
+
? null
|
|
1706
|
+
: react.createElement(
|
|
1707
|
+
"p",
|
|
1708
|
+
{ className: "cpfe-note" },
|
|
1709
|
+
payload.modes.reduce((note, mode) => (mode.id === draft.mode ? t("base." + mode.id + ".note", mode.note) : note), ""),
|
|
1684
1710
|
),
|
|
1685
|
-
),
|
|
1686
|
-
),
|
|
1687
|
-
react.createElement(
|
|
1688
|
-
"p",
|
|
1689
|
-
{ className: "cpfe-note" },
|
|
1690
|
-
payload.modes.reduce((note, mode) => (mode.id === draft.mode ? t("base." + mode.id + ".note", mode.note) : note), ""),
|
|
1691
|
-
),
|
|
1692
1711
|
// 底子改过但还没保存时明说一句:行列表是按**已保存**的组成渲染的,审阅把它记成了"点了没反应"。
|
|
1693
1712
|
draft.mode !== savedMode
|
|
1694
1713
|
? react.createElement(
|
|
@@ -1710,15 +1729,17 @@ try {
|
|
|
1710
1729
|
onToggleExpand: toggleRowExpanded,
|
|
1711
1730
|
t: t,
|
|
1712
1731
|
}),
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1732
|
+
draft.baseUnavailable !== null
|
|
1733
|
+
? react.createElement("p", { className: "cpfe-note" }, t("rows.unavailable"))
|
|
1734
|
+
: react.createElement(RowList, {
|
|
1735
|
+
expanded: expandedRows,
|
|
1736
|
+
onToggleExpand: toggleRowExpanded,
|
|
1737
|
+
rows: payload.rows,
|
|
1738
|
+
overrides: draft.overrides,
|
|
1739
|
+
onToggle: (id, next) => update({ overrides: { ...draft.overrides, [id]: next } }),
|
|
1740
|
+
depth: 0,
|
|
1741
|
+
t: t,
|
|
1742
|
+
}),
|
|
1722
1743
|
),
|
|
1723
1744
|
react.createElement(
|
|
1724
1745
|
"section",
|
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,49 @@ 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}} input - the validated save.
|
|
406
|
+
* @returns {object} the shape `saveState` returns, with `code: 'savedPromptOnly'`.
|
|
407
|
+
*/
|
|
408
|
+
export function savePromptOnly(directory, { id, mode, prompt, name, description }) {
|
|
409
|
+
// 与正常保存同一条纪律:提示词与 preset.yml 一起换名,失败则两个都不动。
|
|
410
|
+
let metaText = null
|
|
411
|
+
if (name !== '') {
|
|
412
|
+
const rendered = presetMetaText(name, description, directory)
|
|
413
|
+
if (rendered.ok !== true) return rendered
|
|
414
|
+
metaText = rendered.text
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
writeAtomicPair([
|
|
418
|
+
[promptFile(directory), prompt],
|
|
419
|
+
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
420
|
+
])
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
recordPrompt(directory, prompt, HISTORY_SOURCE.settings)
|
|
426
|
+
} catch {
|
|
427
|
+
/* 审计记录失败不影响保存本身 */
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
id,
|
|
432
|
+
mode,
|
|
433
|
+
code: 'savedPromptOnly',
|
|
434
|
+
params: { name: name === '' ? id : name, mode },
|
|
435
|
+
note: '已保存系统提示词(本机取不到基础模式的出厂组成,插件开关与基础模式未改动)。新建会话即生效。',
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
376
439
|
export function saveState(rows, input) {
|
|
377
440
|
const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
|
|
378
441
|
const directory = assistantDir(rows, id)
|
|
@@ -420,6 +483,21 @@ export function saveState(rows, input) {
|
|
|
420
483
|
try {
|
|
421
484
|
composition = renderComposition(mode, overrides, { modeName: name, assistantId: id })
|
|
422
485
|
} catch (error) {
|
|
486
|
+
// 拿不到出厂组成:这一页**降级**但不能全废 —— 提示词仍可单独保存(组成文件原样不动)。
|
|
487
|
+
// 页面在降级态下把基础模式与开关都设为不可编辑,所以开关集合必为空;HTTP API 直接调用
|
|
488
|
+
// 还想改开关时明确拒绝,而不是悄悄丢掉用户的意图。
|
|
489
|
+
if (isBaseCompositionUnavailable(error)) {
|
|
490
|
+
if (overrides.size === 0 && existsSync(compositionFile(directory))) {
|
|
491
|
+
return savePromptOnly(directory, { id, mode, prompt, name, description })
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
code: error.code,
|
|
496
|
+
params: { mode, detail: describe(error) },
|
|
497
|
+
error:
|
|
498
|
+
'本机取不到基础模式「' + mode + '」的出厂组成,无法改动插件开关或基础模式(提示词可以单独保存)。',
|
|
499
|
+
}
|
|
500
|
+
}
|
|
423
501
|
return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
|
|
424
502
|
}
|
|
425
503
|
|
|
@@ -432,12 +510,21 @@ export function saveState(rows, input) {
|
|
|
432
510
|
return { ok: false, code: 'selfCheckFailed', params: { detail: describe(error) }, error: '生成结果自检失败,已放弃写入:' + describe(error) }
|
|
433
511
|
}
|
|
434
512
|
|
|
513
|
+
// 三个文件必须一起换名(issue #5):组成、提示词、preset.yml。此前 preset.yml 是**单独**写的,
|
|
514
|
+
// 它失败时磁盘上已经是"新提示词 + 旧名字",而页面报"保存失败"—— 页面又不会重读,于是它永远停在
|
|
515
|
+
// 旧草稿上,用户以为没保存成功。现在三份先各自写进临时文件,再一起 rename;准备阶段失败则一个都不动。
|
|
516
|
+
// 名字为空时**不动** preset.yml:空的 name 标量会让这个模式在所有选择器里退化成裸目录 id。
|
|
517
|
+
let metaText = null
|
|
518
|
+
if (name !== '') {
|
|
519
|
+
const rendered = presetMetaText(name, description, directory)
|
|
520
|
+
if (rendered.ok !== true) return rendered
|
|
521
|
+
metaText = rendered.text
|
|
522
|
+
}
|
|
435
523
|
try {
|
|
436
|
-
// 组成文件与提示词必须一起更新:先都写进临时文件再一起换名,失败时不会留下"新组成 + 旧提示词"
|
|
437
|
-
// 这种混合状态(审阅指出原先两次独立原子写之间存在这个窗口)。
|
|
438
524
|
writeAtomicPair([
|
|
439
525
|
[compositionFile(directory), composition],
|
|
440
526
|
[promptFile(directory), prompt],
|
|
527
|
+
...metaText === null ? [] : [[presetMetaPath(directory), metaText]],
|
|
441
528
|
])
|
|
442
529
|
} catch (error) {
|
|
443
530
|
return { ok: false, code: 'writeFailed', params: { detail: describe(error) }, error: '写入失败:' + describe(error) }
|
|
@@ -455,13 +542,6 @@ export function saveState(rows, input) {
|
|
|
455
542
|
/* 审计记录失败不影响保存本身 */
|
|
456
543
|
}
|
|
457
544
|
|
|
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
545
|
return {
|
|
466
546
|
ok: true,
|
|
467
547
|
id,
|
|
@@ -555,6 +635,14 @@ export function createAssistant(rows, input, templateDir = packagedPresetDir())
|
|
|
555
635
|
// 与保存路径的 `renderFailed` 同一套:**每个**用户可见的结果都要带 `code`,否则页面无从本地化
|
|
556
636
|
// —— client.js 的 apiText 在没有 code 时直接退回下面这串中文,英文界面会在这一刻掉回中文
|
|
557
637
|
// (AGENTS.md 第 13 条)。这条曾经是宿主半唯一漏掉 code 的普通返回。
|
|
638
|
+
if (isBaseCompositionUnavailable(error)) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
code: error.code,
|
|
642
|
+
params: { mode: 'standard', detail: describe(error) },
|
|
643
|
+
error: '本机取不到出厂组成,暂时无法新建助手。',
|
|
644
|
+
}
|
|
645
|
+
}
|
|
558
646
|
return { ok: false, code: 'renderFailed', params: { detail: describe(error) }, error: '生成组成文件失败:' + describe(error) }
|
|
559
647
|
}
|
|
560
648
|
|
|
@@ -749,6 +837,9 @@ export function apply(ctx) {
|
|
|
749
837
|
['connection.fetch.register()', () => typeof scope.connection?.fetch?.register === 'function'],
|
|
750
838
|
]
|
|
751
839
|
const OPTIONAL_APIS = [
|
|
840
|
+
// 出厂组成的来源(新线):由宿主交出声明 YAML。缺了它插件仍能注册/同步,只是设置页里
|
|
841
|
+
// 「基础模式 / 插件开关」会降级为不可编辑(提示词照常)—— 所以它是可选能力。
|
|
842
|
+
['agentPresets.readDocument()', () => typeof scope.agentPresets?.readDocument === 'function'],
|
|
752
843
|
// 旧线的文件系统语义;新线由 preset-backend 的 declarative 后端用 disposer 补上。
|
|
753
844
|
['agentPresets.remove()', () => typeof scope.agentPresets?.remove === 'function'],
|
|
754
845
|
['agentPresets.copy()', () => typeof scope.agentPresets?.copy === 'function'],
|
|
@@ -785,7 +876,7 @@ export function apply(ctx) {
|
|
|
785
876
|
let shippedReady = null
|
|
786
877
|
const ensureShipped = () => {
|
|
787
878
|
if (shippedReady === null) {
|
|
788
|
-
|
|
879
|
+
const attempt = (async () => {
|
|
789
880
|
try {
|
|
790
881
|
const rows = await scope.agentPresets.list()
|
|
791
882
|
const system = rows.find((row) => row.trust === 'system' && typeof row.path === 'string')
|
|
@@ -795,6 +886,10 @@ export function apply(ctx) {
|
|
|
795
886
|
console.error('custom-mode: 无法从 roster 解析出厂预设目录:' + describe(error))
|
|
796
887
|
}
|
|
797
888
|
})()
|
|
889
|
+
// 失败不缓存:旧线之外这条路本就可能没有 system 行,但一次**抛错**不该变成
|
|
890
|
+
// "整个进程生命周期内都不再解析"。
|
|
891
|
+
attempt.catch(() => { shippedReady = null })
|
|
892
|
+
shippedReady = attempt
|
|
798
893
|
}
|
|
799
894
|
return shippedReady
|
|
800
895
|
}
|
|
@@ -853,6 +948,28 @@ export function apply(ctx) {
|
|
|
853
948
|
console.error('custom-mode: 读取 compositionInventory 失败(回退文件系统判定): ' + describe(error))
|
|
854
949
|
}
|
|
855
950
|
}
|
|
951
|
+
// 出厂组成:新线上唯一的来源是宿主自己(readDocument)。取到就交给 composer;取不到就交给
|
|
952
|
+
// 降级路径。两种情况都不阻塞下面的注册表同步 —— 模式可用优先于设置页完整。
|
|
953
|
+
if (declarative !== null) {
|
|
954
|
+
try {
|
|
955
|
+
const bases = await declarative.fetchBaseCompositions(BASE_MODES.map((mode) => mode.id))
|
|
956
|
+
setBaseCompositions(bases.fetched)
|
|
957
|
+
if (bases.fetched.size > 0) {
|
|
958
|
+
console.log(
|
|
959
|
+
'custom-mode: 已取回 ' + String(bases.fetched.size) + ' 个基础模式的出厂组成(' +
|
|
960
|
+
[...bases.fetched.keys()].join('、') + ')',
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
if (bases.problems.length > 0) {
|
|
964
|
+
console.error(
|
|
965
|
+
'custom-mode: 取不到这些基础模式的出厂组成 —— ' + bases.problems.join(';') +
|
|
966
|
+
'。对应模式仍可编辑提示词,基础模式与插件开关会显示为不可用。',
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
} catch (error) {
|
|
970
|
+
console.error('custom-mode: 取回出厂组成时出错(已忽略): ' + describe(error))
|
|
971
|
+
}
|
|
972
|
+
}
|
|
856
973
|
if (declarative === null) return
|
|
857
974
|
try {
|
|
858
975
|
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.13",
|
|
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
|
}
|
|
@@ -47,6 +47,9 @@ export function detectPresetBackend(scope) {
|
|
|
47
47
|
read: typeof svc?.read === 'function',
|
|
48
48
|
inventory: typeof svc?.compositionInventory === 'function',
|
|
49
49
|
select: typeof svc?.select === 'function',
|
|
50
|
+
// 出厂组成的来源:旧线读文件,新线由这个 API 交出同一份声明(缺了它设置页仍可用,但
|
|
51
|
+
// 基础模式与插件开关必须降级为不可编辑 —— 所以它是**可选**能力,不是必需)。
|
|
52
|
+
document: typeof svc?.readDocument === 'function',
|
|
50
53
|
}
|
|
51
54
|
const reasons = []
|
|
52
55
|
|
|
@@ -78,7 +81,12 @@ export function detectPresetBackend(scope) {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
if (looksDeclarative) {
|
|
81
|
-
reasons.push(
|
|
84
|
+
reasons.push(
|
|
85
|
+
'agentPresets.remove() 不存在,但 register() + compositionInventory() 存在 —— 声明式注册表的新线' +
|
|
86
|
+
(capabilities.document
|
|
87
|
+
? '(readDocument() 可用:出厂组成由宿主交出)'
|
|
88
|
+
: '(**readDocument() 不可用**:基础模式与插件开关将降级为不可编辑)'),
|
|
89
|
+
)
|
|
82
90
|
return { id: BACKEND_DECLARATIVE, capabilities, reasons, conflict: false }
|
|
83
91
|
}
|
|
84
92
|
|
package/preset-backend/index.mjs
CHANGED
|
@@ -122,13 +122,21 @@ export function effectiveRosterRows(rows, { root, backendId }) {
|
|
|
122
122
|
for (const synth of synthesizeRosterRows(root)) byId.set(synth.id, synth)
|
|
123
123
|
|
|
124
124
|
// 注册表能给出 mount 诊断(broken),合并进合成行;合成行提供 trust/path。
|
|
125
|
+
//
|
|
126
|
+
// ★ name / description **磁盘优先**:注册表里那两份是**上一次注册**留下的副本(我们自己注册的),
|
|
127
|
+
// 让它们覆盖 preset.yml 里的新值,"改名"就永远不生效 —— 实测(2026-09-25,0.1.7-rc.2):
|
|
128
|
+
// POST /state 存下 name=写作助手、preset.yml 已是新名、readState 也读到了新名,而助手列表与
|
|
129
|
+
// 新会话选择器里仍是旧名「自定义模式」,直到进程重启才对齐。磁盘是用户可见、可手改、可迁移的
|
|
130
|
+
// 真相(本模块头注释里的 A/B/C 里,磁盘从来就是 A 那一侧),注册表只是它的投影。
|
|
125
131
|
for (const row of platform) {
|
|
126
132
|
if (row === null || typeof row !== 'object' || typeof row.id !== 'string') continue
|
|
127
133
|
const synth = byId.get(row.id)
|
|
128
134
|
if (synth === undefined) continue
|
|
129
135
|
if (typeof row.broken === 'string' && row.broken !== '') synth.broken = row.broken
|
|
130
|
-
if (
|
|
131
|
-
if (
|
|
136
|
+
if ((synth.name === undefined || synth.name === '') && typeof row.name === 'string') synth.name = row.name
|
|
137
|
+
if ((synth.description === undefined || synth.description === '') && typeof row.description === 'string') {
|
|
138
|
+
synth.description = row.description
|
|
139
|
+
}
|
|
132
140
|
}
|
|
133
141
|
return [...byId.values()]
|
|
134
142
|
}
|