dsh-router-core 0.4.5 → 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 (75) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/docs/suppliers.md +2 -2
  5. package/lib/client-registry.js +146 -106
  6. package/lib/client.js +146 -106
  7. package/lib/ext-prompt/index.js +182 -17
  8. package/lib/index.js +2764 -63
  9. package/lib/suppliers/opencode/index.js +58 -30
  10. package/lib/types/ext/contract.d.ts +20 -1
  11. package/lib/types/ext-prompt/content.d.ts +7 -1
  12. package/lib/types/ext-prompt/mount.d.ts +12 -1
  13. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  14. package/lib/types/ext-prompt/render.d.ts +14 -3
  15. package/lib/types/ext-prompt/sections.d.ts +95 -0
  16. package/lib/types/protocol/anthropic.d.ts +35 -0
  17. package/lib/types/protocol/chat.d.ts +25 -0
  18. package/lib/types/protocol/entry.d.ts +137 -0
  19. package/lib/types/protocol/gemini.d.ts +57 -0
  20. package/lib/types/protocol/index.d.ts +13 -0
  21. package/lib/types/protocol/responses.d.ts +41 -0
  22. package/lib/types/protocol/sse.d.ts +41 -0
  23. package/lib/types/protocol/stream.d.ts +92 -0
  24. package/lib/types/protocol/types.d.ts +224 -0
  25. package/lib/types/router/account-pool.d.ts +7 -2
  26. package/lib/types/router/index.d.ts +42 -1
  27. package/lib/types/router/types.d.ts +26 -3
  28. package/lib/types/router/usage-store.d.ts +4 -2
  29. package/lib/types/shared.d.ts +24 -0
  30. package/lib/types/suppliers/contract.d.ts +15 -1
  31. package/package.json +6 -6
  32. package/src/client/ExtDetail.tsx +25 -5
  33. package/src/client/group-controls.test.ts +53 -0
  34. package/src/client/group-controls.ts +43 -0
  35. package/src/client/router.css +20 -1
  36. package/src/ext/contract.ts +20 -1
  37. package/src/ext-prompt/assembly.test.ts +63 -0
  38. package/src/ext-prompt/content.ts +8 -10
  39. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  40. package/src/ext-prompt/index.ts +50 -2
  41. package/src/ext-prompt/mount.test.ts +18 -1
  42. package/src/ext-prompt/mount.ts +22 -7
  43. package/src/ext-prompt/plugin.ts +64 -1
  44. package/src/ext-prompt/render.test.ts +1 -1
  45. package/src/ext-prompt/render.ts +17 -6
  46. package/src/ext-prompt/section-controls.test.ts +148 -0
  47. package/src/ext-prompt/section-filter.test.ts +140 -0
  48. package/src/ext-prompt/sections.test.ts +123 -0
  49. package/src/ext-prompt/sections.ts +193 -0
  50. package/src/ext-route.test.ts +16 -0
  51. package/src/index.ts +117 -6
  52. package/src/llm/adapter.test.ts +54 -0
  53. package/src/llm/adapter.ts +24 -2
  54. package/src/protocol/anthropic.ts +662 -0
  55. package/src/protocol/chat.ts +272 -0
  56. package/src/protocol/entry.ts +440 -0
  57. package/src/protocol/gemini.ts +548 -0
  58. package/src/protocol/index.ts +31 -0
  59. package/src/protocol/protocol.test.ts +728 -0
  60. package/src/protocol/responses.ts +551 -0
  61. package/src/protocol/sse.ts +94 -0
  62. package/src/protocol/stream.ts +195 -0
  63. package/src/protocol/types.ts +249 -0
  64. package/src/protocol/wire-e2e.test.ts +208 -0
  65. package/src/protocol/wiring.test.ts +225 -0
  66. package/src/router/account-pool.test.ts +35 -0
  67. package/src/router/account-pool.ts +15 -4
  68. package/src/router/index.ts +143 -24
  69. package/src/router/types.ts +26 -3
  70. package/src/router/usage-store.ts +4 -2
  71. package/src/shared.test.ts +35 -0
  72. package/src/shared.ts +33 -0
  73. package/src/suppliers/contract.ts +15 -2
  74. package/src/suppliers/opencode/plugin.test.ts +79 -25
  75. package/src/suppliers/opencode/plugin.ts +60 -29
@@ -13,6 +13,7 @@ import type { Context } from '@deepseek-ai/cordis';
13
13
  import type { ChatRequest, ModelInfo, SupplierAccount } from '../router/types.ts';
14
14
  import type { SupplierConfigStore } from '../supplier-config.ts';
15
15
  import type { CredentialStore } from '../credential-store.ts';
16
+ import type { ProtocolId } from '../protocol/types.ts';
16
17
  /** 供应商配置:loader 注入的运行时环境。 */
17
18
  export interface SupplierEnv {
18
19
  /** 数据目录(state.json / keys.json 所在目录)。 */
@@ -45,9 +46,16 @@ export interface SupplierEnv {
45
46
  * `source` 是**可选标签**,声明这个供应商是从哪儿来的。核心用它把 `/health` 的
46
47
  * `source` 报准(面板按它分「内置 / 插件」两组,面板和插件页都看这个值)。
47
48
  * 不声明 = `external`(独立安装的供应商插件的现状,一个字段都不用改)。
49
+ *
50
+ * `wire` 是**协议标签**(可选):这个供应商的上游说哪种协议。缺省
51
+ * `'chat'`(= OpenAI Chat Completions,今天所有供应商的现状,一个都不用改)。
52
+ * 声明成别种时,核心在把 `req` 交给插件前把规范请求体翻成它的协议,
53
+ * 并把它的响应流翻回规范形态 —— 插件看到的仍是「OpenAI SSE」,
54
+ * 于是**契约不变、已有插件全都不动**。详见 `protocol/`。
48
55
  */
49
56
  export type SupplierFactory = ((env: SupplierEnv) => SupplierModule) & {
50
57
  source?: 'builtin';
58
+ wire?: ProtocolId;
51
59
  };
52
60
  /**
53
61
  * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
@@ -111,11 +119,17 @@ export type ChatOnceResult =
111
119
  status: number;
112
120
  body: string;
113
121
  }
114
- /** 失败:核心据此做冷却/换号。 */
122
+ /**
123
+ * 失败:核心据此做冷却/换号。
124
+ * `resetAt` = 上游自己声明的重置时刻(epoch ms,可选):限流类错误带它时
125
+ * 核心冷却到该点,而不是指数退避——频率配额的重置是小时级的,5min 上限的
126
+ * 退避只会到点回来再撞(2026-10-15 的 6004 事故:11 个号轮流被打冷)。
127
+ */
115
128
  | {
116
129
  ok: false;
117
130
  state: AccountState;
118
131
  message: string;
132
+ resetAt?: number;
119
133
  };
120
134
  /** 供应商模块 —— 契约(核心必须,差异化可选)。 */
121
135
  export interface SupplierModule {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -105,7 +105,7 @@
105
105
  },
106
106
  "license": "MIT",
107
107
  "dependencies": {
108
- "@deepseek-ai/schemastery": "^3.18.4"
108
+ "@deepseek-ai/schemastery": "^3.18.5"
109
109
  },
110
110
  "peerDependencies": {
111
111
  "@deepseek-ai/cordis": "^4.0.4",
@@ -113,8 +113,8 @@
113
113
  "@deepseek-ai/dsh-client-ui-primitives": "^0.2.0-rc.2",
114
114
  "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
115
115
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
116
- "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
117
116
  "@deepseek-ai/dsh-settings": "^0.2.0-rc.2",
117
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
118
118
  "cordis": "^4.0.0-rc.7",
119
119
  "react": "^18.2.0",
120
120
  "react-dom": "^18.2.0"
@@ -134,13 +134,13 @@
134
134
  }
135
135
  },
136
136
  "devDependencies": {
137
- "@types/node": "^26.6.2",
137
+ "@types/node": "^26.6.5",
138
138
  "@types/react": "~19.3.0",
139
139
  "@types/react-dom": "~19.3.0",
140
140
  "cordis": "4.0.0-rc.10",
141
- "react": "^19.3.0",
141
+ "react": "^19.3.1",
142
142
  "react-dom": "19.3.0",
143
- "tsdown": "^0.23.0",
143
+ "tsdown": "^0.23.1",
144
144
  "typescript": "^7.0.2",
145
145
  "vite": "8.3.0",
146
146
  "vite-plugin-css-injected-by-js": "^5.0.2"
@@ -16,10 +16,11 @@
16
16
  * 名称/副行,下面跟扩展说明卡与未就绪横幅。有 `api()` 支持的自定义扩展可在未来
17
17
  * 按需加独立面板,此处不内置任何具体扩展的专属视图。
18
18
  */
19
- import { useEffect, useState } from 'react'
19
+ import { Fragment, useEffect, useState } from 'react'
20
20
  import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
21
21
  import { ROUTER_API_BASE, type ExtControlItem, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
22
22
  import { extPanel } from './ext-panels.ts'
23
+ import { groupControlRows } from './group-controls.ts'
23
24
  import { Modal } from './Modal.tsx'
24
25
 
25
26
  /**
@@ -436,14 +437,30 @@ function ExtControls({ item }: { item: RouterExtItem }): JSX.Element {
436
437
  它只剩两个字,却仍占一行 —— 页面进来第一眼是「子开关」这三个字而不是
437
438
  那些规则本身。列表自带每条的标题与开关,不需要再套一层说明。 */}
438
439
  <ul className="dshr-compRows">
439
- {controls.map((c, index) => (
440
+ {/* 分组渲染(`ExtControl.group`):无组节(准则分类)平铺在前 ——
441
+ 没用 group 的扩展拿到的就是原来的平铺列表,行为不变;有组的
442
+ 条目(宿主段落)每组前插一行组标题。组的先后与组内顺序都来自
443
+ 服务端 `rows`(= prompt 序),这里不重排。 */}
444
+ {groupControlRows(controls).map((g) => (
445
+ <Fragment key={g.label ?? '__plain__'}>
446
+ {g.label !== undefined && (
447
+ <li className="dshr-extGroupTitle">{g.label}</li>
448
+ )}
449
+ {g.items.map((c) => {
450
+ // 下标永远对 `controls`(= 服务端 rows)取:分组只影响**渲染
451
+ // 顺序**,拖动落盘的 ids 必须基于原列表 —— 段落行不可拖(见下),
452
+ // 可拖的准则全在无组节,原下标与视图位置天然一致。
453
+ const index = controls.indexOf(c)
454
+ const sortable = c.sortable !== false
455
+ return (
440
456
  <li
441
457
  key={c.id}
442
458
  className={`dshr-compRow${dragIndex === index ? ' dshr-linkRowDragging' : ''}`}
443
- draggable
444
- onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
445
- onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
459
+ draggable={sortable}
460
+ onDragStart={(e) => { if (!sortable) return; setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
461
+ onDragOver={(e) => { if (!sortable) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
446
462
  onDrop={(e) => {
463
+ if (!sortable) { setDragIndex(null); return }
447
464
  e.preventDefault()
448
465
  if (dragIndex === null || dragIndex === index) { setDragIndex(null); return }
449
466
  const next = [...controls]
@@ -511,6 +528,9 @@ function ExtControls({ item }: { item: RouterExtItem }): JSX.Element {
511
528
  onChange={(next) => { void toggle(c.id, next) }}
512
529
  />
513
530
  </li>
531
+ )
532
+ })}
533
+ </Fragment>
514
534
  ))}
515
535
  </ul>
516
536
  {withBody.length === 0 && (
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 详情页 controls 分组的判据(`group-controls.ts`)。
3
+ *
4
+ * 关键是**向后兼容不可退**:没用 `group` 字段的扩展(ext-test 等)必须
5
+ * 拿到"一个无组节、顺序不变"的结果 —— 分组是新能力的**叠加**,不是重排。
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { groupControlRows } from './group-controls.ts'
10
+ import type { ExtControlItem } from '../shared.ts'
11
+
12
+ function row(id: string, extra: Partial<ExtControlItem> = {}): ExtControlItem {
13
+ return { id, title: id, on: true, custom: false, overridden: false, ...extra }
14
+ }
15
+
16
+ test('★ 全部无组 ⇒ 单一个无组节(旧扩展的面板展示与改动前一致)', () => {
17
+ const groups = groupControlRows([row('a'), row('b')])
18
+ assert.equal(groups.length, 1)
19
+ assert.equal(groups[0]!.label, undefined)
20
+ assert.deepEqual(groups[0]!.items.map((i) => i.id), ['a', 'b'], '无组条目顺序被改了')
21
+ })
22
+
23
+ test('★ 无组节恒排最前,且不产出空的无组节', () => {
24
+ const groups = groupControlRows([row('a'), row('t1', { group: '工具说明' })])
25
+ assert.deepEqual(groups.map((g) => g.label), [undefined, '工具说明'])
26
+ const onlyGrouped = groupControlRows([row('t1', { group: '工具说明' })])
27
+ assert.deepEqual(onlyGrouped.map((g) => g.label), ['工具说明'], '只有分组条目时不该冒出一个空的无组节')
28
+ })
29
+
30
+ test('组的先后 = 首次出现序(不按字母重排)', () => {
31
+ const groups = groupControlRows([
32
+ row('h', { group: '开场身份' }),
33
+ row('w', { group: '工具说明' }),
34
+ row('e', { group: '工具说明' }),
35
+ row('p', { group: '计划模式' }),
36
+ ])
37
+ assert.deepEqual(groups.map((g) => g.label), ['开场身份', '工具说明', '计划模式'])
38
+ assert.deepEqual(groups[1]!.items.map((i) => i.id), ['w', 'e'], '组内顺序被重排 ⇒ 面板序与 prompt 序脱钩')
39
+ })
40
+
41
+ test('同名组跨位置仍归一组(不因中间夹了别的组而拆开)', () => {
42
+ const groups = groupControlRows([
43
+ row('w1', { group: '工具说明' }),
44
+ row('h', { group: '开场身份' }),
45
+ row('w2', { group: '工具说明' }),
46
+ ])
47
+ const tool = groups.find((g) => g.label === '工具说明')!
48
+ assert.deepEqual(tool.items.map((i) => i.id), ['w1', 'w2'])
49
+ })
50
+
51
+ test('空输入 ⇒ 空数组', () => {
52
+ assert.deepEqual(groupControlRows([]), [])
53
+ })
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 扩展详情页 controls 的**分组**纯逻辑(客户端,可单测)。
3
+ *
4
+ * 分组是**归属信息**,不是样式:一条扩展同时管两类东西时(分层提示词 =
5
+ * 准则分类 + 宿主系统提示词段落),平铺成一长列无从分辨哪条是谁的。
6
+ *
7
+ * 两条规则:
8
+ * ① **无组节恒排最前** —— 没用 `group` 字段的扩展(ext-test 等)拿到的
9
+ * 分组结果就是原来的平铺列表(一个无名节),面板行为一个像素不变;
10
+ * ② 组的先后 = 组名在 `rows` 里的**首次出现序**,组内保持原顺序 ——
11
+ * 不按字母重排:`rows` 的顺序来自服务端(段落按 prompt 序),重排
12
+ * 就会把"面板顺序 ≈ 模型看到的顺序"这条对应关系打断。
13
+ */
14
+ import type { ExtControlItem } from '../shared.ts'
15
+
16
+ /** 一个分组:`label === undefined` 表示"无组节"(平铺条目)。 */
17
+ export interface ControlGroup {
18
+ label?: string
19
+ items: ExtControlItem[]
20
+ }
21
+
22
+ /** 按 `group` 分组;空输入返回空数组(不产出空的无组节)。 */
23
+ export function groupControlRows(rows: readonly ExtControlItem[]): ControlGroup[] {
24
+ const plain: ExtControlItem[] = []
25
+ const groups: ControlGroup[] = []
26
+ const byLabel = new Map<string, ExtControlItem[]>()
27
+ for (const c of rows) {
28
+ if (c.group === undefined) {
29
+ plain.push(c)
30
+ continue
31
+ }
32
+ let items = byLabel.get(c.group)
33
+ if (!items) {
34
+ items = []
35
+ byLabel.set(c.group, items)
36
+ groups.push({ label: c.group, items })
37
+ }
38
+ items.push(c)
39
+ }
40
+ const out: ControlGroup[] = []
41
+ if (plain.length > 0) out.push({ items: plain })
42
+ return [...out, ...groups]
43
+ }
@@ -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
  * 形状:**不属于任何分类** ⇒ 不可被单条开关关掉(它一关,准则就无名无姓)。