dsh-custom-mode 1.0.3 → 1.0.4
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/index.mjs +38 -6
- package/package.json +1 -1
- package/preset/prompt-tool.mjs +18 -2
package/index.mjs
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* rows the user changed by diffing against the same shipped base mode.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { randomBytes } from 'node:crypto'
|
|
40
41
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
41
42
|
import { dirname, join } from 'node:path'
|
|
42
43
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
|
|
@@ -79,6 +80,12 @@ const METHODS = {
|
|
|
79
80
|
[REORDER_PATH]: ['POST'],
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/** 只告警一次:避免每个请求都刷同一行日志。 */
|
|
84
|
+
let warnedRosterShape = false
|
|
85
|
+
|
|
86
|
+
/** 应用层请求体上限;平台的 buffered cap 是第一道,这个是我们自己的兜底(见 handler 里的注释)。 */
|
|
87
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
88
|
+
|
|
82
89
|
/** Longest display name / description the page accepts, so one paste cannot bloat every picker. */
|
|
83
90
|
const MAX_NAME = 80
|
|
84
91
|
const MAX_DESCRIPTION = 400
|
|
@@ -490,10 +497,14 @@ export function apply(ctx) {
|
|
|
490
497
|
// Compatibility guard: this plugin reads host APIs that a future DSH release could
|
|
491
498
|
// reshape. Check them once and say so plainly, instead of letting every request
|
|
492
499
|
// fail with an opaque 500.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
500
|
+
// 数据表形式:以后新增一处耦合点,只要在这里加一行 —— README 的耦合点清单与本表同源,
|
|
501
|
+
// 让"上游改了 API 形状"在启动日志里就能看见,而不是等用户报"设置页白屏"。
|
|
502
|
+
const REQUIRED_APIS = [
|
|
503
|
+
['agentPresets.list()', () => typeof scope.agentPresets?.list === 'function'],
|
|
504
|
+
['agentPresets.remove()', () => typeof scope.agentPresets?.remove === 'function'],
|
|
505
|
+
['connection.fetch.register()', () => typeof scope.connection?.fetch?.register === 'function'],
|
|
506
|
+
]
|
|
507
|
+
const missing = REQUIRED_APIS.filter(([, probe]) => !probe()).map(([name]) => name)
|
|
497
508
|
if (missing.length > 0) {
|
|
498
509
|
console.error(
|
|
499
510
|
'custom-mode: 当前 DSH 版本缺少所需 API:' +
|
|
@@ -527,7 +538,16 @@ export function apply(ctx) {
|
|
|
527
538
|
/** The roster, or an empty list — discovery itself reports broken rows rather than throwing. */
|
|
528
539
|
const roster = async () => {
|
|
529
540
|
const rows = await scope.agentPresets.list()
|
|
530
|
-
|
|
541
|
+
if (!Array.isArray(rows)) {
|
|
542
|
+
// 形状变了:静默返回空列表会让页面显示"一个助手都没有",比报错更难查(曾经就因为
|
|
543
|
+
// 缺少这种告警,一个 API 形状变化以"设置页白屏"的形式出现)。
|
|
544
|
+
if (!warnedRosterShape) {
|
|
545
|
+
warnedRosterShape = true
|
|
546
|
+
console.error('custom-mode: agentPresets.list() 没有返回数组(DSH 版本不匹配?),助手列表将为空。')
|
|
547
|
+
}
|
|
548
|
+
return []
|
|
549
|
+
}
|
|
550
|
+
return rows
|
|
531
551
|
}
|
|
532
552
|
|
|
533
553
|
/**
|
|
@@ -559,6 +579,15 @@ export function apply(ctx) {
|
|
|
559
579
|
return json(readState(await roster(), url.searchParams.get('id') ?? ''))
|
|
560
580
|
}
|
|
561
581
|
|
|
582
|
+
// Backstop on request size. `requestBody: 'buffered'` means the platform applies its own
|
|
583
|
+
// JSON cap, but that cap is the host's configuration, not a contract — measured on Windows,
|
|
584
|
+
// a 5 MB body reached us happily. This keeps one authenticated request from making us buffer
|
|
585
|
+
// an unbounded amount; the platform's cap remains the primary guard.
|
|
586
|
+
const declared = Number(request.headers.get('content-length') ?? '')
|
|
587
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
588
|
+
return json({ ok: false, error: `请求体过大(上限 ${String(MAX_BODY_BYTES)} 字节)` }, 413)
|
|
589
|
+
}
|
|
590
|
+
|
|
562
591
|
// Everything below writes, so the body is read and parsed exactly once.
|
|
563
592
|
let parsed
|
|
564
593
|
try {
|
|
@@ -611,7 +640,10 @@ export function apply(ctx) {
|
|
|
611
640
|
* prompt,而这个文件正是"用户的提示词"。
|
|
612
641
|
*/
|
|
613
642
|
function writeAtomic(file, text) {
|
|
614
|
-
|
|
643
|
+
// 临时名必须**每个请求唯一**:只带 pid 时,同一进程内两个并发保存会争同一个临时名,
|
|
644
|
+
// Windows 上两个 rename 指向同一目标会以 EPERM 失败(实测:10 并发保存 2 例 400),
|
|
645
|
+
// 而 POSIX 上 rename 原子覆盖、静默地后写胜出 —— 也就是说这个缺陷只在 Windows 显现。
|
|
646
|
+
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
615
647
|
writeFileSync(temporary, text, 'utf8')
|
|
616
648
|
renameSync(temporary, file)
|
|
617
649
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Custom modes and custom prompts for DeepSeek Harness (dsh): edit a mode's system prompt on the settings page (it takes effect on the next model step), choose its base mode, switch plugins row by row, and keep several assistants side by side.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/preset/prompt-tool.mjs
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* needs no isolate realm.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { randomBytes } from 'node:crypto'
|
|
29
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
29
30
|
import { dirname } from 'node:path'
|
|
30
31
|
import { fileURLToPath } from 'node:url'
|
|
31
32
|
|
|
@@ -188,7 +189,10 @@ function makeDefinition(modeName) {
|
|
|
188
189
|
if (verdict.ok !== true) return verdict.error
|
|
189
190
|
try {
|
|
190
191
|
mkdirSync(dirname(PROMPT_PATH), { recursive: true })
|
|
191
|
-
|
|
192
|
+
// 与设置页同一条纪律:临时文件 + rename。读取器(prompt-reader.mjs)按 mtime+size 缓存,
|
|
193
|
+
// 非原子写会让它有机会读到写了一半的提示词 —— 设置页早已改用原子写,这里原先还是
|
|
194
|
+
// 直接 writeFileSync,属于"自我标准不一致"。
|
|
195
|
+
writeAtomic(PROMPT_PATH, args.text)
|
|
192
196
|
return '已写入 ' + PROMPT_PATH + '(' + String(args.text.length) + ' 字符)。本会话下一步模型调用即使用新提示词。'
|
|
193
197
|
} catch (error) {
|
|
194
198
|
return '写入失败:' + String((error && error.message) || error)
|
|
@@ -204,3 +208,15 @@ export function apply(ctx, config = {}) {
|
|
|
204
208
|
const definition = makeDefinition(resolveModeName(config))
|
|
205
209
|
ctx.effect(() => ctx.tools.register(definition), 'custom-prompt.tool')
|
|
206
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 写文件:同目录临时文件 + rename(rename 在同一文件系统内原子)。
|
|
214
|
+
*
|
|
215
|
+
* 临时名带 pid 与随机后缀:同一进程内的并发写必须各用各的临时名,否则 Windows 上两个
|
|
216
|
+
* rename 指向同一目标会以 EPERM 失败。
|
|
217
|
+
*/
|
|
218
|
+
function writeAtomic(file, text) {
|
|
219
|
+
const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
|
|
220
|
+
writeFileSync(temporary, text, 'utf8')
|
|
221
|
+
renameSync(temporary, file)
|
|
222
|
+
}
|