dsh-router-core 0.4.6 → 0.4.7

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.
Files changed (63) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/lib/client-registry.js +146 -106
  5. package/lib/client.js +146 -106
  6. package/lib/ext-prompt/index.js +182 -17
  7. package/lib/index.js +2732 -55
  8. package/lib/types/ext/contract.d.ts +20 -1
  9. package/lib/types/ext-prompt/content.d.ts +7 -1
  10. package/lib/types/ext-prompt/mount.d.ts +12 -1
  11. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  12. package/lib/types/ext-prompt/render.d.ts +14 -3
  13. package/lib/types/ext-prompt/sections.d.ts +95 -0
  14. package/lib/types/protocol/anthropic.d.ts +35 -0
  15. package/lib/types/protocol/chat.d.ts +25 -0
  16. package/lib/types/protocol/entry.d.ts +137 -0
  17. package/lib/types/protocol/gemini.d.ts +57 -0
  18. package/lib/types/protocol/index.d.ts +13 -0
  19. package/lib/types/protocol/responses.d.ts +41 -0
  20. package/lib/types/protocol/sse.d.ts +41 -0
  21. package/lib/types/protocol/stream.d.ts +92 -0
  22. package/lib/types/protocol/types.d.ts +224 -0
  23. package/lib/types/router/index.d.ts +42 -1
  24. package/lib/types/router/types.d.ts +19 -0
  25. package/lib/types/shared.d.ts +11 -0
  26. package/lib/types/suppliers/contract.d.ts +8 -0
  27. package/package.json +6 -6
  28. package/src/client/ExtDetail.tsx +25 -5
  29. package/src/client/group-controls.test.ts +53 -0
  30. package/src/client/group-controls.ts +43 -0
  31. package/src/client/router.css +20 -1
  32. package/src/ext/contract.ts +20 -1
  33. package/src/ext-prompt/assembly.test.ts +63 -0
  34. package/src/ext-prompt/content.ts +8 -10
  35. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  36. package/src/ext-prompt/index.ts +50 -2
  37. package/src/ext-prompt/mount.test.ts +18 -1
  38. package/src/ext-prompt/mount.ts +22 -7
  39. package/src/ext-prompt/plugin.ts +64 -1
  40. package/src/ext-prompt/render.test.ts +1 -1
  41. package/src/ext-prompt/render.ts +17 -6
  42. package/src/ext-prompt/section-controls.test.ts +148 -0
  43. package/src/ext-prompt/section-filter.test.ts +140 -0
  44. package/src/ext-prompt/sections.test.ts +123 -0
  45. package/src/ext-prompt/sections.ts +193 -0
  46. package/src/ext-route.test.ts +16 -0
  47. package/src/index.ts +109 -1
  48. package/src/protocol/anthropic.ts +662 -0
  49. package/src/protocol/chat.ts +272 -0
  50. package/src/protocol/entry.ts +440 -0
  51. package/src/protocol/gemini.ts +548 -0
  52. package/src/protocol/index.ts +31 -0
  53. package/src/protocol/protocol.test.ts +728 -0
  54. package/src/protocol/responses.ts +551 -0
  55. package/src/protocol/sse.ts +94 -0
  56. package/src/protocol/stream.ts +195 -0
  57. package/src/protocol/types.ts +249 -0
  58. package/src/protocol/wire-e2e.test.ts +208 -0
  59. package/src/protocol/wiring.test.ts +225 -0
  60. package/src/router/index.ts +142 -23
  61. package/src/router/types.ts +19 -0
  62. package/src/shared.ts +11 -0
  63. package/src/suppliers/contract.ts +8 -0
@@ -3360,6 +3360,25 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
3360
3360
  list-style: none;
3361
3361
  }
3362
3362
 
3363
+ /* 分组标题行 —— `ExtControl.group` 的渲染落点,插在 `.dshr-compRows` 里
3364
+ 的非交互 <li>(无拖把/开关,不可点)。与 `compTitle` 的区别:那是详情页
3365
+ 主标题(页头),这是**组**名(次要色 + 小字号)。左右 14px 对齐
3366
+ `.dshr-compRow` 的行内缩,组名与行文字左缘一致。 */
3367
+ .dshr-extGroupTitle {
3368
+ margin: 14px 0 2px;
3369
+ padding: 0 14px;
3370
+ color: var(--dsw-alias-label-tertiary);
3371
+ font-size: 12px;
3372
+ font-weight: 500;
3373
+ line-height: 18px;
3374
+ list-style: none;
3375
+ }
3376
+
3377
+ /* 全部分组的扩展:首个渲染节点就是组标题,去掉上边距避免顶双空。 */
3378
+ .dshr-extGroupTitle:first-child {
3379
+ margin-top: 0;
3380
+ }
3381
+
3363
3382
  .dshr-compRow {
3364
3383
  display: flex;
3365
3384
  align-items: center;
@@ -3489,7 +3508,7 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not(
3489
3508
  overflow-wrap: anywhere;
3490
3509
  /* 保留换行:子开关的**原文**是这个类的另一个用法(2026-09-29 合并进来),
3491
3510
  它是多行段落。状态文案都是单行,所以这条对它们没有副作用。
3492
- ⇒ 原来的 `.dshr-compRowBody` 整块删掉:14 条规则的正文不需要左边框 +
3511
+ ⇒ 原来的 `.dshr-compRowBody` 整块删掉:各条规则的正文不需要左边框 +
3493
3512
  缩进那种「引用块」的额外装饰(用户要求),层级只靠颜色就够。 */
3494
3513
  white-space: pre-wrap;
3495
3514
  }
@@ -68,7 +68,7 @@ export interface RouterExt {
68
68
  * 扩展自带的**子开关**(可选;不声明 = 只有总开关)。
69
69
  *
70
70
  * 为什么需要:有些扩展的「开/关」不是一件事而是**几件独立的事** —— 例如分层
71
- * 提示词有 14 个准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
71
+ * 提示词的准则分类,每条都该能单独关。硬塞进一个总开关 = 要么全给、
72
72
  * 要么全不给。
73
73
  *
74
74
  * ⚠️ **与总开关的关系**:子开关是**叠加**在总开关之上的,总开关关 ⇒ 全部子开关
@@ -179,6 +179,25 @@ export interface ExtControl {
179
179
  readonly custom?: boolean
180
180
  /** 文本被用户改过(决定「还原」按钮是否可点)。 */
181
181
  readonly overridden?: boolean
182
+ /**
183
+ * 分组名(可选;缺省 = 无组)—— 面板按它把控制条**分节**渲染,组间插一条
184
+ * 组标题行,组内顺序照旧。**无组的条目照旧平铺** ⇒ 没用这个字段的扩展
185
+ * (ext-test 等)面板行为一个像素都不变。
186
+ *
187
+ * 为什么要有:一条扩展同时管两类东西时(分层提示词 = 准则分类 + 宿主段落),
188
+ * 平铺成一长列会让"哪条是准则、哪条是宿主的段"无从分辨 —— 分组是**归属
189
+ * 信息**,不是样式。
190
+ */
191
+ readonly group?: string
192
+ /**
193
+ * 这一条参与拖动排序(可选;缺省 = 参与,保持既有行为)。
194
+ *
195
+ * 为什么需要显式关掉:排序落盘只认扩展自己的条目 id(`setControlOrder`
196
+ * 过滤未知 id)—— 宿主段落的 id 不在其中,拖它只会被静默丢弃 ⇒ 看起来能拖、
197
+ * 松手弹回,是假交互。**由扩展逐条声明**,与 `editable` 同一理由:能力在
198
+ * 实现方手里,由它说哪条有。
199
+ */
200
+ readonly sortable?: boolean
182
201
  }
183
202
 
184
203
  /** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
@@ -18,6 +18,7 @@ import { SystemPrompt, renderPrompt, type PromptAssembly } from '@deepseek-ai/ds
18
18
  import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
19
19
  import { renderPromptText } from './render.ts'
20
20
  import { PROMPT_CATEGORIES } from './content.ts'
21
+ import { createSectionCache, disabledSectionNames, filterSections } from './sections.ts'
21
22
 
22
23
  /** 起一个真 systemPrompt 并挂上本扩展。 */
23
24
  async function assemble(
@@ -77,3 +78,65 @@ test('装配结果与 renderPromptText 的内容一致(渲染逻辑只有一
77
78
  const expected = renderPromptText(true, undefined)
78
79
  assert.ok(text.includes(expected), '装配结果里找不到 renderPromptText 的输出 ⇒ 两处实现漂移了')
79
80
  })
81
+
82
+ // ── 段落总控:真宿主装配链上的"关了就没了"(2026-10-21)──────────
83
+ /**
84
+ * ⚠️ 这里才是段落总控的**终局判据**。前面 `section-filter.test.ts` 验的是
85
+ * "监听器按规则返回了什么",而"宿主真把那个返回值当成最终装配"是**另一件事**
86
+ * —— 宿主源码里 waterfall 之后还有 complete-section 恢复、runtime-context
87
+ * 抑制等收尾,任何一处改动都可能让我们删的段回来。只有跑真 `assemble()`
88
+ * 才能钉住。
89
+ */
90
+ async function assembleWithSections(
91
+ opts: { masterOn?: boolean; data?: unknown },
92
+ ): Promise<{ names: string[]; text: string; dispose: () => void }> {
93
+ const ctx = new Context()
94
+ new SystemPrompt(ctx, { includeHarnessIdentity: true, personaPrefix: 'PERSONA-PREFIX-SENTINEL' })
95
+ let enabled = true
96
+ const cache = createSectionCache()
97
+ // 与 index.ts 的监听器**同一形状**:先 next()、喂清单、再按落盘过滤
98
+ const offAssemble = ctx.on('system-prompt/assemble', async (assembly, _c, next) => {
99
+ const result = await next()
100
+ cache.update(result.sections)
101
+ if ((opts.masterOn ?? enabled) !== true) return result
102
+ const off = disabledSectionNames(opts.data as never)
103
+ if (off.size === 0) return result
104
+ return { ...result, sections: filterSections(result.sections, off) }
105
+ })
106
+ // 本扩展自己的准则段(带正文哨兵,便于断言它没被段开关删掉)
107
+ const dispose = mountPromptSection({
108
+ getSystemPrompt: () => ctx.systemPrompt,
109
+ isEnabled: () => true,
110
+ readData: () => undefined,
111
+ })
112
+ await new Promise((r) => setTimeout(r, 10))
113
+ const a = await ctx.systemPrompt.assemble({})
114
+ return {
115
+ names: a.sections.map((s) => s.name),
116
+ text: renderPrompt(a),
117
+ dispose: () => { offAssemble(); dispose(); void ctx },
118
+ }
119
+ }
120
+
121
+ test('★ 真装配链:关掉某段落 ⇒ 它真的不在最终 prompt 里', async () => {
122
+ const on = await assembleWithSections({})
123
+ // 宿主自带的段(关掉 includeHarnessIdentity 也仍在表里的话更好,但优先用我们自己的)
124
+ assert.ok(on.names.includes(PROMPT_SECTION_NAME), `准则段不在装配里:${on.names.join(', ')}`)
125
+ const target = on.names.find((n) => n === 'deployment:persona-prefix')
126
+ assert.ok(target !== undefined, `找不到宿主 persona 段,实际段名:${on.names.join(', ')}`)
127
+ on.dispose()
128
+
129
+ const off = await assembleWithSections({ data: { sections: { [target]: false } } })
130
+ assert.equal(off.names.includes(target), false, `真装配链里 ${target} 关了却还在`)
131
+ assert.equal(off.text.includes('PERSONA-PREFIX-SENTINEL'), false, '段没了但正文还在 ⇒ 过滤没生效')
132
+ // 反向:别的段与准则不能陪葬
133
+ assert.ok(off.names.includes(PROMPT_SECTION_NAME), '关别人的段时把准则段一起删了')
134
+ off.dispose()
135
+ })
136
+
137
+ test('★ 真装配链:总开关关 ⇒ 一个段都不动(扩展关 = 不干预宿主)', async () => {
138
+ const r = await assembleWithSections({ masterOn: false, data: { sections: { 'deployment:persona-prefix': false } } })
139
+ assert.ok(r.names.includes('deployment:persona-prefix'), '总开关关掉后仍在过滤 ⇒ 关了还生效')
140
+ assert.ok(r.text.includes('PERSONA-PREFIX-SENTINEL'), '总开关关掉后 persona 正文不见了')
141
+ r.dispose()
142
+ })
@@ -20,6 +20,12 @@
20
20
  * —— 它没有终止条件,每次收口都必然能再找出一件存量,等于无限循环。
21
21
  * ② 固化:由"每次用户可见损失都必须固化"降级为"只对数据丢失/安全这类真损失
22
22
  * 补检查",并删掉「不固化的教训等于决定再犯一次」这句无界义务。
23
+ *
24
+ * ⚠️ 2026-10-17 用户点名的两处改动:
25
+ * ① **删掉「固化」分类**(整条移除,id `hardening` 不复存在)。用户已存的
26
+ * `categories`/`text`/`order` 里残留的 `hardening` 键无须迁移 ——
27
+ * `resolveCategories` 规则 2 会忽略不认识的 id。
28
+ * ② 身份气质:「默认中文」→「回复、思考过程必须使用中文」。
23
29
  */
24
30
 
25
31
  /** 一个可独立开关的提示词分类。 */
@@ -45,7 +51,7 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
45
51
  title: '身份气质',
46
52
  defaultOn: true,
47
53
  body:
48
- '身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;默认中文,直接给能跑的东西。',
54
+ '身份气质:大肥鱼 🐋(资深开发者);懒=高效不是马虎;安静、克制、直接;回复、思考过程必须使用中文,直接给能跑的东西。',
49
55
  },
50
56
  {
51
57
  id: 'scope',
@@ -80,14 +86,6 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
80
86
  '②反查重复:新旧成两份就并成一处;③反查形状:该合的合、该归档的归位。' +
81
87
  '做完那块区域该比动手前更整或更少。',
82
88
  },
83
- {
84
- id: 'hardening',
85
- title: '固化',
86
- defaultOn: true,
87
- body:
88
- '固化:只对数据丢失/安全这类真损失补检查——会变红(注入违规必须失败)、可重放(一条命令)、' +
89
- '归属清晰(失败信息指向违规处);普通 bug 修好即止,不额外造闸门。',
90
- },
91
89
  {
92
90
  id: 'verdifiable',
93
91
  title: '可判定',
@@ -155,7 +153,7 @@ export const PROMPT_CATEGORIES: readonly PromptCategory[] = [
155
153
  *
156
154
  * ⚠️ 拆分时差点丢它:原文是 `[…] ·身份气质:…`,标题挂在方括号里、
157
155
  * **不属于任何单条分类**,按分隔符切分时自然被丢掉
158
- * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到 14 条
156
+ * (只在 `content.ts` 头注出现过、没进任何 `body`)。⇒ 模型会看到一堆
159
157
  * **没有名字**的规则,而"这套东西叫什么、为什么存在"恰恰是它需要的上下文。
160
158
  *
161
159
  * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。
@@ -20,7 +20,7 @@ import { PROMPT_CATEGORIES, PROMPT_TITLE } from './content.ts'
20
20
  import type { RouterExtService } from '../ext/contract.ts'
21
21
 
22
22
  test('扩展器带上面板要用的身份', () => {
23
- const ext = createPromptExt({ isSystemPromptReady: () => true })
23
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
24
24
  assert.equal(ext.id, EXT_PROMPT_ID)
25
25
  assert.equal(ext.name, '分层提示词')
26
26
  assert.equal(ext.source, 'builtin', '随核心分发必须标 builtin,否则插件页会重复列它')
@@ -34,7 +34,7 @@ test('★ `description` 是整段标题(不是自我介绍,也不内插分
34
34
  // ③ 现在又要求**顶部显示标题** ⇒ 改成「等于 PROMPT_TITLE」。
35
35
  // 两次反复的共同教训:**默认值/文案该是什么由用户拍板,判据只负责让改动可见**,
36
36
  // 所以断言跟着改、并在注释里留着反复,免得下一个人以为是"又改坏了"。
37
- const ext = createPromptExt({ isSystemPromptReady: () => true })
37
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
38
38
  assert.equal(ext.description, PROMPT_TITLE, '详情页顶部要显示的就是整段标题')
39
39
  // ⚠️ 尤其**不许**内插 `${PROMPT_CATEGORIES.length}`:加减分类后文案不跟着变,
40
40
  // 就是同一件事的两个来源(「反查重复」)。
@@ -53,7 +53,7 @@ test('★ 插件页那一行的说明仍然在(来自 locale,不是 descript
53
53
  })
54
54
 
55
55
  test('注入:systemPrompt 不可用 ⇒ 报 not ready(不能静默不生效)', () => {
56
- const ext = createPromptExt({ isSystemPromptReady: () => false })
56
+ const ext = createPromptExt({ isSystemPromptReady: () => false, getSections: () => [] })
57
57
  const st = ext.getState()
58
58
  assert.equal(st.ready, false, '无处注入却报就绪 = 卡片亮着、准则没进 prompt')
59
59
  assert.ok((st.detail ?? '').length > 0, '不报还不说原因 = 面板上无从判断')
@@ -102,17 +102,27 @@ test('★ 那一行默认关闭(与 ext-test 同款)', () => {
102
102
  * 真 cordis 会如实复现时序(本机 4.0.4 实测:provide 后 inject 不触发,
103
103
  * start() 之后才触发)。
104
104
  */
105
- async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
105
+ async function realCtx(opts: { enabled?: boolean; data?: unknown; assembled?: { name: string; text: string }[] } = {}) {
106
106
  const { Context } = await import('@deepseek-ai/cordis')
107
107
  const table: RouterExtService = {}
108
108
  const sections: { name: string; order: number; text: string | (() => string) }[] = []
109
+ const assembled = opts.assembled ?? []
110
+ let assembleCalls = 0
109
111
  const ctx = new Context()
110
112
  ctx.provide('systemPrompt', {
111
113
  section: (s: { name: string; order: number; text: string | (() => string) }) => {
112
114
  sections.push(s)
113
115
  return () => {}
114
116
  },
115
- getSectionOrder: () => 100,
117
+ // ⚠️ 这里曾经有个 `getSectionOrder: () => 100` 的桩:没人调用它(`mount.ts`
118
+ // 自己定 order),而 100 已经不是本插件的段落 order(2026-10-21 改为 -2000
119
+ // 放到最前)⇒ 留着就是一句与事实不符的注脚,删掉。
120
+ // 段落总控的预热/清单源(apply 里 `void sp.assemble()`);返回值直接决定
121
+ // 面板清单,测试用 `assembled` 注入。
122
+ assemble: async () => {
123
+ assembleCalls++
124
+ return { sections: assembled.map((s) => ({ ...s })) }
125
+ },
116
126
  })
117
127
  ctx.provide('router.ext', table)
118
128
  ctx.provide('router.extStore', {
@@ -121,7 +131,7 @@ async function realCtx(opts: { enabled?: boolean; data?: unknown } = {}) {
121
131
  readData: () => opts.data,
122
132
  writeData: () => {},
123
133
  })
124
- return { ctx, table, sections }
134
+ return { ctx, table, sections, getAssembleCalls: () => assembleCalls }
125
135
  }
126
136
 
127
137
  test('apply 把扩展登记进 router.ext 表(这张表就是「卡片出现」的唯一依据)', async () => {
@@ -163,14 +173,14 @@ test('注入:用户关掉某分类 ⇒ 该分类不进 prompt(可开关的
163
173
 
164
174
  // ── 面板编辑文本(2026-09-29)────────────────────────────────
165
175
  test('★ 每条都标 editable,面板才知道该不该给「修改」按钮', () => {
166
- const ext = createPromptExt({ isSystemPromptReady: () => true, store: fakeStore({ categories: {} }) })
176
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store: fakeStore({ categories: {} }) })
167
177
  for (const c of ext.controls ?? []) assert.equal(c.editable, true, `${c.id} 没标 editable`)
168
178
  assert.equal(typeof ext.setControlText, 'function', '没有 setControlText ⇒ 面板点了必然 400')
169
179
  })
170
180
 
171
181
  test('★ 编辑后 controls 立刻反映新标题/新正文(每次读都重算)', () => {
172
182
  const store = fakeStore({ categories: {}, text: { identity: { title: '我是谁', body: '我是一个测试。' } } })
173
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
183
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
174
184
  const c = (ext.controls ?? []).find((x) => x.id === 'identity')!
175
185
  assert.equal(c.title, '我是谁')
176
186
  assert.equal(c.body, '我是一个测试。')
@@ -182,7 +192,7 @@ test('★ controls 不是快照:改完之后**再读一次**要看到新值',
182
192
  // 旧标题**(用户看到"保存了又变回去")。而我当时注释写着"现算不缓存"。
183
193
  // ⇒ 判据必须**跨两次读**才抓得住(只读一次的写法抓不到)。
184
194
  const store = fakeStore({ categories: {} })
185
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
195
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
186
196
  const before = (ext.controls ?? []).find((x) => x.id === 'ladder')!
187
197
  assert.equal(before.title, '懒人梯子')
188
198
  ext.setControlText?.('ladder', { title: '我的梯子' })
@@ -192,7 +202,7 @@ test('★ controls 不是快照:改完之后**再读一次**要看到新值',
192
202
 
193
203
  test('setControlText 只写覆盖值,且不碰 categories(同块的另一种数据)', () => {
194
204
  const store = fakeStore({ categories: { identity: false }, keep: 'me' })
195
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
205
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
196
206
  assert.equal(ext.setControlText?.('identity', { title: '新标题' }), true)
197
207
  const after = store.readData<Record<string, unknown>>('')
198
208
  assert.deepEqual(after?.categories, { identity: false }, '改标题把开关状态冲掉了')
@@ -207,7 +217,7 @@ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢
207
217
  categories: {},
208
218
  text: { identity: { title: 'A' }, scope: { body: 'B' } },
209
219
  })
210
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
220
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
211
221
  ext.setControlText?.('ladder', { body: '新内容' })
212
222
  assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
213
223
  identity: { title: 'A' },
@@ -218,7 +228,7 @@ test('★ 改一条**不碰其它条**的覆盖(整块重写 text 会静默丢
218
228
 
219
229
  test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,不是替换)', () => {
220
230
  const store = fakeStore({ categories: {}, text: { identity: { title: 'A' } } })
221
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
231
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
222
232
  ext.setControlText?.('identity', { body: 'B' })
223
233
  assert.deepEqual(store.readData<Record<string, unknown>>('')?.text, {
224
234
  identity: { title: 'A', body: 'B' },
@@ -227,7 +237,7 @@ test('改同一条的第二个字段 ⇒ 第一个字段仍在(部分更新,
227
237
 
228
238
  test('注入:空标题/空正文返回 false(不写盘)', () => {
229
239
  const store = fakeStore({ categories: {} })
230
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
240
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
231
241
  for (const bad of [{ title: ' ' }, { title: '' }, { body: '' }]) {
232
242
  assert.equal(ext.setControlText?.('identity', bad), false, `空串被接受了:${JSON.stringify(bad)}`)
233
243
  }
@@ -236,7 +246,7 @@ test('注入:空标题/空正文返回 false(不写盘)', () => {
236
246
 
237
247
  test('注入:未知 controlId 返回 false', () => {
238
248
  const store = fakeStore({ categories: {} })
239
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
249
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
240
250
  assert.equal(ext.setControlText?.('__evil__', { title: 'x' }), false)
241
251
  })
242
252
 
@@ -262,7 +272,7 @@ test('★ controls 走合成列表:自建条目出现、标 custom、内置标
262
272
  custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
263
273
  text: { ladder: { body: '改过的' } },
264
274
  })
265
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
275
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
266
276
  const list = ext.controls ?? []
267
277
  assert.equal(list.length, PROMPT_CATEGORIES.length + 1, '自建条目没出现')
268
278
  assert.equal(list.find((c) => c.id === 'cu-1')?.custom, true)
@@ -272,7 +282,7 @@ test('★ controls 走合成列表:自建条目出现、标 custom、内置标
272
282
 
273
283
  test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)', () => {
274
284
  const store = fakeStore({ categories: {} })
275
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
285
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
276
286
  const id = ext.addCustomControl?.('我的准则', '做完了就收。')
277
287
  assert.ok(id !== null && typeof id === 'string', '没返回新 id')
278
288
  assert.notEqual(id, 'identity', '新 id 撞上了内置条目')
@@ -281,7 +291,7 @@ test('★ 新增自建条目 ⇒ 返回新 id,且落盘(id 不撞内置)',
281
291
 
282
292
  test('注入:空标题/空正文新增 ⇒ 返回 null(不写盘)', () => {
283
293
  const store = fakeStore({ categories: {} })
284
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
294
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
285
295
  assert.equal(ext.addCustomControl?.(' ', 'x'), null)
286
296
  assert.equal(ext.addCustomControl?.('x', ''), null)
287
297
  assert.equal(store.readData<Record<string, unknown>>('')?.custom, undefined, '失败的写入留下痕迹')
@@ -294,7 +304,7 @@ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并
294
304
  order: ['cu-1', 'ladder'],
295
305
  custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }],
296
306
  })
297
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
307
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
298
308
  assert.equal(ext.removeCustomControl?.('identity'), false, '内置条目不该被删(它是代码的一部分)')
299
309
  assert.equal(ext.removeCustomControl?.('ladder'), false, '不存在的条目应被拒')
300
310
  assert.equal(ext.removeCustomControl?.('cu-1'), true)
@@ -306,7 +316,7 @@ test('★ 删除只允许自定义:内置条目被拒、自建条目删掉并
306
316
 
307
317
  test('★ 还原:只对被改过的内置条目成功,且回到内置文本', () => {
308
318
  const store = fakeStore({ categories: {}, text: { ladder: { body: '改过的' } } })
309
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
319
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
310
320
  assert.equal(ext.resetControlText?.('identity'), false, '没改过的内置条目不该「可还原」')
311
321
  assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目没有「内置版本」可回退')
312
322
  assert.equal(ext.resetControlText?.('ladder'), true)
@@ -318,7 +328,7 @@ test('★ 还原:只对被改过的内置条目成功,且回到内置文本'
318
328
 
319
329
  test('★ 排序:只存 id,且过滤掉不存在的 id(脏数据不留幽灵)', () => {
320
330
  const store = fakeStore({ categories: {} })
321
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
331
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
322
332
  assert.equal(ext.setControlOrder?.(['__ghost__', 'ladder', 'ladder']), true)
323
333
  assert.deepEqual(store.readData<Record<string, unknown>>('')?.order, ['ladder'], '幽灵/重复 id 没被过滤')
324
334
  })
@@ -330,7 +340,7 @@ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () =
330
340
  // 当成"存储不可用"直接拒 ⇒ 「添加准则」永远报 `title and body are required`
331
341
  // ——而 title/body 明明都传了,报错还指向错误的字段,排查成本极高。
332
342
  const store = fakeStore(undefined) // **没有 data**
333
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
343
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
334
344
  const id = ext.addCustomControl?.('我的准则', '做完就收。')
335
345
  assert.ok(id !== null, '初始状态下添加失败(真实用户第一次用就是这个状态)')
336
346
  assert.equal((ext.controls ?? []).some((c) => c.id === id), true, '写盘了但 controls 里没有')
@@ -338,7 +348,7 @@ test('★ 没有 data 记录时也能写("没改过" ≠ "不能写")', () =
338
348
 
339
349
  test('★ 四个写入口在"无 data"时都可用(不只是 add)', () => {
340
350
  const store = fakeStore(undefined)
341
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
351
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
342
352
  assert.equal(ext.setControl?.('ladder', false), true, '开关在初始状态下不可用')
343
353
  assert.equal(ext.setControlText?.('ladder', { body: 'x' }), true, '改文本在初始状态下不可用')
344
354
  assert.equal(ext.setControlOrder?.(['ladder']), true, '排序在初始状态下不可用')
@@ -350,7 +360,7 @@ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false
350
360
  // 而代码是对的。**假 store 比被守的东西更宽容 = 判据无效**(第三次栽在这)。
351
361
  // ⇒ 假 store 必须**真的读回自己写的东西**:`fakeStore` 的 `box` 已在做。
352
362
  const store = fakeStore({ categories: {} })
353
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
363
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
354
364
  assert.equal(ext.resetControlText?.('identity'), false, '没改过的却说可还原')
355
365
  ext.setControlText?.('ladder', { body: '改过的' })
356
366
  assert.equal(ext.resetControlText?.('ladder'), true, '改过的却还原不了')
@@ -360,7 +370,7 @@ test('★ 还原:改过 ⇒ true(且覆盖被清掉);没改过 ⇒ false
360
370
  // ── op 成功必须回整张表(2026-09-30)───────────────────────
361
371
  test('★ 契约层:controls 上带 custom 标记(面板据此决定给删除还是还原)', () => {
362
372
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
363
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
373
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
364
374
  const mine = (ext.controls ?? []).find((c) => c.id === 'cu-1')
365
375
  const builtin = (ext.controls ?? []).find((c) => c.id === 'identity')
366
376
  assert.equal(mine?.custom, true, '自建条目没标 custom ⇒ 面板给"还原"而不是"删除"')
@@ -375,7 +385,7 @@ test('★ 自建条目的开关可切(判据要用合成列表,不能只用
375
385
  // ⇒ 用户看到「自定义的开关控制报错」。而它**在 controls 列表里显示着** ——
376
386
  // 列表用 `resolveCategories`、写入判据用 `PROMPT_CATEGORIES`,两处来源不一致。
377
387
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
378
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
388
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
379
389
  assert.equal((ext.controls ?? []).some((c) => c.id === 'cu-1'), true, '列表里没有它')
380
390
  assert.equal(ext.setControl?.('cu-1', false), true, '列表里有、却切不了')
381
391
  assert.equal(store.readData<Record<string, unknown>>('')?.categories && (store.readData<{ categories: Record<string, boolean> }>('')?.categories?.['cu-1']), false, '开关没落盘')
@@ -383,14 +393,14 @@ test('★ 自建条目的开关可切(判据要用合成列表,不能只用
383
393
 
384
394
  test('★ 自建条目的文本可改(同一处来源不一致)', () => {
385
395
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
386
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
396
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
387
397
  assert.equal(ext.setControlText?.('cu-1', { title: '新标题' }), true, '自建条目改不了标题')
388
398
  assert.equal((ext.controls ?? []).find((c) => c.id === 'cu-1')?.title, '新标题')
389
399
  })
390
400
 
391
401
  test('还原仍然只允许内置条目(自建没有「内置版本」可回退)', () => {
392
402
  const store = fakeStore({ categories: {}, custom: [{ id: 'cu-1', title: '我的', defaultOn: true, body: 'B' }] })
393
- const ext = createPromptExt({ isSystemPromptReady: () => true, store })
403
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
394
404
  assert.equal(ext.resetControlText?.('cu-1'), false, '自建条目不该能被「还原」')
395
405
  })
396
406
 
@@ -418,7 +428,7 @@ test('★ 图标能被真实渲染(base64 必须解得出合法 SVG)', () =>
418
428
  })
419
429
 
420
430
  test('图标挂在 ext 上(不设的话面板退回默认闪电)', () => {
421
- const ext = createPromptExt({ isSystemPromptReady: () => true })
431
+ const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [] })
422
432
  assert.equal(ext.icon, PROMPT_LOGO_URL)
423
433
  })
424
434
 
@@ -15,6 +15,14 @@
15
15
  * (宿主管的那个)。不置上的话,用户开了行还要再点一次,两级开关说同一件事。
16
16
  */
17
17
  import type { Context } from '@deepseek-ai/cordis'
18
+ // ⚠️ **只为一个目的而 import type**:让 cordis 的 `Events` 增强
19
+ // (`system-prompt/assemble` / `system-prompt/change`)进入本文件所在的编译单元。
20
+ // 不 import 的话,`ctx.on('system-prompt/assemble', …)` 的参数不是
21
+ // `keyof Events` ⇒ **声明构建(tsconfig.build.json,host 半边)直接报错**,
22
+ // 而主 tsconfig 因为 include 了 `assembly.test.ts`(它 import 了同一个包)
23
+ // 恰好把增强带进来 ⇒ `tsc --noEmit` 全绿、`npm run build` 才红。
24
+ // 这类"两种配置下结论不同"的坑,靠显式依赖修,不靠碰巧。
25
+ import type {} from '@deepseek-ai/dsh-system-prompt'
18
26
  import {
19
27
  currentExts,
20
28
  currentExtStore,
@@ -24,6 +32,7 @@ import {
24
32
  import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
25
33
  import { mountPromptSection, type SystemPromptFace } from './mount.ts'
26
34
  import type { PromptExtData } from './render.ts'
35
+ import { createSectionCache, disabledSectionNames, filterSections } from './sections.ts'
27
36
 
28
37
  /** cordis 插件身份(配置树里这一行的技术名;显示名走同目录的 locale)。 */
29
38
  export const name = 'dsh-router-ext-prompt'
@@ -76,7 +85,7 @@ export function apply(ctx: Context): void {
76
85
  const table = currentExts(ctx)
77
86
  const store = currentExtStore(ctx)
78
87
 
79
- // ① 准则段落。`section()` 注册一个**provider 函数**作为 text ——
88
+ // ① 准则段落。`section()` 注册一个 **provider 函数**作为 text ——
80
89
  // 每次装配现算,所以改开关立即生效,无需重注册。
81
90
  const dispose = mountPromptSection({
82
91
  getSystemPrompt: () => systemPromptOf(ctx),
@@ -84,13 +93,50 @@ export function apply(ctx: Context): void {
84
93
  readData: () => store?.readData<PromptExtData>(EXT_PROMPT_ID),
85
94
  })
86
95
 
87
- // ② 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
96
+ // ② 段落总控:挂 `system-prompt/assemble` waterfall —— 每轮装配时
97
+ // ⑴ 先把**未过滤的全量段落**喂给清单缓存(面板的事实源,在过滤前取 ⇒
98
+ // 关掉的段仍列在面板上,不会"关了就消失、开不回来");
99
+ // ⑵ 再按落盘的关段表删掉关闭的段(宿主以监听器返回值为权威结果
100
+ // —— `assemble()` 的 `return transformed`,删掉的段不会被恢复)。
101
+ //
102
+ // ⚠️ 总开关关 ⇒ 原样放行:扩展关 = 本扩展什么都不做,宿主段落恢复
103
+ // 自然注入(与准则段"关总开关 ⇒ 段文本空 ⇒ 整段被丢"同一语义)。
104
+ // ⚠️ 监听器注册在**无 scope 标签**的 ctx 上,而派发走
105
+ // `scopeTarget`(dsh-scope):无标签监听器收一切 scope 的装配
106
+ // —— 这正是宿主"全局注册、参与所有 agent 装配"的通道,与准则
107
+ // section 进 prompt 同一条 scope 规则。
108
+ const cache = createSectionCache()
109
+ let disposeAssemble: (() => boolean) | undefined
110
+ let disposeChange: (() => boolean) | undefined
111
+ const sp = systemPromptOf(ctx)
112
+ if (sp) {
113
+ disposeAssemble = ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
114
+ const result = await next()
115
+ cache.update(result.sections)
116
+ if (store?.isEnabled(EXT_PROMPT_ID) !== true) return result
117
+ const off = disabledSectionNames(store.readData<PromptExtData>(EXT_PROMPT_ID))
118
+ if (off.size === 0) return result
119
+ return { ...result, sections: filterSections(result.sections, off) }
120
+ })
121
+ // 段在**没有新装配时**增删(插件开关变化等)⇒ 面板清单不会自己更新,
122
+ // 主动跑一次 assemble 刷新(它会再走上面的 waterfall ⇒ 缓存唯一写入点)。
123
+ disposeChange = ctx.on('system-prompt/change', () => {
124
+ void sp.assemble().catch(() => { /* 预热失败不致命:下次装配自然补上 */ })
125
+ })
126
+ // 预热:一次对话都还没发生时,面板也要有清单(无 scope 的 global 近似)。
127
+ void sp.assemble().catch(() => { /* 同上 */ })
128
+ }
129
+
130
+ // ③ 面板登记。幂等:同一个 id 已在表里就不顶掉别人的登记。
88
131
  if (table !== undefined && table[EXT_PROMPT_ID] === undefined) {
89
132
  table[EXT_PROMPT_ID] = createPromptExt({
90
133
  isSystemPromptReady: () => !!systemPromptOf(ctx),
91
134
  // ⚠️ **store 必须在这时就能拿到**:拿不到的话面板上的分类开关显示可点、
92
135
  // 点了 400 —— 「看起来能用但不能用」比「不显示」更难查。
93
136
  store,
137
+ // 段清单来自 waterfall 缓存(若 ② 没挂上 ⇒ 空清单 ⇒ 段组不显示,
138
+ // 但不许这里给缺省 —— 见 createPromptExt 的 deps 注释)。
139
+ getSections: () => cache.list(),
94
140
  })
95
141
  store?.setEnabled(EXT_PROMPT_ID, true)
96
142
  // 核心持有的是**同一个 live 对象**(它 provide 的空表),且 `/ext` 每次请求都
@@ -99,6 +145,8 @@ export function apply(ctx: Context): void {
99
145
 
100
146
  ctx.effect(() => () => {
101
147
  dispose()
148
+ disposeAssemble?.()
149
+ disposeChange?.()
102
150
  if (table !== undefined) delete table[EXT_PROMPT_ID]
103
151
  })
104
152
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import test from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
- import { mountPromptSection, PROMPT_SECTION_NAME } from './mount.ts'
10
+ import { mountPromptSection, PROMPT_SECTION_NAME, OFFICIAL_LOWEST_SECTION_ORDER } from './mount.ts'
11
11
  import { EXT_PROMPT_ID } from './plugin.ts'
12
12
 
13
13
  /** 记录注册内容的假 systemPrompt。 */
@@ -25,6 +25,11 @@ function fakeSystemPrompt() {
25
25
  disposals++
26
26
  }
27
27
  },
28
+ // 本文件测的是注册契约,不碰装配;给最小合法实现满足 `SystemPromptFace`
29
+ //(段清单与过滤的单测在 sections.test.ts / ext-prompt.test.ts)。
30
+ async assemble() {
31
+ return { sections: [] }
32
+ },
28
33
  }
29
34
  }
30
35
 
@@ -35,6 +40,18 @@ test('注册恰好一个段落,名字稳定', () => {
35
40
  assert.equal(sp.registered[0]!.name, PROMPT_SECTION_NAME)
36
41
  })
37
42
 
43
+ test('★ 段落 order 排在官方最低槽之前(2026-10-21 用户点名:准则放最前)', () => {
44
+ // 判据不写"是最小的"(谁将来再往前挪就误伤),写成**必须低于官方第一方最低槽**:
45
+ // 第一方开场白 `harness:identity` 在 -1000,别的部署可能没关它。
46
+ const sp = fakeSystemPrompt()
47
+ mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
48
+ const order = sp.registered[0]!.order
49
+ assert.ok(
50
+ order < OFFICIAL_LOWEST_SECTION_ORDER,
51
+ `order=${order} 未低于官方最低槽 ${OFFICIAL_LOWEST_SECTION_ORDER} ⇒ 准则排不到最前`,
52
+ )
53
+ })
54
+
38
55
  test('⚠️ text 必须是 provider 函数而不是静态字符串(否则改开关不生效)', () => {
39
56
  const sp = fakeSystemPrompt()
40
57
  mountPromptSection({ getSystemPrompt: () => sp, isEnabled: () => true, readData: () => undefined })
@@ -20,21 +20,36 @@ import { renderPromptText, type PromptExtData } from './render.ts'
20
20
  /** `ctx.systemPrompt` 的最小可见面。 */
21
21
  export interface SystemPromptFace {
22
22
  section(section: { name: string; order: number; text: string | (() => string) }): () => void
23
- getSectionOrder?(name: string): number
23
+ /**
24
+ * 段落总控用(`index.ts` 的 waterfall 预热):宿主 `SystemPrompt.assemble()`。
25
+ * 只声明用得到的最小形状 —— 返回的 `sections` 已按 order/name 排好序。
26
+ */
27
+ assemble(context?: Record<string, unknown>): Promise<{ sections: { name: string; text: string }[] }>
24
28
  }
25
29
 
26
30
  /** 段落名(**稳定**):改名会让已注册的作用域 section 与旧名并存。 */
27
31
  export const PROMPT_SECTION_NAME = 'dsh-router-ext-prompt:guideline'
28
32
 
29
33
  /**
30
- * 段落 order:100。
34
+ * 段落 order:**-2000 = 全场最前**(2026-10-21 用户点名「把准则放到最前」)。
35
+ *
36
+ * 官方分配的槽位(`dsh-system-prompt` 的 `SECTION_ORDERS`)最低一档是
37
+ * `HARNESS_IDENTITY: -1000`(第一方开场白 `You are an AI agent powered by
38
+ * DeepSeek Harness.`),其后才是 `DEPLOYMENT_PERSONA_PREFIX: 0`、`PLAN_POLICY: 500`
39
+ * … `HARNESS_SOURCE: 10000`。
31
40
  *
32
- * 官方分配的槽位(`dsh-system-prompt` 的 `SECTION_ORDERS`):
33
- * `PERSONA_PREFIX: 0` · `PLAN_POLICY: 500` · `TEAM_POLICY: 600` …·
34
- * 取 100 = 紧随 persona、远早于任何工具段 ⇒ 准则出现在身份之后、工具说明之前,
35
- * 与用户看到的记忆快照位置一致。
41
+ * 取 **-2000**(低于官方最低槽 1000)而不是「正好 -1000」:
42
+ * - 撞在 -1000 上要靠**段名的字母序**决胜(`comparePromptSections` 是
43
+ * `order` 再按 `name`)—— 那是实现细节,改段名就翻盘,不作为位置依据;
44
+ * - 第一方那行在本部署被 `includeHarnessIdentity: false` 关掉了,但**别的
45
+ * 部署可能还开着** ⇒ 准则仍须排在它前面,不能假设它不存在。
46
+ *
47
+ * ⚠️ `order` 只要求有限数(`Number.isFinite`),负数合法。
36
48
  */
37
- const SECTION_ORDER = 100
49
+ const SECTION_ORDER = -2000
50
+
51
+ /** 官方 `SECTION_ORDERS` 里最低的那档(第一方开场白),测试用它当"最前"的判据。 */
52
+ export const OFFICIAL_LOWEST_SECTION_ORDER = -1000
38
53
 
39
54
  export interface MountPromptDeps {
40
55
  /** 取 `ctx.systemPrompt`(可能尚未就绪 ⇒ undefined)。 */