dsh-custom-mode 1.9.9 → 1.9.11
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 +1 -1
- package/composition.mjs +113 -8
- package/index.mjs +179 -13
- package/package.json +4 -3
- package/preset-backend/declarative.mjs +180 -0
- package/preset-backend/detect.mjs +101 -0
- package/preset-backend/index.mjs +143 -0
- package/preset-backend/parse-composition.mjs +177 -0
- package/seed.mjs +6 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ The mode is **web-profile only**: `agent-presets`, the service that mounts prese
|
|
|
47
47
|
|
|
48
48
|
## Requirements
|
|
49
49
|
|
|
50
|
-
- **dsh `>=0.1.5-rc.2 <0.2.0-0`**(latest stable `0.1.5-rc.2` and
|
|
50
|
+
- **dsh `>=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0`**(latest stable `0.1.5-rc.2` and the preview line `0.1.6-alpha.2` / `0.1.7-alpha.*`)** — declared in `engines.dsh` and as an *optional* peer range. This package never
|
|
51
51
|
imports `@deepseek-ai/dsh`; it only reads the host services dsh injects.
|
|
52
52
|
- **No build step, no dependencies**: the source is published as-is and uses only Node's standard library.
|
|
53
53
|
- The package version is its own line (`1.0.0`, `1.0.1`, …). Which dsh it supports is declared in
|
package/composition.mjs
CHANGED
|
@@ -348,6 +348,74 @@ function evalDisabledExpression(expression) {
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
/** Build one row's UI description from its segment text. */
|
|
351
|
+
/**
|
|
352
|
+
* A row's module specifier: the `name:` line.
|
|
353
|
+
*
|
|
354
|
+
* Needed by the declarative backend (dsh ≥ 0.1.7), which must hand `register()` a real row list
|
|
355
|
+
* rather than a composition file on disk. It is also where the **relative specifiers this feature
|
|
356
|
+
* emits** (`'./prompt-reader.mjs'`, `'./prompt-tool.mjs'`) surface: on the file-scanning line those
|
|
357
|
+
* resolve against the preset directory, but `register()` mounts under the *profile's* baseUrl, so the
|
|
358
|
+
* caller has to rewrite them to absolute `file://` URLs. Measured on 0.1.7-alpha.2: a relative
|
|
359
|
+
* specifier yields `broken: … never started`, an absolute one mounts.
|
|
360
|
+
*
|
|
361
|
+
* @param {string} segmentText - one row's text.
|
|
362
|
+
* @returns {string|null} the specifier with surrounding quotes stripped, or null when the row has none.
|
|
363
|
+
*/
|
|
364
|
+
export function moduleNameOf(segmentText) {
|
|
365
|
+
const match = /^\s*name:\s*(.+?)\s*$/m.exec(segmentText)
|
|
366
|
+
if (match === null) return null
|
|
367
|
+
return match[1].replace(/^['"]|['"]$/g, '')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A row's `config:` block, as a plain object.
|
|
372
|
+
*
|
|
373
|
+
* Deliberately a **small, strict YAML subset**, not a general parser: the only files this reads are
|
|
374
|
+
* the ones this feature writes (`renderComposition`), whose config blocks are flat scalars. Anything
|
|
375
|
+
* it cannot represent is skipped rather than guessed — a wrong value here would silently change what
|
|
376
|
+
* the model sees. Callers that need certainty should compare against {@link moduleNameOf}.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} segmentText - one row's text.
|
|
379
|
+
* @returns {object|null} the config object, or null when the row has no `config:` block.
|
|
380
|
+
*/
|
|
381
|
+
export function configOf(segmentText) {
|
|
382
|
+
const lines = segmentText.split('\n')
|
|
383
|
+
const start = lines.findIndex((line) => /^\s*config:\s*$/.test(line))
|
|
384
|
+
if (start === -1) return null
|
|
385
|
+
const baseIndent = lines[start].search(/\S/)
|
|
386
|
+
const out = {}
|
|
387
|
+
let any = false
|
|
388
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
389
|
+
const line = lines[i]
|
|
390
|
+
if (line.trim() === '') continue
|
|
391
|
+
const indent = line.search(/\S/)
|
|
392
|
+
if (indent <= baseIndent) break
|
|
393
|
+
const match = /^\s*([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line)
|
|
394
|
+
if (match === null) continue
|
|
395
|
+
out[match[1]] = parseScalar(match[2])
|
|
396
|
+
any = true
|
|
397
|
+
}
|
|
398
|
+
return any ? out : null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Parse one YAML scalar into a JS value, staying conservative: unknown shapes stay strings. */
|
|
402
|
+
function parseScalar(raw) {
|
|
403
|
+
const value = raw.trim()
|
|
404
|
+
if (value === '') return ''
|
|
405
|
+
if (value === 'true') return true
|
|
406
|
+
if (value === 'false') return false
|
|
407
|
+
if (value === 'null' || value === '~') return null
|
|
408
|
+
if (/^-?\d+$/.test(value)) return Number(value)
|
|
409
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(value) || /^'(?:[^']|'')*'$/.test(value)) {
|
|
410
|
+
try {
|
|
411
|
+
return JSON.parse(value[0] === '"' ? value : `"${value.slice(1, -1).replace(/'/g, "\\'")}"`)
|
|
412
|
+
} catch {
|
|
413
|
+
return value.slice(1, -1)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return value
|
|
417
|
+
}
|
|
418
|
+
|
|
351
419
|
function describeRow(id, segmentText, children) {
|
|
352
420
|
const own = disabledOf(segmentText)
|
|
353
421
|
const meta = ROW_META[id] ?? {}
|
|
@@ -361,6 +429,9 @@ function describeRow(id, segmentText, children) {
|
|
|
361
429
|
group: children.length > 0,
|
|
362
430
|
disabled: literalOff || fromExpression === true,
|
|
363
431
|
disabledExpression: own.present && own.value.startsWith('!!js') ? own.value : null,
|
|
432
|
+
// 新线(声明式注册表)要把行交给 register(),需要模块说明符与 config —— 见 moduleNameOf。
|
|
433
|
+
moduleName: moduleNameOf(segmentText),
|
|
434
|
+
config: configOf(segmentText),
|
|
364
435
|
label: meta.label ?? id,
|
|
365
436
|
essential: meta.essential === true,
|
|
366
437
|
note: meta.note ?? null,
|
|
@@ -604,16 +675,45 @@ export function disableRowsInPlace(text, ids) {
|
|
|
604
675
|
return applyLevel(text, true, off, false, false)
|
|
605
676
|
}
|
|
606
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Module names this installation actually has, injected by the host half.
|
|
680
|
+
*
|
|
681
|
+
* Why this exists: {@link unresolvableRows} decides "can this line run here?" by walking the install's
|
|
682
|
+
* `node_modules`. That works on the file-scanning dsh line, but **0.1.7 has no `dsh-agent-presets`
|
|
683
|
+
* package and therefore no directory for `shippedPresetsDir()` to find** — the lookup threw, the catch
|
|
684
|
+
* returned `[]`, and the check silently stopped checking anything. On that line the host half has a
|
|
685
|
+
* better source: `agentPresets.compositionInventory()` reports every row of every registered preset with
|
|
686
|
+
* its `moduleName`, which is exactly this set.
|
|
687
|
+
*
|
|
688
|
+
* When a set is injected it **replaces** the filesystem probe (it is strictly better information on the
|
|
689
|
+
* line that needs it). `null` means "not injected" — legacy behaviour.
|
|
690
|
+
*/
|
|
691
|
+
let knownModuleNames = null
|
|
692
|
+
|
|
693
|
+
/** @param {Iterable<string>|null} names - module specifiers known to be installable here. */
|
|
694
|
+
export function setKnownModuleNames(names) {
|
|
695
|
+
knownModuleNames = names === null || names === undefined ? null : new Set(names)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** The injected set, or null when the filesystem probe is in charge. Exported for tests/diagnostics. */
|
|
699
|
+
export function knownModuleNamesInjected() {
|
|
700
|
+
return knownModuleNames
|
|
701
|
+
}
|
|
702
|
+
|
|
607
703
|
export function unresolvableRows(text) {
|
|
608
704
|
let root
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
705
|
+
let fromFilesystem = false
|
|
706
|
+
if (knownModuleNames === null) {
|
|
707
|
+
try {
|
|
708
|
+
root = join(shippedPresetsDir(), '..', '..', '..')
|
|
709
|
+
fromFilesystem = true
|
|
710
|
+
} catch {
|
|
711
|
+
return []
|
|
712
|
+
}
|
|
713
|
+
// **判断不了就不要报警**:如果这个根下根本没有 node_modules(例如测试用的是一个临时出厂目录),
|
|
714
|
+
// 那么"查不到某个包"只说明我们不知道,不说明那行坏了。误报的代价是用户被引导去关掉本来正常的行。
|
|
715
|
+
if (existsSync(join(root, '@deepseek-ai')) === false) return []
|
|
613
716
|
}
|
|
614
|
-
// **判断不了就不要报警**:如果这个根下根本没有 node_modules(例如测试用的是一个临时出厂目录),
|
|
615
|
-
// 那么"查不到某个包"只说明我们不知道,不说明那行坏了。误报的代价是用户被引导去关掉本来正常的行。
|
|
616
|
-
if (existsSync(join(root, '@deepseek-ai')) === false) return []
|
|
617
717
|
const out = []
|
|
618
718
|
const lines = text.split('\n')
|
|
619
719
|
for (let index = 0; index < lines.length; index += 1) {
|
|
@@ -649,7 +749,12 @@ export function unresolvableRows(text) {
|
|
|
649
749
|
if (name.startsWith('.') || name.startsWith('cordis:')) continue
|
|
650
750
|
const parts = name.split('/')
|
|
651
751
|
const pkg = name.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
|
|
652
|
-
|
|
752
|
+
// 注入了集合就用集合(新线:来自 compositionInventory 的 moduleName)——
|
|
753
|
+
// 子路径说明符(`@scope/pkg/sub`)与它的包名都算命中,否则会把健康行误报成坏行。
|
|
754
|
+
const present = fromFilesystem
|
|
755
|
+
? existsSync(join(root, pkg))
|
|
756
|
+
: knownModuleNames.has(name) || knownModuleNames.has(pkg)
|
|
757
|
+
if (present === false) out.push({ id: row[2], name })
|
|
653
758
|
}
|
|
654
759
|
return out
|
|
655
760
|
}
|
package/index.mjs
CHANGED
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { writeAtomic, writeAtomicPair } from './atomic.mjs'
|
|
41
|
+
import {
|
|
42
|
+
BACKEND_DECLARATIVE,
|
|
43
|
+
BACKEND_UNUSABLE,
|
|
44
|
+
createDeclarativeBackend,
|
|
45
|
+
describeBackend,
|
|
46
|
+
detectPresetBackend,
|
|
47
|
+
effectiveRosterRows,
|
|
48
|
+
} from './preset-backend/index.mjs'
|
|
41
49
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
42
50
|
import { dirname, join } from 'node:path'
|
|
43
51
|
import { PROMPT_PATH, COMPOSITION_PATH, ROUTE_PATH, PRESET_DIR } from './paths.mjs'
|
|
@@ -48,6 +56,7 @@ import {
|
|
|
48
56
|
modeOf,
|
|
49
57
|
overridesOf,
|
|
50
58
|
readBaseComposition,
|
|
59
|
+
setKnownModuleNames,
|
|
51
60
|
renderComposition,
|
|
52
61
|
setShippedPresetsDir,
|
|
53
62
|
unresolvableRows,
|
|
@@ -692,6 +701,11 @@ const API_PREFIX = '/api'
|
|
|
692
701
|
const WEB_SERVICES = ['connection', 'agentPresets']
|
|
693
702
|
|
|
694
703
|
export function apply(ctx) {
|
|
704
|
+
// 助手目录的根(`$DSH_HOME/.agent-presets`)。
|
|
705
|
+
// 两条线共用它:旧线由平台扫描这个目录,新线不再扫描但**插件仍然把 composition 写在这里**
|
|
706
|
+
// —— 它是用户可见、可迁移、可手改的真相,也是旧线唯一的输入。
|
|
707
|
+
const assistantRoot = dirname(PRESET_DIR)
|
|
708
|
+
|
|
695
709
|
// Before anything else: make sure the preset tree on disk is complete.
|
|
696
710
|
//
|
|
697
711
|
// A storefront install is a single command (`dsh plugin --profile web add
|
|
@@ -706,7 +720,7 @@ export function apply(ctx) {
|
|
|
706
720
|
// the assistant registry for why a deleted assistant must not come back.
|
|
707
721
|
try {
|
|
708
722
|
seedOnActivation({
|
|
709
|
-
root:
|
|
723
|
+
root: assistantRoot,
|
|
710
724
|
templateDir: packagedPresetDir(),
|
|
711
725
|
// 组成文件**不照搬包内模板**,而是按本机装的那条 dsh 线派生:模板是某一条线渲染出来的,
|
|
712
726
|
// 另一条线可能根本没有它的某些行(实测:预览线的 workflow-ptc 在稳定线上不存在,
|
|
@@ -717,26 +731,52 @@ export function apply(ctx) {
|
|
|
717
731
|
console.error('custom-mode: 初始化 preset 目录时出现意外错误(已忽略): ' + describe(error))
|
|
718
732
|
}
|
|
719
733
|
|
|
720
|
-
|
|
734
|
+
try {
|
|
735
|
+
ctx.inject(WEB_SERVICES, (scope) => {
|
|
721
736
|
// Compatibility guard: this plugin reads host APIs that a future DSH release could
|
|
722
737
|
// reshape. Check them once and say so plainly, instead of letting every request
|
|
723
738
|
// fail with an opaque 500.
|
|
724
739
|
// 数据表形式:以后新增一处耦合点,只要在这里加一行 —— README 的耦合点清单与本表同源,
|
|
725
740
|
// 让"上游改了 API 形状"在启动日志里就能看见,而不是等用户报"设置页白屏"。
|
|
726
|
-
|
|
741
|
+
//
|
|
742
|
+
// ★ 分两级,而不是一张"缺一即死"的表。理由是一次实测:`agentPresets.remove()` 在
|
|
743
|
+
// dsh 0.1.7 上**不存在**(preset 定义改在内存里,删除要走 register() 返回的 disposer),
|
|
744
|
+
// 而本插件早已为它写了 noRemoveApi 降级文案 —— 把它列进必需项,会让**整个设置页**
|
|
745
|
+
// 在 0.1.7 上被自己关掉(实测 stderr:'设置页将不可用')。
|
|
746
|
+
// 能力缺失该逐个降级,不该一票否决整页。
|
|
747
|
+
const CORE_APIS = [
|
|
727
748
|
['agentPresets.list()', () => typeof scope.agentPresets?.list === 'function'],
|
|
728
|
-
['agentPresets.remove()', () => typeof scope.agentPresets?.remove === 'function'],
|
|
729
749
|
['connection.fetch.register()', () => typeof scope.connection?.fetch?.register === 'function'],
|
|
730
750
|
]
|
|
731
|
-
const
|
|
732
|
-
|
|
751
|
+
const OPTIONAL_APIS = [
|
|
752
|
+
// 旧线的文件系统语义;新线由 preset-backend 的 declarative 后端用 disposer 补上。
|
|
753
|
+
['agentPresets.remove()', () => typeof scope.agentPresets?.remove === 'function'],
|
|
754
|
+
['agentPresets.copy()', () => typeof scope.agentPresets?.copy === 'function'],
|
|
755
|
+
]
|
|
756
|
+
const missingCore = CORE_APIS.filter(([, probe]) => !probe()).map(([name]) => name)
|
|
757
|
+
if (missingCore.length > 0) {
|
|
733
758
|
console.error(
|
|
734
|
-
'custom-mode: 当前 DSH
|
|
735
|
-
|
|
759
|
+
'custom-mode: 当前 DSH 版本缺少必需 API:' +
|
|
760
|
+
missingCore.join('、') +
|
|
736
761
|
'。设置页将不可用,请核对 DSH 版本或提 issue。',
|
|
737
762
|
)
|
|
738
763
|
return
|
|
739
764
|
}
|
|
765
|
+
const absentOptional = OPTIONAL_APIS.filter(([, probe]) => !probe()).map(([name]) => name)
|
|
766
|
+
|
|
767
|
+
// 判据是**能力**不是版本号:0.1.7 仍是 alpha,按版本号写的分支会在下一次发布时静默失配。
|
|
768
|
+
const backendVerdict = detectPresetBackend(scope)
|
|
769
|
+
console.log(
|
|
770
|
+
'custom-mode: ' +
|
|
771
|
+
describeBackend(backendVerdict) +
|
|
772
|
+
(absentOptional.length > 0 ? ' · 可选能力缺失: ' + absentOptional.join('、') : ''),
|
|
773
|
+
)
|
|
774
|
+
if (backendVerdict.id === BACKEND_UNUSABLE) {
|
|
775
|
+
for (const reason of backendVerdict.reasons) console.error('custom-mode: ' + reason)
|
|
776
|
+
// 显式不可用,好过用错后端去写用户的 preset。
|
|
777
|
+
console.error('custom-mode: 设置页将不可用(本插件不会写入任何 preset)。')
|
|
778
|
+
return
|
|
779
|
+
}
|
|
740
780
|
|
|
741
781
|
/**
|
|
742
782
|
* Resolve the shipped-preset directory through the roster, which reports each
|
|
@@ -759,8 +799,17 @@ export function apply(ctx) {
|
|
|
759
799
|
return shippedReady
|
|
760
800
|
}
|
|
761
801
|
|
|
762
|
-
/**
|
|
763
|
-
|
|
802
|
+
/**
|
|
803
|
+
* 声明式注册表(dsh ≥ 0.1.7)下 preset 必须由插件自己注册;旧线只需把文件写到磁盘、平台自己去扫。
|
|
804
|
+
* 这里是后端的唯一实例(旧线为 null,所有相关分支都是空操作)。
|
|
805
|
+
*/
|
|
806
|
+
const declarative =
|
|
807
|
+
backendVerdict.id === BACKEND_DECLARATIVE
|
|
808
|
+
? createDeclarativeBackend({ scope, log: console.log, warn: console.error })
|
|
809
|
+
: null
|
|
810
|
+
|
|
811
|
+
/** 原始 roster:只读、无副作用。同步逻辑走它,避免与 {@link roster} 互相递归。 */
|
|
812
|
+
const rawRoster = async () => {
|
|
764
813
|
const rows = await scope.agentPresets.list()
|
|
765
814
|
if (!Array.isArray(rows)) {
|
|
766
815
|
// 形状变了:静默返回空列表会让页面显示"一个助手都没有",比报错更难查(曾经就因为
|
|
@@ -771,9 +820,114 @@ export function apply(ctx) {
|
|
|
771
820
|
}
|
|
772
821
|
return []
|
|
773
822
|
}
|
|
774
|
-
|
|
823
|
+
// 新线的 list() 只有显示元数据(没有 trust / path),而 roster 的消费者(assistants.mjs、
|
|
824
|
+
// 整个路由层)都建立在这两个字段上。在这里从磁盘补出来,下游就不必做线判断。
|
|
825
|
+
return effectiveRosterRows(rows, { root: assistantRoot, backendId: backendVerdict.id })
|
|
775
826
|
}
|
|
776
827
|
|
|
828
|
+
/**
|
|
829
|
+
* 把磁盘上的助手同步进注册表 —— **仅新线**(旧线上 `declarative` 为 null,整体是空操作)。
|
|
830
|
+
*
|
|
831
|
+
* 新线没有目录扫描,注册与注销都由本插件负责:不跑这一步,用户在设置页里新建/改名/删除的
|
|
832
|
+
* 助手就不会出现在选择器里。所以每次**写操作之后**都要重来一遍。
|
|
833
|
+
*/
|
|
834
|
+
const doSync = async () => {
|
|
835
|
+
// 先把"本机有哪些模块"喂给 composition 层。
|
|
836
|
+
//
|
|
837
|
+
// 它原本靠文件系统探测出厂目录(`shippedPresetsDir()` 往上推三层找 node_modules),
|
|
838
|
+
// 而 **0.1.7 没有那个目录**:探测抛错、catch 返回 []、于是"本行能否在本机运行"的检查
|
|
839
|
+
// **静默失效**(不报错,也不报警)。新线上有更好的来源 ——注册表自己就知道每个已注册
|
|
840
|
+
// preset 的每一行的 moduleName。
|
|
841
|
+
// 放在这里而不是外面:`ctx.inject` 的回调不是 async(写成 await 会让模块直接语法错误)。
|
|
842
|
+
if (declarative !== null) {
|
|
843
|
+
try {
|
|
844
|
+
const inventory = await scope.agentPresets.compositionInventory()
|
|
845
|
+
const rows = (Array.isArray(inventory) ? inventory : []).flatMap((preset) => preset?.rows ?? [])
|
|
846
|
+
const names = rows.map((row) => row.moduleName).filter((name) => typeof name === 'string')
|
|
847
|
+
if (names.length > 0) {
|
|
848
|
+
setKnownModuleNames(names)
|
|
849
|
+
console.log(`custom-mode: 已注入 ${names.length} 个出厂行模块名(compositionInventory),供"本行能否在本机运行"的判定使用`)
|
|
850
|
+
}
|
|
851
|
+
} catch (error) {
|
|
852
|
+
// 注入不了就退回文件系统判定 —— 那是旧线的正常路径,不是错误状态。
|
|
853
|
+
console.error('custom-mode: 读取 compositionInventory 失败(回退文件系统判定): ' + describe(error))
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (declarative === null) return
|
|
857
|
+
try {
|
|
858
|
+
const rows = await rawRoster()
|
|
859
|
+
const targets = []
|
|
860
|
+
for (const assistant of assistantsFromRoster(rows)) {
|
|
861
|
+
const dir = assistantDir(rows, assistant.id)
|
|
862
|
+
if (dir !== undefined) targets.push({ ...assistant, dir })
|
|
863
|
+
}
|
|
864
|
+
const results = await declarative.sync(targets)
|
|
865
|
+
|
|
866
|
+
// 核对每个助手的 composition:有没有"本机装不了的启用行"。
|
|
867
|
+
//
|
|
868
|
+
// 为什么必须由我们说出来:新线没有出厂 composition 可派生(`seedOnActivation` 只能回落到
|
|
869
|
+
// 包内模板),而模板可能来自**另一条** dsh 线。平台对含未知行的 preset 的处置是
|
|
870
|
+
// **静默把整个模式从所有选择器里丢掉**,设置页却照常工作 —— 用户只会看到"我的模式不见了"。
|
|
871
|
+
// 这一步把它变成一条指名道姓的日志。
|
|
872
|
+
for (const target of targets) {
|
|
873
|
+
try {
|
|
874
|
+
const text = readFileSync(join(target.dir, 'agent.cordis.yml'), 'utf8')
|
|
875
|
+
const bad = unresolvableRows(text)
|
|
876
|
+
if (bad.length > 0) {
|
|
877
|
+
console.error(
|
|
878
|
+
`custom-mode: 助手 "${target.id}" 有 ${bad.length} 行在本机装不了 —— ` +
|
|
879
|
+
bad.map((b) => `${b.id}(${b.name})`).join('、') +
|
|
880
|
+
'。含这种行的模式会被平台静默地从选择器里丢掉;可在设置页用"按本线修复"关掉它们。',
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
} catch (error) {
|
|
884
|
+
// 读不到就没法判 —— 不猜。
|
|
885
|
+
console.error(`custom-mode: 无法核对助手 "${target.id}" 的 composition: ${describe(error)}`)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const ok = results.filter((r) => r.ok === true)
|
|
889
|
+
const failed = results.filter((r) => r.ok === false)
|
|
890
|
+
// 成功也要说:新线上「设置页里能看到助手」完全取决于这一步,
|
|
891
|
+
// 而它此前是静默的 —— 出问题时没有任何一行日志能说明"到底同步了几个"。
|
|
892
|
+
console.log(
|
|
893
|
+
`custom-mode: 声明式注册表同步完成 —— 目标 ${targets.length} 个助手,成功 ${ok.length} 个` +
|
|
894
|
+
(failed.length > 0 ? `,失败 ${failed.length} 个` : '') +
|
|
895
|
+
`(${ok.map((r) => r.id).join(', ') || '无'})`,
|
|
896
|
+
)
|
|
897
|
+
if (failed.length > 0) {
|
|
898
|
+
console.error(`custom-mode: ${failed.length} 个助手的 preset 未能挂载 —— 见上面的具体原因。`)
|
|
899
|
+
}
|
|
900
|
+
} catch (error) {
|
|
901
|
+
console.error('custom-mode: 同步 preset 到注册表失败(已忽略): ' + describe(error))
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* 路由层用的 roster:首次调用会等**启动同步**完成,之后不再等。
|
|
907
|
+
*
|
|
908
|
+
* 为什么不是简单地 await 一次:`ctx.inject` 的回调不是 async(实测写成 `await` 会让模块
|
|
909
|
+
* 直接语法错误、插件加载失败),所以启动同步只能在这里补等;而它只该等一次 ——
|
|
910
|
+
* 写操作之后的重同步由 `resyncPresets` 负责,不该让每个请求都等一遍。
|
|
911
|
+
*/
|
|
912
|
+
let startupSync = null
|
|
913
|
+
const roster = async () => {
|
|
914
|
+
if (startupSync !== null) {
|
|
915
|
+
const pending = startupSync
|
|
916
|
+
startupSync = null
|
|
917
|
+
await pending
|
|
918
|
+
}
|
|
919
|
+
return rawRoster()
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** 写操作之后调用:重新对齐注册表。失败已在内部记录,不抛出。 */
|
|
923
|
+
const resyncPresets = async () => {
|
|
924
|
+
startupSync = null
|
|
925
|
+
await doSync()
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 启动即同步:新线不是"写盘即生效",第一次进来必须先注册。
|
|
929
|
+
startupSync = doSync()
|
|
930
|
+
|
|
777
931
|
/**
|
|
778
932
|
* One Fetch-shaped handler for the whole surface.
|
|
779
933
|
*
|
|
@@ -872,12 +1026,24 @@ export function apply(ctx) {
|
|
|
872
1026
|
path: API_PREFIX + pathname,
|
|
873
1027
|
methods: [...methods],
|
|
874
1028
|
requestBody: 'buffered',
|
|
875
|
-
fetch: (request) =>
|
|
1029
|
+
fetch: async (request) => {
|
|
1030
|
+
const response = await handle(request, pathname)
|
|
1031
|
+
// 写成功之后,把注册表同步到磁盘的新状态。
|
|
1032
|
+
// 旧线上 `declarative` 是 null,这里恒为空操作;新线上没有它,用户在设置页
|
|
1033
|
+
// 新建/改名/删除的助手就不会出现在选择器里 —— 是本插件最容易被漏掉的一步。
|
|
1034
|
+
if (declarative !== null && request.method !== 'GET' && response.ok) await resyncPresets()
|
|
1035
|
+
return response
|
|
1036
|
+
},
|
|
876
1037
|
}),
|
|
877
1038
|
'custom-mode.route' + pathname,
|
|
878
1039
|
)
|
|
879
1040
|
}
|
|
880
|
-
|
|
1041
|
+
})
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
// ctx.inject 自身抛错(例如宿主改了它的签名)会让整段装配静默消失:没有路由、没有日志。
|
|
1044
|
+
// 这一层是把它变成一条可读的错误。
|
|
1045
|
+
console.error('custom-mode: ctx.inject 注册失败(这是个 bug,请提 issue): ' + describe(error))
|
|
1046
|
+
}
|
|
881
1047
|
}
|
|
882
1048
|
|
|
883
1049
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-custom-mode",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.11",
|
|
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",
|
|
@@ -58,13 +58,14 @@
|
|
|
58
58
|
"locales.mjs",
|
|
59
59
|
"journal.mjs",
|
|
60
60
|
"cordis.patch.yml",
|
|
61
|
+
"preset-backend/",
|
|
61
62
|
"preset/"
|
|
62
63
|
],
|
|
63
64
|
"scripts": {
|
|
64
65
|
"test": "node ../test/run.mjs"
|
|
65
66
|
},
|
|
66
67
|
"peerDependencies": {
|
|
67
|
-
"@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0"
|
|
68
|
+
"@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0"
|
|
68
69
|
},
|
|
69
70
|
"publishConfig": {
|
|
70
71
|
"access": "public",
|
|
@@ -76,6 +77,6 @@
|
|
|
76
77
|
}
|
|
77
78
|
},
|
|
78
79
|
"engines": {
|
|
79
|
-
"dsh": ">=0.1.5-rc.2 <0.2.0-0"
|
|
80
|
+
"dsh": ">=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0 || >=0.1.7-alpha.1 <0.2.0-0"
|
|
80
81
|
}
|
|
81
82
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* declarative 后端:dsh ≥ 0.1.7 的「声明式注册表」。
|
|
3
|
+
*
|
|
4
|
+
* ## 与 legacy 后端的差别,一句话
|
|
5
|
+
*
|
|
6
|
+
* 旧线上,插件只是**把文件写到磁盘**(`$DSH_HOME/.agent-presets/<id>/`),平台自己去扫、去挂;
|
|
7
|
+
* 新线上没有目录扫描,插件必须**自己把 preset 注册进注册表**,并持有返回的 disposer。
|
|
8
|
+
*
|
|
9
|
+
* ## 三条由实测定下来的硬约束(0.1.7-alpha.2)
|
|
10
|
+
*
|
|
11
|
+
* 1. **相对模块说明符不工作。** 本插件的 composition 里有 `name: './prompt-reader.mjs'` 这类
|
|
12
|
+
* 相对路径;旧线上它相对 preset 目录解析,而 `register()` 在 profile 的 baseUrl 下挂载。
|
|
13
|
+
* 实测:相对路径 → `broken: … never started`;绝对 `file://` URL → 正常 mount。
|
|
14
|
+
* ⇒ `toPluginRows()` 必须把相对说明符改写成绝对 URL。
|
|
15
|
+
* 2. **`register()` 的返回值是 disposer**,注销唯一手段;服务上没有 `remove()`。
|
|
16
|
+
* 3. **同一个 id 重复注册会抛 `Duplicate agent preset`**
|
|
17
|
+
* ⇒ 重新挂载前必须先 dispose 旧的。
|
|
18
|
+
*
|
|
19
|
+
* ## 不写任何文件
|
|
20
|
+
*
|
|
21
|
+
* 新线不需要把 preset「落到磁盘」才生效,所以这里只读 composition、只动内存注册表。
|
|
22
|
+
* `agent.cordis.yml` 仍然写盘 —— 它是用户可见、可迁移、可手改的真相,也是旧线唯一的输入。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { pathToFileURL } from 'node:url'
|
|
28
|
+
import { parseComposition } from './parse-composition.mjs'
|
|
29
|
+
|
|
30
|
+
export const BACKEND_ID = 'declarative'
|
|
31
|
+
|
|
32
|
+
/** Evaluate a `!!js` predicate. Only ever applied to text this feature itself wrote. */
|
|
33
|
+
function resolveDisabled(value) {
|
|
34
|
+
if (value !== null && typeof value === 'object' && typeof value.__js === 'string') {
|
|
35
|
+
try {
|
|
36
|
+
// eslint-disable-next-line no-new-func
|
|
37
|
+
return Boolean(new Function(`"use strict"; return (${value.__js});`)())
|
|
38
|
+
} catch {
|
|
39
|
+
// 表达式求值不了时按"关掉"处理:宁可少一行能力,也不要让整条 preset 挂载失败。
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return value === true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A `./x.mjs` specifier becomes an absolute file:// URL; package specifiers pass through. */
|
|
47
|
+
export function absoluteSpecifier(specifier, presetDir) {
|
|
48
|
+
if (typeof specifier !== 'string' || !specifier.startsWith('.')) return specifier
|
|
49
|
+
return pathToFileURL(join(presetDir, specifier)).href
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Turn parsed composition rows into the row objects `register()` takes.
|
|
54
|
+
*
|
|
55
|
+
* Group rows keep their `isolate` realm and their children: the composition's own comments require a
|
|
56
|
+
* service row to sit inside such a group, or it publishes into the root realm and collides.
|
|
57
|
+
*
|
|
58
|
+
* @param {Array<object>} rows - rows from {@link parseComposition}.
|
|
59
|
+
* @param {string} presetDir - the assistant's directory, the base for relative specifiers.
|
|
60
|
+
* @returns {Array<object>} plugin rows.
|
|
61
|
+
*/
|
|
62
|
+
export function toPluginRows(rows, presetDir) {
|
|
63
|
+
if (!Array.isArray(rows)) return []
|
|
64
|
+
const out = []
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
if (row === null || typeof row !== 'object' || typeof row.id !== 'string') continue
|
|
67
|
+
const plugin = { id: row.id }
|
|
68
|
+
if (typeof row.name === 'string') plugin.name = absoluteSpecifier(row.name, presetDir)
|
|
69
|
+
if (row.group === true) {
|
|
70
|
+
plugin.group = true
|
|
71
|
+
if (row.isolate !== undefined && row.isolate !== null) plugin.isolate = row.isolate
|
|
72
|
+
plugin.config = toPluginRows(row.config, presetDir)
|
|
73
|
+
} else if (row.config !== undefined && row.config !== null) {
|
|
74
|
+
plugin.config = row.config
|
|
75
|
+
}
|
|
76
|
+
if (row.disabled !== undefined) plugin.disabled = resolveDisabled(row.disabled)
|
|
77
|
+
out.push(plugin)
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{
|
|
84
|
+
* scope: object,
|
|
85
|
+
* log?: (m: string) => void,
|
|
86
|
+
* warn?: (m: string) => void,
|
|
87
|
+
* }} deps
|
|
88
|
+
*/
|
|
89
|
+
export function createDeclarativeBackend({ scope, log = console.log, warn = console.error }) {
|
|
90
|
+
/** presetId → { dispose, dir } —— 当前挂在注册表里的东西,注销的唯一凭据。 */
|
|
91
|
+
const mounted = new Map()
|
|
92
|
+
|
|
93
|
+
async function unmount(id) {
|
|
94
|
+
const entry = mounted.get(id)
|
|
95
|
+
if (entry === undefined) return
|
|
96
|
+
mounted.delete(id)
|
|
97
|
+
try {
|
|
98
|
+
await entry.dispose()
|
|
99
|
+
} catch (error) {
|
|
100
|
+
warn(`custom-mode: 注销 preset "${id}" 时出错(已忽略): ${describe(error)}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build the definition for one assistant directory, or null when it has no composition. */
|
|
105
|
+
function buildDefinition(assistant) {
|
|
106
|
+
const file = join(assistant.dir, 'agent.cordis.yml')
|
|
107
|
+
if (!existsSync(file)) return null
|
|
108
|
+
const rows = parseComposition(readFileSync(file, 'utf8'))
|
|
109
|
+
const plugins = toPluginRows(rows, assistant.dir)
|
|
110
|
+
if (plugins.length === 0) return null
|
|
111
|
+
const definition = { id: assistant.id, plugins }
|
|
112
|
+
if (assistant.name !== undefined && assistant.name !== '') definition.name = assistant.name
|
|
113
|
+
if (assistant.description !== undefined && assistant.description !== '') definition.description = assistant.description
|
|
114
|
+
if (Number.isFinite(assistant.order)) definition.order = assistant.order
|
|
115
|
+
return definition
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* (Re)mount one assistant. Idempotent: an already-mounted id is disposed first, because the
|
|
120
|
+
* registry refuses a duplicate id outright.
|
|
121
|
+
*/
|
|
122
|
+
async function mountOne(assistant) {
|
|
123
|
+
let definition
|
|
124
|
+
try {
|
|
125
|
+
definition = buildDefinition(assistant)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// 解析失败是**用户可见**的问题(手改坏了 composition),要显式说,不能静默跳过。
|
|
128
|
+
warn(`custom-mode: 预设 "${assistant.id}" 的 composition 解析失败 —— ${describe(error)}。该助手不会出现在选择器里。`)
|
|
129
|
+
await unmount(assistant.id)
|
|
130
|
+
return { ok: false, error: describe(error) }
|
|
131
|
+
}
|
|
132
|
+
if (definition === null) {
|
|
133
|
+
await unmount(assistant.id)
|
|
134
|
+
return { ok: false, error: 'no composition' }
|
|
135
|
+
}
|
|
136
|
+
await unmount(assistant.id)
|
|
137
|
+
try {
|
|
138
|
+
const dispose = await scope.agentPresets.register(definition)
|
|
139
|
+
mounted.set(assistant.id, { dispose, dir: assistant.dir })
|
|
140
|
+
return { ok: true }
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// register 会抛(Duplicate / id 空 / 已存在):说清是哪个助手、哪条错误。
|
|
143
|
+
warn(`custom-mode: 注册预设 "${assistant.id}" 失败 —— ${describe(error)}`)
|
|
144
|
+
return { ok: false, error: describe(error) }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Bring the registry in line with the given assistant list: mount all, drop the rest. */
|
|
149
|
+
async function sync(assistants) {
|
|
150
|
+
const results = []
|
|
151
|
+
for (const assistant of assistants) results.push({ id: assistant.id, ...(await mountOne(assistant)) })
|
|
152
|
+
const wanted = new Set(assistants.map((a) => a.id))
|
|
153
|
+
for (const id of [...mounted.keys()]) {
|
|
154
|
+
if (!wanted.has(id)) {
|
|
155
|
+
await unmount(id)
|
|
156
|
+
results.push({ id, ok: true, unmounted: true })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return results
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function disposeAll() {
|
|
163
|
+
for (const id of [...mounted.keys()]) await unmount(id)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id: BACKEND_ID,
|
|
168
|
+
/** 新线不把 preset 落到磁盘,注册表就是唯一真相。 */
|
|
169
|
+
writesPresetFiles: false,
|
|
170
|
+
sync,
|
|
171
|
+
mountOne,
|
|
172
|
+
unmount,
|
|
173
|
+
disposeAll,
|
|
174
|
+
mountedIds: () => [...mounted.keys()],
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function describe(error) {
|
|
179
|
+
return String((error && error.message) || error)
|
|
180
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力探测:判定当前 dsh 线用的是哪一种 agent-preset 机制。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么按能力探测,而不是按版本号
|
|
5
|
+
*
|
|
6
|
+
* preset 机制在 0.1.7 被整体换过(见 notes/2026-09-22-双线适配设计.md §1):
|
|
7
|
+
*
|
|
8
|
+
* 0.1.2 … 0.1.6 `@deepseek-ai/dsh-agent-presets`(复数)—— **扫目录** `$DSH_HOME/.agent-presets/`,
|
|
9
|
+
* 服务带读改删三件套 `read` / `copy` / `remove`,`list()` 的行带 `trust` 与 `path`。
|
|
10
|
+
* 0.1.7 + `@deepseek-ai/dsh-agent-preset`(单数)+ `-registry` —— **声明式注册表**,
|
|
11
|
+
* 不扫目录,服务有 `register` / `compositionInventory` / `select`,但**没有** remove。
|
|
12
|
+
*
|
|
13
|
+
* 0.1.7 仍在 alpha(已到 alpha.2),接口还会动。按版本号写分支会在下一次发布时**静默失配**,
|
|
14
|
+
* 而按能力探测天然面向未来。
|
|
15
|
+
*
|
|
16
|
+
* ## 判据的选取
|
|
17
|
+
*
|
|
18
|
+
* 主判据是 **`remove` 的有无**:它是"文件系统语义"的直接标志 —— 旧线能删磁盘上的 preset 目录,
|
|
19
|
+
* 新线的定义在内存里、只能靠 `register()` 返回的 disposer 注销。
|
|
20
|
+
* 辅以 `register` / `compositionInventory` 作交叉验证;**信号矛盾时不猜**,落到只读降级态并显式报告。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 后端标识。字符串而不是布尔,便于日志与测试断言。 */
|
|
24
|
+
export const BACKEND_LEGACY = 'legacy-dir'
|
|
25
|
+
export const BACKEND_DECLARATIVE = 'declarative'
|
|
26
|
+
export const BACKEND_UNUSABLE = 'unusable'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Probe the agent-preset service and decide which backend to drive it with.
|
|
30
|
+
*
|
|
31
|
+
* @param {{agentPresets?: object}} scope - the scoped context carrying `agentPresets`.
|
|
32
|
+
* @returns {{
|
|
33
|
+
* id: string,
|
|
34
|
+
* capabilities: {list: boolean, register: boolean, remove: boolean, copy: boolean, read: boolean,
|
|
35
|
+
* inventory: boolean, select: boolean},
|
|
36
|
+
* reasons: string[],
|
|
37
|
+
* conflict: boolean,
|
|
38
|
+
* }}
|
|
39
|
+
*/
|
|
40
|
+
export function detectPresetBackend(scope) {
|
|
41
|
+
const svc = scope?.agentPresets
|
|
42
|
+
const capabilities = {
|
|
43
|
+
list: typeof svc?.list === 'function',
|
|
44
|
+
register: typeof svc?.register === 'function',
|
|
45
|
+
remove: typeof svc?.remove === 'function',
|
|
46
|
+
copy: typeof svc?.copy === 'function',
|
|
47
|
+
read: typeof svc?.read === 'function',
|
|
48
|
+
inventory: typeof svc?.compositionInventory === 'function',
|
|
49
|
+
select: typeof svc?.select === 'function',
|
|
50
|
+
}
|
|
51
|
+
const reasons = []
|
|
52
|
+
|
|
53
|
+
// 核心前提:没有 list() 就既读不到 roster,也没法判断任何事。
|
|
54
|
+
if (!capabilities.list) {
|
|
55
|
+
reasons.push('agentPresets.list() 不可用 —— 无法读取 preset roster')
|
|
56
|
+
return { id: BACKEND_UNUSABLE, capabilities, reasons, conflict: false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 旧线的标志:文件系统语义齐全(remove 是其中最不会误报的一个 —— 新线确实没有它)。
|
|
60
|
+
const looksLegacy = capabilities.remove
|
|
61
|
+
// 新线的标志:声明式注册面存在。
|
|
62
|
+
const looksDeclarative = capabilities.register && capabilities.inventory
|
|
63
|
+
|
|
64
|
+
if (looksLegacy && looksDeclarative) {
|
|
65
|
+
// 两条线的特征同时出现:可能 upstream 把两套并了,也可能是我们判错了。
|
|
66
|
+
// **不猜** —— 显式报告并走只读,避免用错后端写坏用户的 preset。
|
|
67
|
+
reasons.push(
|
|
68
|
+
'同时探测到两套机制的特征(既有 remove(),又有 register() + compositionInventory())。' +
|
|
69
|
+
'这可能是 dsh 换用了一套兼容层;本插件不在这种情况下写入任何 preset,' +
|
|
70
|
+
'只做只读展示。请提 issue 并附上 dsh --version。',
|
|
71
|
+
)
|
|
72
|
+
return { id: BACKEND_UNUSABLE, capabilities, reasons, conflict: true }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (looksLegacy) {
|
|
76
|
+
reasons.push('agentPresets.remove() 存在 —— 文件系统语义的旧线(扫 $DSH_HOME/.agent-presets/)')
|
|
77
|
+
return { id: BACKEND_LEGACY, capabilities, reasons, conflict: false }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (looksDeclarative) {
|
|
81
|
+
reasons.push('agentPresets.remove() 不存在,但 register() + compositionInventory() 存在 —— 声明式注册表的新线')
|
|
82
|
+
return { id: BACKEND_DECLARATIVE, capabilities, reasons, conflict: false }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
reasons.push(
|
|
86
|
+
'既没有 remove()(旧线的文件系统语义),也没有 register() + compositionInventory()(新线的声明式注册表)。' +
|
|
87
|
+
'这不像任何一条已知的 dsh 线。',
|
|
88
|
+
)
|
|
89
|
+
return { id: BACKEND_UNUSABLE, capabilities, reasons, conflict: false }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 人类可读的一行摘要,供启动日志使用。 */
|
|
93
|
+
export function describeBackend(verdict) {
|
|
94
|
+
const on = Object.entries(verdict.capabilities)
|
|
95
|
+
.filter(([, v]) => v)
|
|
96
|
+
.map(([k]) => k)
|
|
97
|
+
const off = Object.entries(verdict.capabilities)
|
|
98
|
+
.filter(([, v]) => !v)
|
|
99
|
+
.map(([k]) => k)
|
|
100
|
+
return `backend=${verdict.id} · 有: ${on.join(',') || '(无)'} · 无: ${off.join(',') || '(无)'}`
|
|
101
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preset-backend —— 让设置页在两条 dsh 线上跑同一套逻辑。
|
|
3
|
+
*
|
|
4
|
+
* ## 这个目录解决什么
|
|
5
|
+
*
|
|
6
|
+
* preset 机制在 0.1.7 被整体换过:旧线**扫目录**、服务带读改删;新线是**声明式注册表**、
|
|
7
|
+
* 不扫目录、没有 remove。设置页本身(UI、双语、composition 编译、提示词读写)两线一模一样,
|
|
8
|
+
* 差别只在三处:
|
|
9
|
+
*
|
|
10
|
+
* A 路径 —— 助手目录在哪、prompt/composition 文件在哪
|
|
11
|
+
* B roster —— 「有哪些助手」从哪知道
|
|
12
|
+
* C 出厂组成 —— base mode 的 composition 从哪来
|
|
13
|
+
*
|
|
14
|
+
* 本模块把这三处收成一个后端对象(见 notes/2026-09-22-双线适配设计.md §3)。
|
|
15
|
+
*
|
|
16
|
+
* ## 关键设计:新线上**合成 roster**,而不是让下游到处分支
|
|
17
|
+
*
|
|
18
|
+
* 新线 `list()` 返回的行没有 `trust` / `path`(实测),而 `assistants.mjs` 的
|
|
19
|
+
* `assistantsFromRoster` / `assistantDir` 与 `index.mjs` 的整个路由层都建立在这两个字段上。
|
|
20
|
+
* 与其把线判断散布到十几处调用点,不如在**这一处**补出一个与旧线同形的行列表:
|
|
21
|
+
* 助手本来就是磁盘上的目录(`$DSH_HOME/.agent-presets/<id>/`),扫一遍即可。
|
|
22
|
+
* 这样路由层零改动,两条线走同一段代码。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
26
|
+
import { basename, join } from 'node:path'
|
|
27
|
+
import { scanManagedDirs } from '../assistants.mjs'
|
|
28
|
+
import { parseComposition } from './parse-composition.mjs'
|
|
29
|
+
|
|
30
|
+
export { detectPresetBackend, describeBackend, BACKEND_LEGACY, BACKEND_DECLARATIVE, BACKEND_UNUSABLE } from './detect.mjs'
|
|
31
|
+
export { createDeclarativeBackend, toPluginRows, absoluteSpecifier } from './declarative.mjs'
|
|
32
|
+
export { parseComposition, CompositionParseError } from './parse-composition.mjs'
|
|
33
|
+
|
|
34
|
+
/** The managed-assistant marker file, same name `assistants.mjs` uses. */
|
|
35
|
+
const SEED_MARKER = '.custom-mode.json'
|
|
36
|
+
const COMPOSITION_FILE = 'agent.cordis.yml'
|
|
37
|
+
const META_FILE = 'preset.yml'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read `preset.yml` beside a composition.
|
|
41
|
+
*
|
|
42
|
+
* Uses the same small parser as the composition: the file is written by this feature
|
|
43
|
+
* (`yamlScalar`), so a strict subset is enough, and a hand-broken meta must not take the page down.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} dir - assistant directory.
|
|
46
|
+
* @returns {{name?: string, description?: string}} whatever could be read.
|
|
47
|
+
*/
|
|
48
|
+
export function readPresetMeta(dir) {
|
|
49
|
+
const file = join(dir, META_FILE)
|
|
50
|
+
if (!existsSync(file)) return {}
|
|
51
|
+
try {
|
|
52
|
+
const parsed = parseComposition(readFileSync(file, 'utf8'))
|
|
53
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
54
|
+
const out = {}
|
|
55
|
+
if (typeof parsed.name === 'string') out.name = parsed.name
|
|
56
|
+
if (typeof parsed.description === 'string') out.description = parsed.description
|
|
57
|
+
return out
|
|
58
|
+
} catch {
|
|
59
|
+
// 元数据读不了不是致命问题:目录里还有 composition,助手仍应出现(只是名字退回 id)。
|
|
60
|
+
return {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Directories under `root` that this feature manages.
|
|
66
|
+
*
|
|
67
|
+
* Delegates to `assistants.mjs:scanManagedDirs` on purpose. My first version invented its own
|
|
68
|
+
* predicate (look for `.custom-mode.json` inside each assistant directory) and therefore found
|
|
69
|
+
* **nothing** — measured: the preset root held `.agent-presets/.custom-mode.json` (a root-level
|
|
70
|
+
* "this feature owns this root" marker) while the assistant directory had no such file, so
|
|
71
|
+
* `sync()` reported "目标 0 个助手". The real predicate is "has prompt.md, and either ships
|
|
72
|
+
* prompt-reader.mjs or the composition still references it". Reusing it also means the two
|
|
73
|
+
* definitions cannot drift.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} root - user preset root.
|
|
76
|
+
* @returns {string[]} absolute directory paths, sorted for a stable roster order.
|
|
77
|
+
*/
|
|
78
|
+
export function scanManagedAssistantDirs(root) {
|
|
79
|
+
return scanManagedDirs(root)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Synthesize roster rows in the shape `assistants.mjs` expects, by scanning the disk.
|
|
84
|
+
*
|
|
85
|
+
* Only needed on the declarative line, whose `list()` does not carry `trust` / `path`.
|
|
86
|
+
* On the legacy line the platform's own rows are richer (they include `broken` diagnostics),
|
|
87
|
+
* so callers should prefer them and fall back to this.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} root - user preset root (`$DSH_HOME/.agent-presets`).
|
|
90
|
+
* @returns {Array<{id: string, trust: 'user', path: string, name: string, description: string}>}
|
|
91
|
+
*/
|
|
92
|
+
export function synthesizeRosterRows(root) {
|
|
93
|
+
return scanManagedAssistantDirs(root).map((dir) => {
|
|
94
|
+
const meta = readPresetMeta(dir)
|
|
95
|
+
return {
|
|
96
|
+
id: basename(dir),
|
|
97
|
+
trust: 'user',
|
|
98
|
+
path: join(dir, COMPOSITION_FILE),
|
|
99
|
+
name: meta.name ?? '',
|
|
100
|
+
description: meta.description ?? '',
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The roster to drive the settings page with.
|
|
107
|
+
*
|
|
108
|
+
* Legacy line: the platform's rows already describe authored presets, so they are used as-is.
|
|
109
|
+
* Declarative line: its rows are display metadata only, so authored presets are re-derived from disk
|
|
110
|
+
* and **merged** with whatever the registry reported (registry rows win for `broken`, which is a
|
|
111
|
+
* mount diagnostic only the platform can produce).
|
|
112
|
+
*
|
|
113
|
+
* @param {Array<object>} rows - `agentPresets.list()` result.
|
|
114
|
+
* @param {{root: string, backendId: string}} options
|
|
115
|
+
* @returns {Array<object>} rows including every authored assistant.
|
|
116
|
+
*/
|
|
117
|
+
export function effectiveRosterRows(rows, { root, backendId }) {
|
|
118
|
+
const platform = Array.isArray(rows) ? rows : []
|
|
119
|
+
if (backendId !== 'declarative') return platform
|
|
120
|
+
|
|
121
|
+
const byId = new Map()
|
|
122
|
+
for (const synth of synthesizeRosterRows(root)) byId.set(synth.id, synth)
|
|
123
|
+
|
|
124
|
+
// 注册表能给出 mount 诊断(broken),合并进合成行;合成行提供 trust/path。
|
|
125
|
+
for (const row of platform) {
|
|
126
|
+
if (row === null || typeof row !== 'object' || typeof row.id !== 'string') continue
|
|
127
|
+
const synth = byId.get(row.id)
|
|
128
|
+
if (synth === undefined) continue
|
|
129
|
+
if (typeof row.broken === 'string' && row.broken !== '') synth.broken = row.broken
|
|
130
|
+
if (typeof row.name === 'string' && row.name !== '') synth.name = row.name
|
|
131
|
+
if (typeof row.description === 'string' && row.description !== '') synth.description = row.description
|
|
132
|
+
}
|
|
133
|
+
return [...byId.values()]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True when `dir` looks like a managed assistant directory (used to validate a path). */
|
|
137
|
+
export function isManagedAssistantDir(dir) {
|
|
138
|
+
try {
|
|
139
|
+
return statSync(dir).isDirectory() && existsSync(join(dir, SEED_MARKER))
|
|
140
|
+
} catch {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberately small YAML reader, sized to `agent.cordis.yml`.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* The declarative backend (dsh ≥ 0.1.7) needs the composition as **JS row objects** to hand to
|
|
7
|
+
* `agentPresets.register()`; there is no file for the platform to scan any more. Three routes were
|
|
8
|
+
* ruled out by measurement before writing this:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Borrow the host's parser** — `createRequire(import.meta.url).resolve('@deepseek-ai/cordis-plugin-loader')`
|
|
11
|
+
* fails from inside a profile-installed plugin (measured), so the loader's YAML entry is unreachable.
|
|
12
|
+
* 2. **Reconstruct the rows from `compositionInventory()`** — it returns a **flattened** row list
|
|
13
|
+
* (`entryId` / `moduleName` / `enabled`), so the `isolate` groups that service rows must sit inside
|
|
14
|
+
* are lost. The composition's own comments say a service row outside such a group collides with
|
|
15
|
+
* other presets at mount, so flattening is not an option.
|
|
16
|
+
* 3. **Skip the text and re-render** — the composer works by *text surgery* (it preserves comments and
|
|
17
|
+
* `!!js` predicates byte-for-byte), so it has no structured row model to hand out.
|
|
18
|
+
*
|
|
19
|
+
* ## What it supports, and what it refuses
|
|
20
|
+
*
|
|
21
|
+
* Only what this feature's own files contain: block sequences, nested block mappings, block scalars
|
|
22
|
+
* (`|`, `|-`, `>`, `>-`), quoted and plain scalars, comments, and the `!!js` tag. Anything else is
|
|
23
|
+
* refused with a located error rather than guessed — a mis-read row silently changes what the model
|
|
24
|
+
* sees, which is the failure this repository least tolerates.
|
|
25
|
+
*
|
|
26
|
+
* `!!js` values are returned as `{ __js: "<source>" }` so callers decide how to evaluate them; this
|
|
27
|
+
* module never runs anything.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Thrown with the offending line number so a broken composition is diagnosable, not mysterious. */
|
|
31
|
+
export class CompositionParseError extends Error {
|
|
32
|
+
constructor(message, line) {
|
|
33
|
+
super(`${message}(第 ${line} 行)`)
|
|
34
|
+
this.name = 'CompositionParseError'
|
|
35
|
+
this.line = line
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isBlank = (line) => line.trim() === '' || line.trim().startsWith('#')
|
|
40
|
+
const indentOf = (line) => line.length - line.trimStart().length
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a composition document into plain JS.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} text - the composition file's contents.
|
|
46
|
+
* @returns {any} the document (a block sequence for a composition file).
|
|
47
|
+
*/
|
|
48
|
+
export function parseComposition(text) {
|
|
49
|
+
const lines = String(text).split('\n')
|
|
50
|
+
const state = { lines, index: 0 }
|
|
51
|
+
skipBlank(state)
|
|
52
|
+
if (state.index >= lines.length) return null
|
|
53
|
+
return parseNode(state, indentOf(lines[state.index]))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function skipBlank(state) {
|
|
57
|
+
while (state.index < state.lines.length && isBlank(state.lines[state.index])) state.index += 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The next content line, or undefined. */
|
|
61
|
+
function peek(state) {
|
|
62
|
+
skipBlank(state)
|
|
63
|
+
return state.lines[state.index]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse the block starting at the current line, whose entries are indented at least `indent`.
|
|
68
|
+
* A line beginning with `- ` opens a sequence; anything else opens a mapping.
|
|
69
|
+
*/
|
|
70
|
+
function parseNode(state, indent) {
|
|
71
|
+
const line = peek(state)
|
|
72
|
+
if (line === undefined) return null
|
|
73
|
+
return /^\s*-(\s|$)/.test(line) ? parseSequence(state, indent) : parseMapping(state, indent)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseSequence(state, indent) {
|
|
77
|
+
const out = []
|
|
78
|
+
for (;;) {
|
|
79
|
+
const line = peek(state)
|
|
80
|
+
if (line === undefined || indentOf(line) < indent || !/^\s*-(\s|$)/.test(line)) break
|
|
81
|
+
const lineNo = state.index + 1
|
|
82
|
+
const rest = line.trimStart().slice(1)
|
|
83
|
+
// `- ` on its own, or `- key: value` inline.
|
|
84
|
+
const inline = rest.trimStart()
|
|
85
|
+
if (inline === '') {
|
|
86
|
+
state.index += 1
|
|
87
|
+
out.push(parseNode(state, indent + 1))
|
|
88
|
+
} else if (/^[^:]+:(\s|$)/.test(inline)) {
|
|
89
|
+
// Inline mapping start: rewrite it as a mapping at its own column and re-parse.
|
|
90
|
+
state.lines[state.index] = ' '.repeat(indent + 2) + inline
|
|
91
|
+
out.push(parseMapping(state, indent + 2))
|
|
92
|
+
} else {
|
|
93
|
+
state.index += 1
|
|
94
|
+
out.push(parseScalar(inline, lineNo))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseMapping(state, indent) {
|
|
101
|
+
const out = {}
|
|
102
|
+
for (;;) {
|
|
103
|
+
const line = peek(state)
|
|
104
|
+
if (line === undefined || indentOf(line) < indent) break
|
|
105
|
+
if (/^\s*-(\s|$)/.test(line)) break
|
|
106
|
+
const lineNo = state.index + 1
|
|
107
|
+
const match = /^([A-Za-z_][\w.-]*)\s*:(.*)$/.exec(line.trim())
|
|
108
|
+
if (match === null) throw new CompositionParseError(`无法把这一行读成映射项:${JSON.stringify(line.trim())}`, lineNo)
|
|
109
|
+
const key = match[1]
|
|
110
|
+
const tail = match[2]
|
|
111
|
+
state.index += 1
|
|
112
|
+
if (tail.trim() === '') {
|
|
113
|
+
// Value is a nested block (or null when nothing is indented under it).
|
|
114
|
+
const next = peek(state)
|
|
115
|
+
if (next === undefined || indentOf(next) <= indent) out[key] = null
|
|
116
|
+
else out[key] = parseNode(state, indentOf(next))
|
|
117
|
+
} else if (/^[|>][-+]?\d*\s*$/.test(tail.trim())) {
|
|
118
|
+
out[key] = parseBlockScalar(state, indent, tail.trim())
|
|
119
|
+
} else {
|
|
120
|
+
out[key] = parseScalar(tail.trim(), lineNo)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A `|`/`>` block scalar: consume every more-indented line, keeping its text. */
|
|
127
|
+
function parseBlockScalar(state, indent, header) {
|
|
128
|
+
const fold = header.startsWith('>')
|
|
129
|
+
const stripFinal = header.includes('-')
|
|
130
|
+
const body = []
|
|
131
|
+
let blockIndent = -1
|
|
132
|
+
while (state.index < state.lines.length) {
|
|
133
|
+
const line = state.lines[state.index]
|
|
134
|
+
if (line.trim() !== '' && indentOf(line) <= indent) break
|
|
135
|
+
body.push(line)
|
|
136
|
+
state.index += 1
|
|
137
|
+
}
|
|
138
|
+
while (body.length > 0 && body[0].trim() === '') body.shift()
|
|
139
|
+
while (body.length > 0 && body[body.length - 1].trim() === '') body.pop()
|
|
140
|
+
if (blockIndent === -1) {
|
|
141
|
+
blockIndent = body.reduce((min, l) => (l.trim() === '' ? min : Math.min(min, indentOf(l))), Number.MAX_SAFE_INTEGER)
|
|
142
|
+
if (blockIndent === Number.MAX_SAFE_INTEGER) blockIndent = indent + 2
|
|
143
|
+
}
|
|
144
|
+
const text = body.map((l) => l.slice(blockIndent)).join('\n')
|
|
145
|
+
const value = fold ? text.replace(/\n(?!\n)/g, ' ') : text
|
|
146
|
+
return stripFinal ? value : value + '\n'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A scalar: `!!js` tagged, quoted, or plain.
|
|
151
|
+
*
|
|
152
|
+
* Plain scalars stay strings — the composer only ever writes strings, booleans and numbers, and a
|
|
153
|
+
* bare word like `standard` must not become a date or a number.
|
|
154
|
+
*/
|
|
155
|
+
export function parseScalar(raw, lineNo = 0) {
|
|
156
|
+
const value = raw.trim()
|
|
157
|
+
if (value === '') return null
|
|
158
|
+
if (value.startsWith('!!js')) {
|
|
159
|
+
const source = value.slice(4).trim()
|
|
160
|
+
if (source === '') throw new CompositionParseError('!!js 后面没有表达式', lineNo)
|
|
161
|
+
return { __js: source }
|
|
162
|
+
}
|
|
163
|
+
if (value === 'true') return true
|
|
164
|
+
if (value === 'false') return false
|
|
165
|
+
if (value === 'null' || value === '~') return null
|
|
166
|
+
if (/^-?\d+$/.test(value)) return Number(value)
|
|
167
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(value)) {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(value)
|
|
170
|
+
} catch {
|
|
171
|
+
throw new CompositionParseError(`双引号标量无法转义:${value}`, lineNo)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (/^'(?:[^']|'')*'$/.test(value)) return value.slice(1, -1).replace(/''/g, "'")
|
|
175
|
+
if (/^[[{]/.test(value)) throw new CompositionParseError(`不支持流式集合:${value}`, lineNo)
|
|
176
|
+
return value
|
|
177
|
+
}
|
package/seed.mjs
CHANGED
|
@@ -79,7 +79,12 @@ export function starterComposition(options = {}) {
|
|
|
79
79
|
} catch (error) {
|
|
80
80
|
console.error(
|
|
81
81
|
'custom-mode: 无法从本机安装的出厂组成派生播种文件(' + describe(error) + '),改用包内模板。' +
|
|
82
|
-
|
|
82
|
+
// 0.1.7 起这条是**预期**的:那条线不再有 `@deepseek-ai/dsh-agent-presets` 包,
|
|
83
|
+
// 因而没有 presets/ 目录可派生 —— 出厂定义改成了 web-app bundle 里的声明行。
|
|
84
|
+
// 模板能不能用不靠猜:宿主半边随后会用 `compositionInventory()` 的模块名集合
|
|
85
|
+
// 逐行核对,装不了的行会指名报出来(见 index.mjs 的 doSync)。
|
|
86
|
+
'若该 dsh 线已改用声明式 preset(0.1.7+),这是预期回退:那条线没有 presets/ 目录。' +
|
|
87
|
+
'插件随后会核对模板的每一行能否在本机运行,装不了的行会被指名报出。',
|
|
83
88
|
)
|
|
84
89
|
return null
|
|
85
90
|
}
|