dsh-custom-mode 0.1.6-alpha.1 → 0.1.6-alpha.2

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/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # dsh-custom-mode
2
+
3
+ The **settings-page half** of [dsh-custom-mode](https://github.com/BOWLUNA/dsh-custom-mode): a Web UI
4
+ for a [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) agent preset whose
5
+ **system prompt is a plain file you can edit, taking effect on the next model step** — no restart, no
6
+ new session.
7
+
8
+ ![Settings → Custom mode](https://raw.githubusercontent.com/BOWLUNA/dsh-custom-mode/main/docs/images/01-mode-switch.png)
9
+
10
+ ## What is in this package, and what is not
11
+
12
+ This package is **only the editor**: one private HTTP route plus the browser half that renders the
13
+ settings section (base-mode picker, per-row plugin switches, prompt editor, mode rename).
14
+
15
+ The **agent preset itself is not an npm package** — it is a directory of files
16
+ (`preset/agent.cordis.yml`, `prompt-reader.mjs`, `prompt-tool.mjs`, `prompt.md`) that dsh discovers
17
+ under `$DSH_HOME/.agent-presets/<id>/`. It comes from the GitHub repository.
18
+
19
+ So:
20
+
21
+ - **First-time install** → clone the repository and run `./install.sh`. It copies the preset *and*
22
+ installs this package into your profile.
23
+ - **Already have the preset, want to update the editor** → install this package directly:
24
+
25
+ ```sh
26
+ dsh plugin --profile web add dsh-custom-mode
27
+ ```
28
+
29
+ - The mode is **web-profile only**: the `agent-presets` service, which mounts presets at all, ships
30
+ with dsh's `web` profile. In `tui` / `headless` this package activates but registers nothing.
31
+
32
+ ## Requirements
33
+
34
+ - `@deepseek-ai/dsh` `>=0.1.2-alpha.1` (declared as an *optional* peer: this package never imports it,
35
+ it only reads the host services dsh injects)
36
+ - The version mirrors **the DSH release this plugin was adapted to** (`0.1.6-alpha.1`). It is not
37
+ bumped per change; see the repository README's "Versioning" section.
38
+ - Source is published as-is: **no build step, no dependencies** beyond Node's standard library.
39
+
40
+ ## Documentation
41
+
42
+ The full documentation is bilingual in the repository:
43
+
44
+ - [README](https://github.com/BOWLUNA/dsh-custom-mode#readme) (English) ·
45
+ [中文说明](https://github.com/BOWLUNA/dsh-custom-mode/blob/main/README.zh.md)
46
+ - [Troubleshooting](https://github.com/BOWLUNA/dsh-custom-mode/blob/main/docs/TROUBLESHOOTING.md) —
47
+ every failure that was actually reproduced, with symptoms, cause and a way out
48
+ - [Measured behaviour](https://github.com/BOWLUNA/dsh-custom-mode/blob/main/docs/%E5%AE%9E%E6%B5%8B%E8%AE%B0%E5%BD%95.md) —
49
+ the commands and raw output behind every claim
50
+ - [Architecture](https://github.com/BOWLUNA/dsh-custom-mode/blob/main/docs/ARCHITECTURE.md) — why
51
+ this had to be two artifacts, and how the host APIs it depends on can break
52
+
53
+ ## Security note
54
+
55
+ The route this package serves runs the platform's own browser-trust check
56
+ (`ctx.connection.requestRejection`) before anything else and **fails closed** when that service is
57
+ unavailable. Before that check existed, the route could be read and written unauthenticated — see
58
+ [SECURITY.md](https://github.com/BOWLUNA/dsh-custom-mode/blob/main/SECURITY.md) for the affected
59
+ range and the mitigation.
60
+
61
+ ## License
62
+
63
+ MIT
package/assistants.mjs ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * The assistant registry: many custom modes, one settings page.
3
+ *
4
+ * The feature started as a SINGLE custom preset (`$DSH_HOME/.agent-presets/custom`).
5
+ * This module generalises it to N assistants that live side by side in the same
6
+ * user preset root, which is what the harness already supports: `dsh-agent-presets`
7
+ * scans every directory under the writable root and re-reads the roots on each
8
+ * roster call, so a directory authored while the process runs is selectable in the
9
+ * next session without a restart.
10
+ *
11
+ * What makes a directory an ASSISTANT of this feature is deliberately narrow:
12
+ *
13
+ * - it carries `prompt.md` (the editable identity), AND
14
+ * - it carries `prompt-reader.mjs`, or its composition still names
15
+ * `./prompt-reader.mjs` — the reader that re-reads that file every step.
16
+ *
17
+ * A hand-authored preset that merely has a `prompt.md` is NOT claimed: the page
18
+ * regenerates a composition from a base mode, and doing that to somebody's
19
+ * hand-written composition would destroy it. The second half of the predicate is
20
+ * what keeps a preset repairable after its reader module is deleted by accident.
21
+ *
22
+ * Everything here is a pure function of (roster rows | directory tree) plus the
23
+ * packaged template. The host half owns the roster and the platform's authoring
24
+ * calls (`agentPresets.copy/remove`); this module owns naming, discovery and the
25
+ * file work, so it can be tested without a running harness.
26
+ */
27
+
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+ import { dshHome, PRESET_DIR } from './paths.mjs'
31
+ import { readPresetMeta, writePresetMeta } from './meta.mjs'
32
+ import { seedPreset, seedPresetWithLog } from './seed.mjs'
33
+
34
+ /**
35
+ * Preset ids a directory may use, mirrored from
36
+ * `@deepseek-ai/dsh-agent-presets`'s `PRESET_ID`.
37
+ *
38
+ * The id becomes a path segment, so this is a containment boundary: `..`, a
39
+ * separator or an absolute-looking name would place a preset outside the root the
40
+ * deployment authorised. A copy is refused by the harness for the same reason, so
41
+ * an id this module hands out must pass the same test.
42
+ */
43
+ export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
44
+
45
+ /** The id of the legacy single-mode install, and the first id this feature hands out. */
46
+ export const LEGACY_ID = 'custom'
47
+
48
+ /** The composition file every preset directory is discovered by. */
49
+ export const COMPOSITION_FILE = 'agent.cordis.yml'
50
+
51
+ /**
52
+ * Marker file recording that the one-time seed already happened.
53
+ *
54
+ * It is a FILE directly under the preset root, so discovery's `child.isDirectory()`
55
+ * gate skips it, and its leading dot keeps it out of `PRESET_ID` anyway. Without it,
56
+ * a user who deletes every assistant would find `custom` recreated on the next
57
+ * process start — the seed below is idempotent, not clairvoyant.
58
+ */
59
+ export const SEED_MARKER_FILE = '.custom-mode.json'
60
+
61
+ /** Absolute path of the seed marker inside one preset root. */
62
+ export function seedMarkerPath(root) {
63
+ return join(root, SEED_MARKER_FILE)
64
+ }
65
+
66
+ /** Whether the one-time seed already ran for this preset root. */
67
+ export function isSeeded(root) {
68
+ try {
69
+ return existsSync(seedMarkerPath(root))
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Record that the one-time seed ran.
77
+ *
78
+ * Best-effort: an unwritable root must not keep the harness from booting, and the
79
+ * cost of a failed write is only that the next activation repeats an idempotent
80
+ * seed. Never throws.
81
+ *
82
+ * @param {string} root - the preset root.
83
+ * @returns {boolean} whether the marker is now on disk.
84
+ */
85
+ export function markSeeded(root) {
86
+ try {
87
+ mkdirSync(root, { recursive: true })
88
+ writeFileSync(seedMarkerPath(root), JSON.stringify({ seededAt: new Date().toISOString() }) + '\n', 'utf8')
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ /** Whether `dir` is a directory this feature owns (see the module comment). */
96
+ export function isManagedDir(dir) {
97
+ if (typeof dir !== 'string' || dir === '') return false
98
+ if (!existsSync(join(dir, 'prompt.md'))) return false
99
+ if (existsSync(join(dir, 'prompt-reader.mjs'))) return true
100
+ // The reader is gone but the composition still injects it: still ours, and the
101
+ // repair pass below can put the module back.
102
+ try {
103
+ return readFileSync(join(dir, COMPOSITION_FILE), 'utf8').includes("'./prompt-reader.mjs'")
104
+ } catch {
105
+ return false
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Every managed assistant directory under one preset root, ordered by id.
111
+ *
112
+ * A missing root is not an error: it is the state of a machine that has not
113
+ * installed anything yet, and the seed below is what fills it.
114
+ *
115
+ * @param {string} root - the writable preset root.
116
+ * @returns {string[]} absolute directories.
117
+ */
118
+ export function scanManagedDirs(root) {
119
+ let children
120
+ try {
121
+ children = readdirSync(root, { withFileTypes: true })
122
+ } catch {
123
+ return []
124
+ }
125
+ const found = []
126
+ for (const child of children) {
127
+ if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue
128
+ const dir = join(root, child.name)
129
+ if (isManagedDir(dir)) found.push(dir)
130
+ }
131
+ return found.sort()
132
+ }
133
+
134
+ /** `<root>/<id>/agent.cordis.yml` → `<root>`. */
135
+ export function rootOfPresetPath(presetPath) {
136
+ return dirname(dirname(presetPath))
137
+ }
138
+
139
+ /**
140
+ * The writable preset root, resolved from the roster.
141
+ *
142
+ * A user-trust row carries the absolute path of its composition, so its grandparent
143
+ * IS the root the deployment authorised — independent of install layout, and
144
+ * correct even when a profile points `roots` somewhere else. The fallback is the
145
+ * harness-home root `dsh-agent-presets` itself defaults to, and it only applies to
146
+ * a machine with no user preset at all (so nothing can be learned from the roster).
147
+ *
148
+ * @param {Array<{trust?: string, path?: string}>} rows - roster rows.
149
+ * @param {string} [fallback] - root to use when the roster carries no user preset.
150
+ * @returns {string} absolute preset root.
151
+ */
152
+ export function userPresetRoot(rows, fallback = dirname(PRESET_DIR)) {
153
+ for (const row of Array.isArray(rows) ? rows : []) {
154
+ if (row === null || typeof row !== 'object') continue
155
+ if (row.trust !== 'user') continue
156
+ if (typeof row.path !== 'string' || row.path === '') continue
157
+ return rootOfPresetPath(row.path)
158
+ }
159
+ return fallback === undefined || fallback === '' ? join(dshHome(), '.agent-presets') : fallback
160
+ }
161
+
162
+ /**
163
+ * The assistants a roster describes, in roster order (which is id order for
164
+ * authored presets, since none of them carries a shipped `order`).
165
+ *
166
+ * @param {Array<{id?: string, trust?: string, path?: string, name?: string, description?: string, broken?: string}>} rows
167
+ * @returns {Array<{id: string, name: string, description: string, broken?: string}>}
168
+ */
169
+ export function assistantsFromRoster(rows) {
170
+ const out = []
171
+ for (const row of Array.isArray(rows) ? rows : []) {
172
+ if (row === null || typeof row !== 'object') continue
173
+ if (row.trust !== 'user') continue
174
+ if (typeof row.id !== 'string' || row.id === '') continue
175
+ if (typeof row.path !== 'string' || row.path === '') continue
176
+ if (!isManagedDir(dirname(row.path))) continue
177
+ out.push({
178
+ id: row.id,
179
+ name: typeof row.name === 'string' ? row.name : '',
180
+ description: typeof row.description === 'string' ? row.description : '',
181
+ ...typeof row.broken === 'string' && row.broken !== '' ? { broken: row.broken } : {},
182
+ })
183
+ }
184
+ return out
185
+ }
186
+
187
+ /**
188
+ * Absolute directory of one assistant, or undefined when the roster does not
189
+ * describe a managed one under that id.
190
+ *
191
+ * Resolution goes through the roster rather than string-joining the root, so a
192
+ * request for an id the deployment never discovered cannot address a directory.
193
+ *
194
+ * @param {Array<object>} rows - roster rows.
195
+ * @param {string} id - the assistant id.
196
+ * @returns {string|undefined} the directory, or undefined.
197
+ */
198
+ export function assistantDir(rows, id) {
199
+ if (typeof id !== 'string' || id === '') return undefined
200
+ for (const row of Array.isArray(rows) ? rows : []) {
201
+ if (row === null || typeof row !== 'object') continue
202
+ if (row.id !== id) continue
203
+ if (row.trust !== 'user') return undefined
204
+ if (typeof row.path !== 'string' || row.path === '') return undefined
205
+ const dir = dirname(row.path)
206
+ return isManagedDir(dir) ? dir : undefined
207
+ }
208
+ return undefined
209
+ }
210
+
211
+ /**
212
+ * Choose a free preset id for a new assistant.
213
+ *
214
+ * A name that is already a legal id (an English word, say) becomes the id, which
215
+ * keeps the directory readable; a repeat takes `-2`, `-3`, … on that same stem.
216
+ * Anything else — most Chinese names slug to nothing usable — falls back to
217
+ * `custom`, then `custom-2`, `custom-3`, …
218
+ *
219
+ * @param {string} name - the assistant's display name.
220
+ * @param {Set<string>} taken - ids already used by any preset (shipped or authored).
221
+ * @returns {string} a free id.
222
+ */
223
+ export function allocateId(name, taken) {
224
+ const used = taken instanceof Set ? taken : new Set(taken ?? [])
225
+ const slug = String(name ?? '')
226
+ .toLowerCase()
227
+ .replace(/[^a-z0-9]+/g, '-')
228
+ .replace(/^-+|-+$/g, '')
229
+ if (PRESET_ID.test(slug)) {
230
+ if (!used.has(slug)) return slug
231
+ for (let n = 2; ; n += 1) {
232
+ const id = `${slug}-${n}`
233
+ if (!used.has(id)) return id
234
+ }
235
+ }
236
+ for (let n = 1; ; n += 1) {
237
+ const id = n === 1 ? LEGACY_ID : `${LEGACY_ID}-${n}`
238
+ if (!used.has(id)) return id
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Create one assistant directory from the packaged template.
244
+ *
245
+ * Seeding from the package rather than copying an existing assistant is what makes
246
+ * "new assistant" mean NEW: a copy would inherit whatever prompt, row switches and
247
+ * extra files the source had accumulated. It also keeps creation working when the
248
+ * user has deleted every other assistant.
249
+ *
250
+ * The composition text is passed in by the caller (`renderComposition`) so this
251
+ * module stays free of the base-mode machinery.
252
+ *
253
+ * @param {{root: string, id: string, composition: string, templateDir?: string}} input
254
+ * @returns {{ok: true, id: string, dir: string} | {ok: false, error: string}}
255
+ */
256
+ export function createAssistantDir({ root, id, composition, templateDir }) {
257
+ if (!PRESET_ID.test(String(id ?? ''))) {
258
+ return { ok: false, error: `助手标识不合法:${String(id)}(只能是 a-z0-9 与连字符)` }
259
+ }
260
+ const dir = join(root, id)
261
+ if (existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` }
262
+
263
+ const seeded = seedPreset(dir, templateDir)
264
+ if (seeded.errors.length > 0) {
265
+ return { ok: false, error: '复制模式模板失败:' + seeded.errors.join(';') }
266
+ }
267
+ try {
268
+ writeFileSync(join(dir, COMPOSITION_FILE), composition, 'utf8')
269
+ } catch (error) {
270
+ return { ok: false, error: '写入组成文件失败:' + describe(error) }
271
+ }
272
+ return { ok: true, id, dir }
273
+ }
274
+
275
+ /**
276
+ * What one activation should do about the preset tree.
277
+ *
278
+ * Two jobs, in this order:
279
+ *
280
+ * 1. **Repair.** Fill in files missing from any managed assistant (never
281
+ * overwriting one), because a composition row naming a module that no longer
282
+ * exists makes the whole preset read as BROKEN in discovery — it would vanish
283
+ * from every picker with no way back.
284
+ * 2. **First run.** Create the legacy `custom` assistant when this feature has
285
+ * never run here. The marker is what stops a deleted assistant from coming back:
286
+ * an install that already has assistants is adopted silently, and an install
287
+ * that deliberately deleted all of them stays empty.
288
+ *
289
+ * @param {{root: string, templateDir?: string, log?: Function, info?: Function}} input
290
+ * @returns {{created: boolean, repaired: number, adopted: boolean}}
291
+ */
292
+ export function seedOnActivation({ root, templateDir, log = console.error, info = console.log }) {
293
+ const existing = scanManagedDirs(root)
294
+ let repaired = 0
295
+ for (const dir of existing) {
296
+ // Fill-only, exactly like the original single-preset behaviour.
297
+ const result = seedPreset(dir, templateDir)
298
+ if (result.created.length > 0) {
299
+ repaired += 1
300
+ info(`custom-mode: 已补全 ${dir} 缺失的模板文件(${result.created.join(', ')})`)
301
+ }
302
+ for (const error of result.errors) log(`custom-mode: 补全 ${dir} 失败 —— ${error}`)
303
+ }
304
+
305
+ if (isSeeded(root)) return { created: false, repaired, adopted: false }
306
+ if (existing.length > 0) {
307
+ markSeeded(root)
308
+ return { created: false, repaired, adopted: true }
309
+ }
310
+
311
+ const created = seedPresetWithLog(join(root, LEGACY_ID), log, info, templateDir)
312
+ markSeeded(root)
313
+ return { created: created.created.length > 0, repaired, adopted: false }
314
+ }
315
+
316
+ /**
317
+ * Move one assistant one slot up or down in the picker order.
318
+ *
319
+ * The order lives in each preset's own `preset.yml` as `order`, which is the roster's declared
320
+ * sort key (`dsh-agent-presets` sorts by `order ?? Infinity`, then id) — the same mechanism the
321
+ * shipped presets use. That makes the order a property of the preset rather than of this plugin,
322
+ * so it survives a restart, is visible in the file, and needs no state file to drift.
323
+ *
324
+ * Writing positions `1..N` for EVERY managed assistant is deliberate: assistants authored before
325
+ * anyone reordered have no `order` at all, and pinning all of them makes the result independent
326
+ * of the id-sort that would otherwise shuffle them back.
327
+ *
328
+ * @param {Array<object>} rows - the current roster (already in display order).
329
+ * @param {{id?: string, direction?: string}} input - which assistant moves, and which way.
330
+ * @param {Function} [write] - metadata writer (tests inject their own).
331
+ * @returns {{ok: true, id: string, order: string[], note: string} | {ok: false, error: string}}
332
+ */
333
+ export function reorderAssistant(rows, input, write = writePresetMeta) {
334
+ const id = input !== null && typeof input === 'object' && typeof input.id === 'string' ? input.id : ''
335
+ const direction = input !== null && typeof input === 'object' ? input.direction : undefined
336
+ if (direction !== 'up' && direction !== 'down') {
337
+ return { ok: false, error: '未知的排序方向:' + String(direction) }
338
+ }
339
+ const list = assistantsFromRoster(rows)
340
+ const index = list.findIndex((item) => item.id === id)
341
+ if (index === -1) return { ok: false, error: '找不到助手「' + id + '」。' }
342
+ const target = direction === 'up' ? index - 1 : index + 1
343
+ if (target < 0) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最前面。' }
344
+ if (target >= list.length) return { ok: false, error: '「' + (list[index].name || id) + '」已经在最后面。' }
345
+
346
+ const next = [...list]
347
+ const [moved] = next.splice(index, 1)
348
+ next.splice(target, 0, moved)
349
+
350
+ for (const [position, item] of next.entries()) {
351
+ const directory = assistantDir(rows, item.id)
352
+ if (directory === undefined) continue
353
+ const result = write(item.name, item.description, directory, { order: position + 1 })
354
+ if (result.ok !== true) return result
355
+ }
356
+ return {
357
+ ok: true,
358
+ id,
359
+ order: next.map((item) => item.id),
360
+ note: '顺序已保存:新建会话时的模式选择器按这个顺序排列。',
361
+ }
362
+ }
363
+
364
+ /** `error` as a readable string, without assuming it is an Error. */
365
+ function describe(error) {
366
+ return String((error && error.message) || error)
367
+ }