dsh-custom-mode 1.0.2 → 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/client.js +3 -1
- package/index.mjs +130 -164
- package/package.json +1 -1
- package/preset/prompt-tool.mjs +18 -2
package/client.js
CHANGED
package/index.mjs
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host half: the 「自定义模式」 settings page —
|
|
2
|
+
* Host half: the 「自定义模式」 settings page — an ASSISTANT MANAGER.
|
|
3
3
|
*
|
|
4
|
-
* It
|
|
4
|
+
* It registers five exact routes on the platform's shared `/api` channel; the browser half calls them
|
|
5
5
|
* with `fetch`:
|
|
6
6
|
*
|
|
7
|
-
* GET /custom-mode — every assistant this feature manages.
|
|
8
|
-
* GET /custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
|
|
9
|
-
* POST /custom-mode/state — { id, mode, overrides, prompt, name, description }:
|
|
10
|
-
*
|
|
11
|
-
* POST /custom-mode/create — { name, description }: seed a new assistant from the
|
|
12
|
-
*
|
|
13
|
-
* POST /custom-mode/delete — { id }: remove a locally authored assistant.
|
|
14
|
-
* POST /custom-mode/reorder — { id, direction }: move one assistant up/down in the
|
|
15
|
-
*
|
|
7
|
+
* GET /api/custom-mode — every assistant this feature manages.
|
|
8
|
+
* GET /api/custom-mode/state?id=… — one assistant: base mode, rows, switches, prompt.
|
|
9
|
+
* POST /api/custom-mode/state — { id, mode, overrides, prompt, name, description }:
|
|
10
|
+
* validate, render a fresh `agent.cordis.yml`, write both files.
|
|
11
|
+
* POST /api/custom-mode/create — { name, description }: seed a new assistant from the
|
|
12
|
+
* packaged template and give it the standard base mode.
|
|
13
|
+
* POST /api/custom-mode/delete — { id }: remove a locally authored assistant.
|
|
14
|
+
* POST /api/custom-mode/reorder — { id, direction }: move one assistant up/down in the
|
|
15
|
+
* picker order by writing `order` into each `preset.yml`.
|
|
16
16
|
*
|
|
17
|
-
* Why
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* **Why `/api` and not the raw `webServer` table**: the carrier that owns the `/api` channel applies the
|
|
18
|
+
* platform's trust and authentication policy — loopback/`trustedHosts` Host check, `Sec-Fetch-Site`,
|
|
19
|
+
* `Origin`, and the signed browser-session cookie — *before* dispatching to a route. Registering there
|
|
20
|
+
* makes the fence part of the structure: a route cannot exist without it. Registering on the raw table
|
|
21
|
+
* instead puts the route outside that policy and leaves "check the request first" as a rule a human has
|
|
22
|
+
* to remember — and an earlier version of this plugin, doing exactly that, let an unauthenticated GET
|
|
23
|
+
* read the whole system prompt and an unauthenticated cross-site POST rewrite `prompt.md` (every official
|
|
24
|
+
* route answered 401; a plain form post needs no preflight, so CORS would not have helped either). The
|
|
25
|
+
* registered paths are absolute *including* `/api`, which is what the platform's own packages pass.
|
|
21
26
|
*
|
|
22
|
-
* Why
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* releases (see docs/ARCHITECTURE.md).
|
|
27
|
+
* **Why exact routes rather than one prefix**: the Fetch registry matches exact paths. Five registrations
|
|
28
|
+
* cost nothing and each declares the methods it owns, so the method table doubles as the guarantee that a
|
|
29
|
+
* prefetched `GET …/create` cannot create anything — that method is simply not registered for that path.
|
|
26
30
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* POST with `content-type: text/plain` rewrote `prompt.md`, while every official
|
|
31
|
-
* route answered 401 — and a cross-site form post needs no preflight, so any page
|
|
32
|
-
* the user visited could have rewritten their agent's system prompt. The route
|
|
33
|
-
* therefore runs the platform's own check first, via
|
|
34
|
-
* `ctx.connection.requestRejection(req)` (Host/Origin fence + browser auth),
|
|
35
|
-
* which is the same verdict `/api` gets.
|
|
31
|
+
* Why a private route instead of a Remote namespace or `dsh-settings`: this plugin then owns no Cordis
|
|
32
|
+
* service name and cannot collide with anything, and it stays independent of the settings API whose helper
|
|
33
|
+
* names differ between dsh releases (see docs/ARCHITECTURE.md §5).
|
|
36
34
|
*
|
|
37
35
|
* Storage is deliberately file-only and stateless: the composition file IS the
|
|
38
36
|
* saved state, so no second document can drift from it. The page derives which
|
|
39
37
|
* rows the user changed by diffing against the same shipped base mode.
|
|
40
38
|
*/
|
|
41
39
|
|
|
40
|
+
import { randomBytes } from 'node:crypto'
|
|
42
41
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
43
42
|
import { dirname, join } from 'node:path'
|
|
44
43
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
|
|
@@ -64,7 +63,7 @@ import {
|
|
|
64
63
|
userPresetRoot,
|
|
65
64
|
} from './assistants.mjs'
|
|
66
65
|
|
|
67
|
-
export { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR, PRESET_META_PATH }
|
|
66
|
+
export { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, API_PREFIX, PRESET_DIR, PRESET_META_PATH }
|
|
68
67
|
|
|
69
68
|
/** The list endpoint is the route path itself; the rest hang off it. */
|
|
70
69
|
const STATE_PATH = ROUTE_PATH + '/state'
|
|
@@ -81,6 +80,12 @@ const METHODS = {
|
|
|
81
80
|
[REORDER_PATH]: ['POST'],
|
|
82
81
|
}
|
|
83
82
|
|
|
83
|
+
/** 只告警一次:避免每个请求都刷同一行日志。 */
|
|
84
|
+
let warnedRosterShape = false
|
|
85
|
+
|
|
86
|
+
/** 应用层请求体上限;平台的 buffered cap 是第一道,这个是我们自己的兜底(见 handler 里的注释)。 */
|
|
87
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
88
|
+
|
|
84
89
|
/** Longest display name / description the page accepts, so one paste cannot bloat every picker. */
|
|
85
90
|
const MAX_NAME = 80
|
|
86
91
|
const MAX_DESCRIPTION = 400
|
|
@@ -424,92 +429,35 @@ export async function deleteAssistant(rows, input, agentPresets) {
|
|
|
424
429
|
return { ok: true, id, note: '已删除「' + id + '」。正在使用它的会话不受影响;新建会话时不再出现。' }
|
|
425
430
|
}
|
|
426
431
|
|
|
427
|
-
/** Send JSON with no-store caching, so a save is never read back stale. */
|
|
428
|
-
function sendJson(res, status, value) {
|
|
429
|
-
const body = JSON.stringify(value)
|
|
430
|
-
res.writeHead(status, {
|
|
431
|
-
'content-type': 'application/json; charset=utf-8',
|
|
432
|
-
'cache-control': 'no-store',
|
|
433
|
-
'content-length': String(Buffer.byteLength(body, 'utf8')),
|
|
434
|
-
})
|
|
435
|
-
res.end(body)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** Read a request body with a hard cap. */
|
|
439
|
-
async function readBody(req) {
|
|
440
|
-
const chunks = []
|
|
441
|
-
let size = 0
|
|
442
|
-
for await (const chunk of req) {
|
|
443
|
-
size += chunk.length
|
|
444
|
-
if (size > 4_000_000) throw new Error('请求体过大(上限 4MB)')
|
|
445
|
-
chunks.push(chunk)
|
|
446
|
-
}
|
|
447
|
-
return Buffer.concat(chunks).toString('utf8')
|
|
448
|
-
}
|
|
449
|
-
|
|
450
432
|
/**
|
|
451
|
-
* The
|
|
433
|
+
* The plugin's HTTP surface, registered on the platform's **shared `/api` channel**.
|
|
452
434
|
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
try {
|
|
461
|
-
return new URL(raw, 'http://localhost')
|
|
462
|
-
} catch {
|
|
463
|
-
return new URL(ROUTE_PATH, 'http://localhost')
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Rejection status for one request, or undefined when it may proceed.
|
|
469
|
-
*
|
|
470
|
-
* The platform's fence is verified through the Connection service, which is where
|
|
471
|
-
* the Host/Origin check and the browser-session check live:
|
|
435
|
+
* `ctx.connection.fetch.register({ path, methods, requestBody, fetch })` mounts one exact route under
|
|
436
|
+
* `/api`, and the carrier applies its trust and authentication policy **before** dispatch. That is a
|
|
437
|
+
* structural guarantee, not a convention: a route cannot exist without the fence, so "did you remember
|
|
438
|
+
* to check?" is not a question anyone has to answer. An earlier version of this plugin registered on the
|
|
439
|
+
* raw `ctx.webServer` table and called `ctx.connection.requestRejection` by hand — measured then, an
|
|
440
|
+
* unauthenticated GET returned the whole system prompt and an unauthenticated cross-site POST rewrote
|
|
441
|
+
* `prompt.md`, while every official route answered 401.
|
|
472
442
|
*
|
|
473
|
-
*
|
|
474
|
-
* rebinding, where the socket reaches this server but the Host names the
|
|
475
|
-
* attacker's domain;
|
|
476
|
-
* - `Sec-Fetch-Site: cross-site` and a mismatching `Origin` are refused → defeats
|
|
477
|
-
* a malicious page posting to this local port (a simple form post needs no
|
|
478
|
-
* preflight, so CORS alone would not have stopped it);
|
|
479
|
-
* - the signed `dsh-auth-*` cookie must be present → without the browser session
|
|
480
|
-
* that the launch URL establishes, the route is closed.
|
|
443
|
+
* Measured on 0.1.6-alpha.2 with a throwaway probe plugin (see `docs/MEASUREMENTS.md` §16):
|
|
481
444
|
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
445
|
+
* GET /api/<route> no session cookie → 401 unauthorized
|
|
446
|
+
* GET /api/<route> Origin: https://evil.example cross-site → 403
|
|
447
|
+
* GET /api/<route> Host: evil.example DNS-rebinding shape → 403
|
|
448
|
+
* GET /api/<route> session cookie → 200
|
|
449
|
+
* POST /api/<route> on a route that owns GET only → 404 (never dispatched)
|
|
486
450
|
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* system prompt is a silent one.
|
|
451
|
+
* The paths are absolute *including* `/api` — that is what the platform's own packages pass
|
|
452
|
+
* (`/api/present.host`, `/api/changes.summary`), despite the type saying "below /api".
|
|
490
453
|
*/
|
|
491
|
-
|
|
492
|
-
function connectionRejection(ctx, req) {
|
|
493
|
-
const connection = ctx.get('connection')
|
|
494
|
-
if (connection !== undefined && typeof connection.requestRejection === 'function') {
|
|
495
|
-
return connection.requestRejection(req)
|
|
496
|
-
}
|
|
497
|
-
if (!warnedMissingConnection) {
|
|
498
|
-
warnedMissingConnection = true
|
|
499
|
-
console.error(
|
|
500
|
-
'custom-mode: connection 服务不可用(DSH 版本不匹配?),已拒绝该设置页的所有请求以保守处理。' +
|
|
501
|
-
'prompt.md 与 custom_prompt 工具不受影响。',
|
|
502
|
-
)
|
|
503
|
-
}
|
|
504
|
-
return 503
|
|
505
|
-
}
|
|
454
|
+
const API_PREFIX = '/api'
|
|
506
455
|
|
|
507
456
|
/**
|
|
508
457
|
* Where the settings page can exist at all.
|
|
509
458
|
*
|
|
510
|
-
* The page is a WEB page: without `
|
|
511
|
-
*
|
|
512
|
-
* neither.
|
|
459
|
+
* The page is a WEB page: without `connection` there is no `/api` channel to register on, and without
|
|
460
|
+
* `agentPresets` the assistant list cannot be built. The tui profile has neither.
|
|
513
461
|
*
|
|
514
462
|
* These must NOT go into the row's own `inject`. Measured on 0.1.6-alpha.1:
|
|
515
463
|
* `./install.sh --profile tui` — a usage both `install.sh --help` and the READMEs
|
|
@@ -520,11 +468,11 @@ function connectionRejection(ctx, req) {
|
|
|
520
468
|
* custom-mode (dsh-custom-mode): pending (waiting for services: webServer, agentPresets)
|
|
521
469
|
*
|
|
522
470
|
* That is the SAME line a broken installation prints, so it teaches users to ignore the
|
|
523
|
-
* one warning that matters. Instead the row always activates, and the
|
|
471
|
+
* one warning that matters. Instead the row always activates, and the routes are
|
|
524
472
|
* registered from a scoped fiber that waits for those two services (`ctx.inject`),
|
|
525
473
|
* which is the dynamic form of the same declaration.
|
|
526
474
|
*/
|
|
527
|
-
const WEB_SERVICES = ['
|
|
475
|
+
const WEB_SERVICES = ['connection', 'agentPresets']
|
|
528
476
|
|
|
529
477
|
export function apply(ctx) {
|
|
530
478
|
// Before anything else: make sure the preset tree on disk is complete.
|
|
@@ -549,10 +497,14 @@ export function apply(ctx) {
|
|
|
549
497
|
// Compatibility guard: this plugin reads host APIs that a future DSH release could
|
|
550
498
|
// reshape. Check them once and say so plainly, instead of letting every request
|
|
551
499
|
// fail with an opaque 500.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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)
|
|
556
508
|
if (missing.length > 0) {
|
|
557
509
|
console.error(
|
|
558
510
|
'custom-mode: 当前 DSH 版本缺少所需 API:' +
|
|
@@ -586,86 +538,97 @@ export function apply(ctx) {
|
|
|
586
538
|
/** The roster, or an empty list — discovery itself reports broken rows rather than throwing. */
|
|
587
539
|
const roster = async () => {
|
|
588
540
|
const rows = await scope.agentPresets.list()
|
|
589
|
-
|
|
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
|
|
590
551
|
}
|
|
591
552
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
553
|
+
/**
|
|
554
|
+
* One Fetch-shaped handler for the whole surface.
|
|
555
|
+
*
|
|
556
|
+
* `pathname` is the logical path (without the `/api` prefix) so the method table above stays the
|
|
557
|
+
* single place where the surface is described.
|
|
558
|
+
*/
|
|
559
|
+
/**
|
|
560
|
+
* JSON response with the page's caching policy attached.
|
|
561
|
+
*
|
|
562
|
+
* `no-store` because every one of these answers is state the page then displays: a cached
|
|
563
|
+
* `GET /api/custom-mode/state` would show the user rows they already changed. (The old
|
|
564
|
+
* hand-rolled response helper set the same header; dropping it here would have been a silent
|
|
565
|
+
* regression.)
|
|
566
|
+
*/
|
|
567
|
+
const json = (value, status = 200) =>
|
|
568
|
+
Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
|
|
605
569
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
if (
|
|
610
|
-
sendJson(res, 404, { ok: false, error: '未知的子路径:' + pathname })
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
// Per-path method table, not a global GET/POST gate: `create` and `delete`
|
|
614
|
-
// are POST-only, and letting a GET fall through to them would create an
|
|
615
|
-
// assistant off a link a browser prefetched.
|
|
616
|
-
if (!methods.includes(req.method)) {
|
|
617
|
-
sendJson(res, 405, { ok: false, error: '只支持 ' + methods.join(' 与 ') })
|
|
618
|
-
return
|
|
619
|
-
}
|
|
620
|
-
if (req.method === 'GET' && pathname === ROUTE_PATH) {
|
|
570
|
+
const handle = async (request, pathname) => {
|
|
571
|
+
try {
|
|
572
|
+
const url = new URL(request.url)
|
|
573
|
+
if (request.method === 'GET' && pathname === ROUTE_PATH) {
|
|
621
574
|
await ensureShipped()
|
|
622
|
-
|
|
623
|
-
return
|
|
575
|
+
return json(readList(await roster()))
|
|
624
576
|
}
|
|
625
|
-
if (
|
|
577
|
+
if (request.method === 'GET' && pathname === STATE_PATH) {
|
|
626
578
|
await ensureShipped()
|
|
627
|
-
|
|
628
|
-
|
|
579
|
+
return json(readState(await roster(), url.searchParams.get('id') ?? ''))
|
|
580
|
+
}
|
|
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)
|
|
629
589
|
}
|
|
630
590
|
|
|
631
591
|
// Everything below writes, so the body is read and parsed exactly once.
|
|
632
|
-
const raw = await readBody(req)
|
|
633
592
|
let parsed
|
|
634
593
|
try {
|
|
635
|
-
parsed =
|
|
594
|
+
parsed = await request.json()
|
|
636
595
|
} catch {
|
|
637
|
-
|
|
638
|
-
return
|
|
596
|
+
return json({ ok: false, error: '请求体不是合法 JSON' }, 400)
|
|
639
597
|
}
|
|
640
598
|
await ensureShipped()
|
|
641
599
|
if (pathname === STATE_PATH) {
|
|
642
600
|
const result = saveState(await roster(), parsed)
|
|
643
|
-
|
|
644
|
-
return
|
|
601
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
645
602
|
}
|
|
646
603
|
if (pathname === CREATE_PATH) {
|
|
647
604
|
const result = createAssistant(await roster(), parsed)
|
|
648
|
-
|
|
649
|
-
return
|
|
605
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
650
606
|
}
|
|
651
607
|
if (pathname === REORDER_PATH) {
|
|
652
608
|
const result = reorderAssistant(await roster(), parsed)
|
|
653
|
-
|
|
654
|
-
return
|
|
609
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
655
610
|
}
|
|
656
611
|
const result = await deleteAssistant(await roster(), parsed, scope.agentPresets)
|
|
657
|
-
|
|
612
|
+
return json(result, result.ok === true ? 200 : 400)
|
|
658
613
|
} catch (error) {
|
|
659
|
-
|
|
614
|
+
return json({ ok: false, error: describe(error) }, 500)
|
|
660
615
|
}
|
|
661
616
|
}
|
|
662
617
|
|
|
663
|
-
//
|
|
664
|
-
// the
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
618
|
+
// One registration per exact path — the platform's Fetch registry matches exact paths, not prefixes —
|
|
619
|
+
// each declaring the methods it owns. The method table doubles as the guarantee that a prefetched
|
|
620
|
+
// `GET /api/custom-mode/create` cannot create anything: that method is not registered for that path.
|
|
621
|
+
for (const [pathname, methods] of Object.entries(METHODS)) {
|
|
622
|
+
scope.effect(
|
|
623
|
+
() => scope.connection.fetch.register({
|
|
624
|
+
path: API_PREFIX + pathname,
|
|
625
|
+
methods: [...methods],
|
|
626
|
+
requestBody: 'buffered',
|
|
627
|
+
fetch: (request) => handle(request, pathname),
|
|
628
|
+
}),
|
|
629
|
+
'custom-mode.route' + pathname,
|
|
630
|
+
)
|
|
631
|
+
}
|
|
669
632
|
})
|
|
670
633
|
}
|
|
671
634
|
|
|
@@ -677,7 +640,10 @@ export function apply(ctx) {
|
|
|
677
640
|
* prompt,而这个文件正是"用户的提示词"。
|
|
678
641
|
*/
|
|
679
642
|
function writeAtomic(file, text) {
|
|
680
|
-
|
|
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')}`
|
|
681
647
|
writeFileSync(temporary, text, 'utf8')
|
|
682
648
|
renameSync(temporary, file)
|
|
683
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
|
+
}
|