dsh-custom-mode 1.9.8 → 1.9.10

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 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 latest preview `0.1.6-alpha.2`)** — declared in `engines.dsh` and as an *optional* peer range. This package never
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/client.js CHANGED
@@ -683,6 +683,13 @@ try {
683
683
  ".cpfe-btn-ghost{border-color:transparent;background:transparent}",
684
684
  ".cpfe-input{box-sizing:border-box;width:100%;height:34px;padding:0 12px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}",
685
685
  ".cpfe-switch{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary)}",
686
+ // 忙碌期间禁掉草稿类控件的交互(issue #6)。
687
+ // 状态层已经拦住了改动(`update()` 在 busy 时直接返回),但对**受控组件**来说,
688
+ // 光拦状态还不够:DOM 会先显示用户敲进去的字/翻过去的开关,等下一次重渲染才被拉回来 ——
689
+ // 那就是"我明明改了,它自己弹回去了"。`pointer-events` 让点击根本到不了控件,
690
+ // 视觉上也给一个明确的"此刻不能动"。
691
+ ".cpfe-busy .cpfe-switch,.cpfe-busy .cpfe-row-toggle,.cpfe-busy .cpfe-input,.cpfe-busy .cpfe-field,.cpfe-busy .cpfe-editor{pointer-events:none;opacity:.65}",
692
+ ".cpfe-busy .cpfe-editor{cursor:progress}",
686
693
  ".cpfe-tag{font-size:11px;line-height:16px;padding:0 5px;border-radius:4px;border:.5px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}",
687
694
  ".cpfe-tag-success,.cpfe-tag-info{color:var(--dsw-alias-state-success-primary)}",
688
695
  ".cpfe-tag-warning{color:var(--dsw-alias-state-warn-primary)}",
@@ -1597,6 +1604,9 @@ try {
1597
1604
  className: "cpfe-field cpfe-desc",
1598
1605
  value: draft.description,
1599
1606
  rows: 2,
1607
+ // 忙碌期间只读(issue #6)。**描述框在 DOM 里排在提示词编辑器前面**,
1608
+ // 所以只给 `.cpfe-editor` 加 readOnly 是不够的 —— 实测就是这么漏掉的。
1609
+ readOnly: busy === true,
1600
1610
  placeholder: t("name.descriptionPlaceholder"),
1601
1611
  "aria-label": t("name.descriptionPlaceholder"),
1602
1612
  onChange: (event) => update({ description: event.target.value }),
@@ -1835,7 +1845,9 @@ try {
1835
1845
 
1836
1846
  return react.createElement(
1837
1847
  "div",
1838
- { className: "cpfe" },
1848
+ // `cpfe-busy` 是 issue #6 的兜底:面板里**所有**会改草稿的控件在忙碌期间一律不可交互。
1849
+ // 逐个控件加 `disabled` 会漏(本轮就漏了行开关与描述框),一个根类 + 一条 CSS 反而漏不掉。
1850
+ { className: busy === true ? "cpfe cpfe-busy" : "cpfe" },
1839
1851
  assistantList,
1840
1852
  react.createElement("p", { className: "cpfe-note" }, t("assistant.switchHint")),
1841
1853
  // 配了却不生效的项:主动点名,而不是让用户对着"我明明写了"发呆。
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
- try {
610
- root = join(shippedPresetsDir(), '..', '..', '..')
611
- } catch {
612
- return []
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
- if (existsSync(join(root, pkg)) === false) out.push({ id: row[2], name })
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: dirname(PRESET_DIR),
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
- ctx.inject(WEB_SERVICES, (scope) => {
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
- const REQUIRED_APIS = [
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 missing = REQUIRED_APIS.filter(([, probe]) => !probe()).map(([name]) => name)
732
- if (missing.length > 0) {
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 版本缺少所需 API:' +
735
- missing.join('、') +
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
- /** The roster, or an empty list — discovery itself reports broken rows rather than throwing. */
763
- const roster = async () => {
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
- return rows
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) => handle(request, pathname),
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.8",
3
+ "version": "1.9.10",
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
- '如果这条 dsh 线与该模板的差异行不匹配,模式可能被判为 broken 而不出现在选择器里。',
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
  }