dsh-decision-engine 0.4.0 → 0.5.0

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
@@ -4,14 +4,17 @@
4
4
  (风险、可逆性、任务相关性、注入嫌疑),据此放行、询问或拦截。默认 `shadow`:
5
5
  只记录判断,绝不打断你干活。
6
6
 
7
- **它支持所有 system-one 风格 API**,不只是 bamboonode:内置 `system one` 与
7
+ **它支持所有 system-one 风格 API**:内置 `system one` 与
8
8
  `bamboonode` 两个预设,也可以填任意 `custom` base URL(自建 llama.cpp / vLLM、
9
9
  企业网关、第三方中转)。请求路径、鉴权方式与响应结构与 system-one 协议保持一致,
10
10
  换 provider 不影响各决策点的解析逻辑。
11
11
 
12
12
  > **本项目原名 `dsh-startlux-decision`**(更早叫 `dsh-startlux-guard`)。
13
13
  > 0.4.0 起更名为 `dsh-decision-engine` 并加入 provider 抽象。
14
- > 配置文件名仍叫 `startlux.yml`,插件会自动读取旧数据目录,升级不需要重新填 key。
14
+ >
15
+ > **0.5.0 起配置文件名从 `startlux.yml` 改为 `config.yml`**——本插件已不是
16
+ > 只服务 startlux 一家,名字里再挂某个服务商只会误导。旧文件会被**自动改名
17
+ > 迁移**,升级不需要重新填 key;迁移失败时仍会回退读取旧文件名。
15
18
 
16
19
  ## 安装
17
20
 
@@ -39,22 +42,6 @@ cd dsh-decision-engine
39
42
  # 复制到 profile 的 node_modules,或 pnpm link
40
43
  ```
41
44
 
42
- ### 从旧名字升级
43
-
44
- 包名变了,profile 里的两条记录都要改(否则 DSH 找不到插件):
45
-
46
- ```jsonc
47
- // ~/.dsh/profiles/desktop/package.json
48
- "dsh.profile.bundles": [
49
- // "dsh-startlux-decision" ← 删掉这一项
50
- "dsh-decision-engine" // ← 换成这个
51
- ],
52
- "dependencies": {
53
- // "dsh-startlux-decision": "file:/Users/you/Code/dsh-startlux-decision" ← 删掉
54
- "dsh-decision-engine": "file:/Users/you/Code/dsh-startlux-decision" // key 必须与包名一致
55
- }
56
- ```
57
-
58
45
  仓库里带了迁移脚本,不用手改(默认只预览,`--write` 才写入并自动备份):
59
46
 
60
47
  ```sh
@@ -68,18 +55,16 @@ cd ~/.dsh/profiles/desktop && pnpm install
68
55
 
69
56
  | | 路径 |
70
57
  |---|---|
71
- | 新数据目录(迁移目标) | `~/.dsh/dsh-decision-engine/` |
72
- | 旧数据目录(迁移来源,保留不动) | `~/.dsh/dsh-startlux-decision/` |
73
- | 更早的位置(仅回退) | `~/.dsh/plugins/dsh-startlux-decision/`、`~/.dsh/plugins/dsh-startlux-guard/` |
58
+ | 数据目录(迁移目标) | `~/.dsh/dsh-decision-engine/` |
74
59
 
75
60
  迁移规则:
76
61
 
77
- - **只搬一次**:新目录已有 `startlux.yml` 就不再动,绝不覆盖你后来改的值。
78
- - **只搬两个文件**:`startlux.yml` 与 `telemetry.jsonl`。目录里其它东西
79
- (笔记、备份)不碰。
80
- - **源文件保留**:是拷贝不是移动,搬完旧文件还在,出问题可以对照。
81
- - **失败即回退**:新目录不可写时继续用旧目录,不会因为搬不动就丢失配置。
82
- - 迁移发生时启动日志会打一行 `已把旧数据目录的配置迁移到 …`。
62
+ - **只搬一次**:新目录已有 `config.yml` 就不再动,绝不覆盖你后来改的值。
63
+ - **配置改名**:新目录里的旧名 `startlux.yml` 会被 `rename` 成 `config.yml`
64
+ (rename 失败则拷贝);跨目录迁移是**拷贝**,源文件保留,出问题可对照。
65
+ - **只搬两个文件**:配置文件与 `telemetry.jsonl`。目录里其它东西
66
+ (笔记、备份)一概不碰。
67
+ - **权限 600**:配置里可能有明文 key,迁移后仍保持只有你可读。
83
68
 
84
69
  ## 配置
85
70
 
@@ -90,6 +75,7 @@ cd ~/.dsh/profiles/desktop && pnpm install
90
75
 
91
76
  - **服务提供方**下拉:`system one` / `bamboonode` / `custom`,下面一行显示当前生效端点
92
77
  - 两个开关:`启用守卫`、`真正拦截`
78
+ - **思考模式**下拉:`never` / `auto` / `always`(见下文「省 token 的手段」里的取舍)
93
79
  - **模型**下拉(来自当前 provider 的 `/v1/models`,含「自动(服务端默认)」)
94
80
  - 一个 **API Key 输入框** + `保存` 按钮
95
81
  - 选 `custom` 时展开四个字段:`Base URL`(必填)、`端点风格`、`附加请求头`、`密钥变量名`
@@ -97,7 +83,7 @@ cd ~/.dsh/profiles/desktop && pnpm install
97
83
  - **决策模型参与点**列表:每个决策点一行,右侧是 `关闭 / 观察 / 生效` 三态
98
84
  - `自身 token 预算` 输入框,以及实时的缓存命中率与每个点的 token / 延迟统计
99
85
 
100
- 改完点`保存`即写入 `startlux.yml`,**不需要重启**:监听器常驻注册,provider 与
86
+ 改完点`保存`即写入 `config.yml`,**不需要重启**:监听器常驻注册,provider 与
101
87
  模式在每次调用时现读;预算与缓存参数同样是取值函数而非启动期快照。
102
88
 
103
89
  ## Provider:接任意 system-one 风格服务
@@ -163,7 +149,7 @@ content-type: application/json
163
149
 
164
150
  ### 持久化与切换行为
165
151
 
166
- - 所有 provider 的配置**并列**存在 `startlux.yml` 里,互不覆盖:
152
+ - 所有 provider 的配置**并列**存在 `config.yml` 里,互不覆盖:
167
153
  `provider` 记录当前选择,`baseUrl`/`model` 属于内置预设(bamboonode 的
168
154
  `baseUrl` 保留老的覆盖语义),`customBaseUrl`/`customModel`/`customApiKey`/
169
155
  `customApiKeyEnv`/`customHeaders`/`customEndpointStyle` 属于 custom。
@@ -182,7 +168,7 @@ content-type: application/json
182
168
  | `keyStorage` | 写到哪里 | 适合 |
183
169
  |---|---|---|
184
170
  | `credentials`(默认) | `~/.dsh/.credentials.yaml` 的 `refs:` 段 | 与其他服务(DeepSeek / ModelScope …)统一托管,插件目录不留明文 |
185
- | `config` | 插件配置目录下的 `startlux.yml` | 希望配置自包含、能随目录整体备份或迁移;注意是**明文** |
171
+ | `config` | 插件配置目录下的 `config.yml` | 希望配置自包含、能随目录整体备份或迁移;注意是**明文** |
186
172
 
187
173
  在设置界面「密钥保存位置」下拉切换,保存后立即按新位置写入;已有的 key 不会被
188
174
  自动搬家(避免"看不到的地方被改动"),需要时重新粘贴一次即可。
@@ -283,7 +269,7 @@ content-type: application/json
283
269
 
284
270
  配置页的「模型」是**下拉框**,由服务商实时提供,另有「自动(服务端默认)」选项。
285
271
 
286
- **默认就是「自动」**(`startlux.yml` 里 `model:` 留空):插件**不发送该字段**,
272
+ **默认就是「自动」**(`config.yml` 里 `model:` 留空):插件**不发送该字段**,
287
273
  服务端用它当前的默认决策模型。实测省略 `model` 返回 200,响应里回填实际使用的模型名:
288
274
 
289
275
  ```json
@@ -292,7 +278,7 @@ content-type: application/json
292
278
 
293
279
  这样即便服务商将来更换模型名,留空配置也不会因此 400。
294
280
 
295
- > 注意:`startlux.yml` 里的 `model:` **必须写出来但留空**(`model:` 后面不跟值)。
281
+ > 注意:`config.yml` 里的 `model:` **必须写出来但留空**(`model:` 后面不跟值)。
296
282
  > 解析器把显式空值保留为 `""`;若整行删掉,键会缺失并回落到内置的硬编码模型名,
297
283
  > 「自动」就不生效了。
298
284
 
@@ -370,8 +356,18 @@ AbortSignal.timeout(budget) → 超时 → degrade("timeout") → 钩子 next()
370
356
  于是**实测 309 次调用命中 0 次**——等于缓存完全没工作。
371
357
  现在 4 条同类 `node -e '...'` 变体只需 1 次真实请求(命中率 60%),
372
358
  而 `rm -rf` 不会复用任何缓存裁决。
373
- - **`thinkMode: never`**:实测主要省**延迟**(约 1.9s vs 7.2s);输入 token 基本不变
374
- (该端点 `output_tokens` 恒为 0,思考开销未计入计量)。
359
+ - **`thinkMode` 默认 `never`**:实测主要省**延迟**(约 1.9s vs 7.2s);输入 token 基本不变
360
+ (该端点 `output_tokens` 恒为 0,思考开销未计入计量)。三档可在配置页「思考模式」
361
+ 下拉切换,或手改 `config.yml`:
362
+
363
+ | 值 | 行为 | 适用 |
364
+ |---|---|---|
365
+ | `never` | 不思考。默认值 | 热路径首选,不想拖慢每次工具调用 |
366
+ | `auto` | 仅在模型自己判断需要时思考 | 折中 |
367
+ | `always` | 总是思考 | 高风险场景、排查误判时临时使用;每次约多等 5s |
368
+
369
+ 非法值(如 `thinkMode: sometimes`)会被归一成 `never`,不会被原样发给服务端:
370
+ 配置来自自研 YAML 子集解析器,绕过了 Schemastery 校验,因此取值必须在这里兜。
375
371
  - **只读工具直接放行**:`read`/`grep`/`glob`/`todo_write` 等不发请求。
376
372
  - **state 截断**:`args` 走头尾摘要,`recent` 只带最近 4 条、每条 240 字符。
377
373
  - **失败冷却**:连续 3 次失败进入 60s 冷却,避免对着故障端点烧钱。
@@ -396,7 +392,7 @@ pnpm verify # 前两条一起跑
396
392
  | `test/spec.test.js` | 每个决策点的问法 / 解析 / 处置与阈值边界 |
397
393
  | `test/engine.test.js` | 适配器接线、off/observe/enforce 语义、全部降级分支 |
398
394
  | `test/providers.test.js` | provider 预设、地址拼接、URL 校验、鉴权与请求构造 |
399
- | `test/config-file.test.js` | `startlux.yml` 读写往返:保存后再打开的回显、切换不互相覆盖 |
395
+ | `test/config-file.test.js` | `config.yml` 读写往返:保存后再打开的回显、切换不互相覆盖 |
400
396
  | `test/client-ui.test.js` | 设置界面渲染:provider 下拉、custom 字段、错误提示 |
401
397
 
402
398
  `test/providers.test.js` 是 provider 改造的验收用例(不依赖宿主依赖,
@@ -459,7 +455,7 @@ system-one 服务),它逐点校验:
459
455
 
460
456
  ### 其余八个决策点
461
457
 
462
- 全部阈值都在 `startlux.yml` 里可覆盖,默认值按实测校准(见上文「阈值是实测
458
+ 全部阈值都在 `config.yml` 里可覆盖,默认值按实测校准(见上文「阈值是实测
463
459
  校准的」)。下表列出「改动它会怎样」:
464
460
 
465
461
  | 键 | 默认 | 调高会怎样 | 调低会怎样 |
@@ -2,17 +2,22 @@
2
2
  #
3
3
  # 用法:复制到数据目录,按需修改。
4
4
  # mkdir -p ~/.dsh/dsh-decision-engine
5
- # cp startlux.example.yml ~/.dsh/dsh-decision-engine/startlux.yml
5
+ # cp config.example.yml ~/.dsh/dsh-decision-engine/config.yml
6
6
  #
7
- # 文件名刻意仍叫 startlux.yml:老版本就叫这个名字,改名会让已保存的配置
8
- # (含 API key 与决策点开关)失效。插件会自动读取以下位置,取第一个存在的:
9
- # ~/.dsh/dsh-decision-engine/startlux.yml (首选位置)
10
- # ~/.dsh/dsh-startlux-decision/startlux.yml (上一代;首次启动会自动拷到新目录)
7
+ # 文件名是 config.yml:本插件早已不是「只服务 startlux 一家」的插件,
8
+ # 而是任意 system-one 风格服务(system one / bamboonode / custom)的决策层,
9
+ # 名字里再挂某个服务商只会误导。旧名 startlux.yml 仍会被自动读取并迁移,
10
+ # 升级不需要重新填 key。
11
+ #
12
+ # 插件按以下顺序找配置,取第一个存在的:
13
+ # ~/.dsh/dsh-decision-engine/config.yml (首选位置)
14
+ # ~/.dsh/dsh-decision-engine/startlux.yml (旧名,首次启动自动改名)
15
+ # ~/.dsh/dsh-startlux-decision/startlux.yml (上一代数据目录;自动拷到新目录)
11
16
  # ~/.dsh/plugins/dsh-startlux-decision/startlux.yml
12
17
  # ~/.dsh/plugins/dsh-startlux-guard/startlux.yml
13
18
  #
14
19
  # 数据目录(插件自动读取):
15
- # <上述目录>/startlux.yml 配置
20
+ # <上述目录>/config.yml 配置
16
21
  # <上述目录>/telemetry.jsonl 决策记录
17
22
  #
18
23
  # 改完立即生效:每次工具调用 / 每一步前都会检查 mtime,无需重启 DSH。
@@ -110,10 +115,6 @@ keyStorage: credentials
110
115
  # bamboonodeApiKey: ""
111
116
  # customApiKey: ""
112
117
 
113
- # 历史字段:老版本的单一 key 槽位,仅对 bamboonode 生效(保留兼容,不建议再用)。
114
- apiKey: ""
115
- apiKeyEnv: BAMBOO_API_KEY
116
-
117
118
  # .credentials.yaml 里的形态就是 refs 段:
118
119
  # refs:
119
120
  # BAMBOO_API_KEY: sk-你的key
@@ -122,7 +123,11 @@ apiKeyEnv: BAMBOO_API_KEY
122
123
  # 超时预算(毫秒)。超时自动降级放行,连续失败进入冷却。
123
124
  timeoutMs: 8000
124
125
 
125
- # never = 不思考,最省 token(默认);auto = 仅在不确定时思考
126
+ # 思考模式(startlux 的 think_mode),三档:
127
+ # never 不思考——最省延迟(实测约 1.9s vs 7.2s),默认值,热路径首选
128
+ # auto 仅在模型自己判断需要时思考——折中
129
+ # always 总是思考——判断更充分,但每次工具调用约多等 5 秒
130
+ # 该端点 output_tokens 恒为 0,思考开销不计入计量,所以省的主要是延迟。
126
131
  thinkMode: never
127
132
 
128
133
  # ============================================================================
package/cordis.patch.yml CHANGED
@@ -7,7 +7,7 @@
7
7
  # 并非用户显式配置。若在此硬编码,会盖掉用户在配置页保存的选择,
8
8
  # 表现为「一按保存就自动弹回关闭」。
9
9
  #
10
- # 运行时配置的唯一来源是插件目录下的 startlux.yml(配置页保存的也是它)。
10
+ # 运行时配置的唯一来源是插件目录下的 config.yml(配置页保存的也是它)。
11
11
  # 插件内的 DEFAULTS 只负责兜底,不在此重复声明。
12
12
  - insert:
13
13
  - id: decision-engine
package/lib/client.js CHANGED
@@ -222,7 +222,7 @@ window.__ModuleLoader__.load({
222
222
  * 每行左侧是「标题 + 灰色说明」,右侧是开关;底部一个「保存」按钮。
223
223
  *
224
224
  * 数据全部走本插件自己的 `/api/decision-engine/*`,**不用** settings 服务
225
- *(旧插件死在 `settings.installSection` 上)。保存即改写 startlux.yml。
225
+ *(旧插件死在 `settings.installSection` 上)。保存即改写 config.yml。
226
226
  *
227
227
  * @param props - 槽位属性;`view === "summary"` 时只渲染一行摘要文本。
228
228
  */
@@ -271,6 +271,7 @@ window.__ModuleLoader__.load({
271
271
  setDraft({
272
272
  enabled: next.enabled === true,
273
273
  mode: next.mode ?? "shadow",
274
+ thinkMode: next.thinkMode ?? "never",
274
275
  model: next.rawModel ?? "",
275
276
  systemoneModel: next.rawSystemoneModel ?? "",
276
277
  });
@@ -352,6 +353,9 @@ window.__ModuleLoader__.load({
352
353
  keyHint: "",
353
354
  keyStorage: "credentials",
354
355
  keyStatus: {},
356
+ // 兜底也要给出思考模式取值:status 请求失败时下拉框仍要能显示。
357
+ thinkMode: "never",
358
+ thinkModes: ["never", "auto", "always"],
355
359
  stats: { requests: 0, cached: 0 },
356
360
  };
357
361
 
@@ -359,6 +363,8 @@ window.__ModuleLoader__.load({
359
363
  const draft = draftState ?? {
360
364
  enabled: view.enabled === true,
361
365
  mode: view.mode ?? "shadow",
366
+ // 思考模式也走 draft:改完点保存才落盘,与开关语义一致。
367
+ thinkMode: view.thinkMode ?? "never",
362
368
  model: view.rawModel ?? "",
363
369
  systemoneModel: view.rawSystemoneModel ?? "",
364
370
  };
@@ -382,6 +388,7 @@ window.__ModuleLoader__.load({
382
388
  setDraft({
383
389
  enabled: next.enabled === true,
384
390
  mode: next.mode ?? "shadow",
391
+ thinkMode: next.thinkMode ?? "never",
385
392
  model: next.rawModel ?? "",
386
393
  systemoneModel: next.rawSystemoneModel ?? "",
387
394
  });
@@ -488,6 +495,9 @@ window.__ModuleLoader__.load({
488
495
  const patch = {
489
496
  enabled: draft.enabled === true,
490
497
  mode: draft.mode,
498
+ // 思考模式总是提交:它是全局项(与 provider 无关),
499
+ // 服务端还会再校验一次合法取值。
500
+ thinkMode: draft.thinkMode ?? "never",
491
501
  // 模型字段只写当前 provider 自己那一格(model / systemoneModel /
492
502
  // customModel)。混写会让「切过去再切回来」丢掉原值,也会把
493
503
  // bamboonode 的模型名发给别的服务。
@@ -705,7 +715,7 @@ window.__ModuleLoader__.load({
705
715
  "生效端点",
706
716
  isCustom
707
717
  ? "由上方 base URL + 端点风格拼接;已带 /decisions 或 /systemone 时原样使用"
708
- : "内置预设的默认地址(可用 startlux.yml 的 baseUrl 覆盖 bamboonode)",
718
+ : "内置预设的默认地址(可用 config.yml 的 baseUrl 覆盖 bamboonode)",
709
719
  h(
710
720
  "span",
711
721
  { style: { ...S.v, fontSize: "11px", opacity: 0.75, maxWidth: "300px" } },
@@ -739,6 +749,45 @@ window.__ModuleLoader__.load({
739
749
  }),
740
750
  "mode",
741
751
  ),
752
+ // 2.1) 思考模式:never 最省延迟,always 判断更充分但每次多等约 5s。
753
+ // 这一项以前只能手改配置文件,界面没有入口(后端一直支持三档)。
754
+ row(
755
+ "思考模式",
756
+ draft.thinkMode === "always"
757
+ ? "总是思考:判断更充分,但每次工具调用约多等 5 秒"
758
+ : draft.thinkMode === "auto"
759
+ ? "仅在模型自己判断需要时思考(折中)"
760
+ : "不思考:最快(约 1.9s),热路径首选",
761
+ h(
762
+ "select",
763
+ {
764
+ style: { ...S.input, minWidth: "190px", flex: "none" },
765
+ value: draft.thinkMode ?? "never",
766
+ disabled: busy !== "",
767
+ onChange: (event) => {
768
+ setDraft({ ...draft, thinkMode: event.target.value });
769
+ setDirty(true);
770
+ dirtyRef.current = true;
771
+ },
772
+ },
773
+ // 选项由服务端下发(thinkModes),前端不硬编码取值,避免漂移。
774
+ ...(Array.isArray(view.thinkModes) && view.thinkModes.length > 0
775
+ ? view.thinkModes
776
+ : ["never", "auto", "always"]
777
+ ).map((value) =>
778
+ h(
779
+ "option",
780
+ { key: value, value },
781
+ value === "never"
782
+ ? "never(不思考)"
783
+ : value === "auto"
784
+ ? "auto(按需思考)"
785
+ : "always(总是思考)",
786
+ ),
787
+ ),
788
+ ),
789
+ "thinkMode",
790
+ ),
742
791
  // 3) 模型:下拉选择,含「自动(服务端默认)」
743
792
  // custom 用自己那一格(customModel),内置预设共用 model——
744
793
  // 分开存才能做到「切 provider 不覆盖其它 provider 已保存的模型」。
@@ -805,7 +854,7 @@ window.__ModuleLoader__.load({
805
854
  row(
806
855
  "密钥保存位置",
807
856
  storageValue === "config"
808
- ? "存进插件配置 startlux.yml(自包含,便于随目录备份;注意是明文)"
857
+ ? "存进插件配置 config.yml(自包含,便于随目录备份;注意是明文)"
809
858
  : "存进 ~/.dsh/.credentials.yaml(默认,与其他服务统一托管)",
810
859
  h(
811
860
  "select",
@@ -820,7 +869,7 @@ window.__ModuleLoader__.load({
820
869
  },
821
870
  },
822
871
  h("option", { value: "credentials" }, ".credentials.yaml(默认)"),
823
- h("option", { value: "config" }, "插件配置 startlux.yml"),
872
+ h("option", { value: "config" }, "插件配置 config.yml"),
824
873
  ),
825
874
  "keyStorage",
826
875
  ),
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `startlux.yml` 的极简读写。
2
+ * `config.yml` 的极简读写。
3
3
  *
4
4
  * 为什么自己写:本插件的配置全是「标量 + 字符串数组」,为它引入一个 YAML
5
5
  * 依赖不值得(插件目标之一是零运行时依赖)。但这套读写必须**可测**——
package/lib/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { appendFile, mkdir, readFile } from "node:fs/promises";
25
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
25
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { join } from "node:path";
28
28
  import z from "@deepseek-ai/schemastery";
@@ -40,13 +40,16 @@ import { applyConfigUpdates, parseConfigText } from "./config-file.js";
40
40
  import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
41
41
  import {
42
42
  DEFAULT_PROVIDER,
43
+ DEFAULT_THINK_MODE,
43
44
  ENDPOINT_STYLES,
44
45
  PROVIDER_IDS,
45
46
  PROVIDER_PRESETS,
47
+ THINK_MODES,
46
48
  buildEndpoint,
47
49
  buildHeaders,
48
50
  buildRequestBody,
49
51
  normalizeBaseUrl,
52
+ normalizeThinkMode,
50
53
  parseCustomHeaders,
51
54
  providerCatalog,
52
55
  resolveKeyFor,
@@ -99,12 +102,13 @@ const DEFAULTS = {
99
102
  /**
100
103
  * 密钥保存位置:
101
104
  * credentials = ~/.dsh/.credentials.yaml 的 refs 段(默认,与其他服务统一托管)
102
- * config = 插件自己的 startlux.yml(自包含,便于随目录备份/迁移)
105
+ * config = 插件自己的 config.yml(自包含,便于随目录备份/迁移)
103
106
  */
104
107
  keyStorage: "credentials",
105
108
  apiKeyEnv: "BAMBOO_API_KEY",
106
109
  timeoutMs: DEFAULT_TIMEOUT_MS,
107
- thinkMode: "never",
110
+ // 思考模式:取值与默认值都来自 providers.js,避免两处各写一份而漂移。
111
+ thinkMode: DEFAULT_THINK_MODE,
108
112
  cooldownMs: 60000,
109
113
  failureThreshold: 3,
110
114
  cacheSize: 256,
@@ -237,7 +241,7 @@ export const Config = z.object({
237
241
  keyStorage: z.union(["credentials", "config"]).default(DEFAULTS.keyStorage)
238
242
  .description(
239
243
  "密钥保存位置:credentials = ~/.dsh/.credentials.yaml(默认,与其他服务统一托管);"
240
- + "config = 插件配置目录下的 startlux.yml",
244
+ + "config = 插件配置目录下的 config.yml",
241
245
  ),
242
246
  timeoutMs: z.number().step(1).min(1000).default(DEFAULTS.timeoutMs),
243
247
  thinkMode: z.union(["never", "auto", "always"]).default(DEFAULTS.thinkMode)
@@ -438,6 +442,15 @@ export function validateProviderPatch(patch, current) {
438
442
  };
439
443
  }
440
444
 
445
+ // 思考模式同样在落盘前拦下:写进 yml 再报错等于让用户带着坏配置重启,
446
+ // 而坏值只会得到一次静默降级。
447
+ if (Object.hasOwn(patch, "thinkMode") && !THINK_MODES.includes(patch.thinkMode)) {
448
+ return {
449
+ ok: false,
450
+ error: `未知思考模式: ${String(patch.thinkMode)}(可选 ${THINK_MODES.join(" / ")})`,
451
+ };
452
+ }
453
+
441
454
  // 变量名必须能当环境变量用,否则凭据永远读不到。
442
455
  for (const key of ["customApiKeyEnv", "bamboonodeApiKeyEnv", "systemoneApiKeyEnv"]) {
443
456
  if (!Object.hasOwn(patch, key)) continue;
@@ -579,6 +592,26 @@ export function listCredentialRefs() {
579
592
  /** 插件更名后的数据目录名(`~/.dsh/<name>/`)。 */
580
593
  const DATA_DIR_NAME = "dsh-decision-engine";
581
594
 
595
+ /**
596
+ * 当前配置文件名。
597
+ *
598
+ * 早期叫 `startlux.yml`——那时本插件只服务 startlux 一家。现在它是
599
+ * 「对任意 system-one 风格服务的决策层」,名字里再挂某个服务商就会误导
600
+ * (用户会以为换 provider 要换文件),所以改叫 `config.yml`。
601
+ *
602
+ * **旧名字必须继续读**(见 `LEGACY_CONFIG_FILE_NAMES`):改文件名本身
603
+ * 不影响任何配置语义,一旦处理不当,用户唯一感知到的就是「我的 key 没了」。
604
+ */
605
+ const CONFIG_FILE_NAME = "config.yml";
606
+
607
+ /**
608
+ * 历史配置文件名,按新→旧优先级排列。
609
+ *
610
+ * 只做**一次性迁移 + 回退读取**,绝不删除旧文件:配置里可能有明文 key,
611
+ * 删错一次就要用户重填。留在原地的成本只有 2KB。
612
+ */
613
+ const LEGACY_CONFIG_FILE_NAMES = ["startlux.yml"];
614
+
582
615
  /**
583
616
  * 插件数据目录:`~/.dsh/dsh-decision-engine/`。
584
617
  * 与插件源码分离——源码可以 clone 到任意位置,运行期数据只在 DSH 家目录下。
@@ -627,21 +660,42 @@ function legacyConfigPaths() {
627
660
  * 2. 旧文件留着没有代价(配置只有 2KB),搬丢了却要用户重填 key;
628
661
  * 3. 迁移失败必须能安静降级——所有异常都吞掉,由调用方回退。
629
662
  *
630
- * 只搬 `startlux.yml` 与 `telemetry.jsonl` 两个已知文件,不递归整个目录:
631
- * 里面可能有用户自己放的笔记或备份,插件无权擅自处理。
663
+ * 搬两类东西:配置(含本次 `startlux.yml` → `config.yml` 的改名)与
664
+ * `telemetry.jsonl`。只搬这些已知文件、不递归整个目录:里面可能有用户
665
+ * 自己放的笔记或备份,插件无权擅自处理。
632
666
  *
633
667
  * @returns 是否真的搬了东西(供启动日志说明)。
634
668
  */
635
669
  function migrateLegacyData() {
636
670
  const targetDir = defaultTelemetryDir();
637
- const targetConfig = join(targetDir, "startlux.yml");
671
+ const targetConfig = join(targetDir, CONFIG_FILE_NAME);
638
672
  try {
639
673
  // 新目录已有配置 → 之前迁移过,或用户主动建过。一律不再动它。
640
674
  if (existsSync(targetConfig)) return false;
641
- const legacyConfig = legacyConfigPaths().find((path) => existsSync(path));
675
+
676
+ // 候选来源按「越新越靠前」排列:
677
+ // 1) 新目录里的旧文件名(本次改名的迁移来源,最常见)
678
+ // 2) 历史数据目录里的旧文件名
679
+ const candidates = [
680
+ ...LEGACY_CONFIG_FILE_NAMES.map((name) => join(targetDir, name)),
681
+ ...legacyConfigPaths(),
682
+ ];
683
+ const legacyConfig = candidates.find((path) => existsSync(path));
642
684
  if (legacyConfig === undefined) return false;
685
+
686
+ // 同目录改名时直接 rename:保留 mtime 之外的一切,且不会因为
687
+ // 「拷贝 + 旧文件仍在」造成两份配置并存(并存会导致读哪份说不清)。
688
+ // 跨目录/跨卷时 rename 会抛错,回退到拷贝。
643
689
  mkdirSync(targetDir, { recursive: true });
644
- copyFileSync(legacyConfig, targetConfig);
690
+ if (join(legacyConfig, "..") === targetDir) {
691
+ try {
692
+ renameSync(legacyConfig, targetConfig);
693
+ } catch {
694
+ copyFileSync(legacyConfig, targetConfig);
695
+ }
696
+ } else {
697
+ copyFileSync(legacyConfig, targetConfig);
698
+ }
645
699
  try {
646
700
  // 配置里可能有明文 key,权限保持 600。
647
701
  chmodSync(targetConfig, 0o600);
@@ -668,14 +722,22 @@ function migrateLegacyData() {
668
722
  /**
669
723
  * 在多个候选位置中挑出第一个存在的配置文件。
670
724
  *
671
- * 顺序:新目录 → 旧目录(仅在迁移没成功时才可能命中)。
725
+ * 顺序:新文件名 → 新目录里的旧文件名 → 历史目录里的旧文件名。
726
+ * 后两者只在迁移没成功(目录只读等)时才可能命中,此时继续读写旧文件,
727
+ * 好过「因为搬不动就彻底不用配置」。
672
728
  *
673
729
  * @returns 配置文件绝对路径(都不存在时返回新路径,供首次写入使用)。
674
730
  */
675
731
  function resolveConfigPath() {
676
- const preferred = join(defaultTelemetryDir(), "startlux.yml");
732
+ const targetDir = defaultTelemetryDir();
733
+ const preferred = join(targetDir, CONFIG_FILE_NAME);
677
734
  try {
678
735
  if (existsSync(preferred)) return preferred;
736
+ // 迁移失败但旧文件名还在新目录里:继续用它,避免配置凭空消失。
737
+ for (const name of LEGACY_CONFIG_FILE_NAMES) {
738
+ const stale = join(targetDir, name);
739
+ if (existsSync(stale)) return stale;
740
+ }
679
741
  for (const legacy of legacyConfigPaths()) {
680
742
  if (existsSync(legacy)) return legacy;
681
743
  }
@@ -697,7 +759,11 @@ function resolveTelemetryDir() {
697
759
  if (existsSync(join(preferred, "telemetry.jsonl"))) return preferred;
698
760
  // 新目录里还没有记录:只有「迁移失败」才继续用旧目录,
699
761
  // 否则应当写进新目录(迁移成功但还没有新记录时属于这种情形)。
700
- if (existsSync(join(preferred, "startlux.yml"))) return preferred;
762
+ // 新旧两个文件名都要认:迁移失败时配置仍留在旧文件名下。
763
+ if (existsSync(join(preferred, CONFIG_FILE_NAME))) return preferred;
764
+ for (const name of LEGACY_CONFIG_FILE_NAMES) {
765
+ if (existsSync(join(preferred, name))) return preferred;
766
+ }
701
767
  for (const legacy of legacyConfigPaths()) {
702
768
  const dir = join(legacy, "..");
703
769
  if (existsSync(join(dir, "telemetry.jsonl"))) return dir;
@@ -1102,7 +1168,7 @@ class StartluxClient {
1102
1168
  }
1103
1169
 
1104
1170
  /**
1105
- * 读取插件自己的配置文件 `<插件目录>/startlux.yml`。
1171
+ * 读取插件自己的配置文件 `<插件目录>/config.yml`。
1106
1172
  *
1107
1173
  * 配置**只放这里**,不进 DSH 的 Settings 面板,也不依赖 profile 的
1108
1174
  * cordis.patch.yml 覆盖——只有一项 api key,单独一个文件最省心,
@@ -1130,7 +1196,7 @@ async function readOwnConfig(path) {
1130
1196
  /**
1131
1197
  * 挂载守卫。
1132
1198
  * @param ctx - cordis 插件上下文。
1133
- * @param config - profile 里可选的 config(留空即可,本插件主配置在 startlux.yml)。
1199
+ * @param config - profile 里可选的 config(留空即可,本插件主配置在 config.yml)。
1134
1200
  */
1135
1201
  export function apply(ctx, config) {
1136
1202
  // 更名迁移必须先跑:它决定后面读写的到底是哪个目录。
@@ -1138,11 +1204,11 @@ export function apply(ctx, config) {
1138
1204
  const migrated = migrateLegacyData();
1139
1205
  const ownConfigPath = resolveConfigPath();
1140
1206
 
1141
- // 优先级:startlux.yml > profile 的 config 行 > DEFAULTS。
1207
+ // 优先级:config.yml > profile 的 config 行 > DEFAULTS。
1142
1208
  // 文件是主配置,profile 里的 config 只作为可选覆盖(便于临时试验)。
1143
1209
  // 注意:DSH 传给 apply 的 config 是 Schemastery 解析后的**完整默认值**
1144
1210
  //(enabled:false / mode:shadow / timeoutMs:20000 ...),并非用户显式配置。
1145
- // 若直接用它覆盖 startlux.yml,文件里保存的开关就会被永久压回默认值——
1211
+ // 若直接用它覆盖 config.yml,文件里保存的开关就会被永久压回默认值——
1146
1212
  // 这正是「保存后开关回弹」的根因。因此这里先用 DEFAULTS 打底,
1147
1213
  // 由 reloadOwnConfig 以「yml 优先、config 仅补空缺」的顺序合成。
1148
1214
  let cfg = { ...DEFAULTS };
@@ -1157,7 +1223,7 @@ export function apply(ctx, config) {
1157
1223
  const isLive = () => cfg.enabled === true;
1158
1224
 
1159
1225
  /**
1160
- * 载入 startlux.yml 并与当前配置合并。每次工具调用前都会调一次,
1226
+ * 载入 config.yml 并与当前配置合并。每次工具调用前都会调一次,
1161
1227
  * 因此**改 yml 立即生效,不用重启**(靠 mtime 判断是否需要重读)。
1162
1228
  */
1163
1229
  let lastMtime = 0;
@@ -1172,11 +1238,11 @@ export function apply(ctx, config) {
1172
1238
  // 窗口,会显示一份默认配置(provider/地址全错)。代价只是并发时多读一次文件。
1173
1239
  const own = await readOwnConfig(ownConfigPath);
1174
1240
  if (Object.keys(own).length === 0) return;
1175
- // 优先级:DEFAULTS < startlux.yml < profile 的 config 行。
1241
+ // 优先级:DEFAULTS < config.yml < profile 的 config 行。
1176
1242
  // profile 显式配置必须能覆盖文件——否则用户在 cordis.patch.yml 里改的
1177
1243
  // 值会被文件静默吃掉,测试与运维都无法覆盖(DSH 的配置层级也是这个顺序)。
1178
- // 合成顺序:DEFAULTS < DSH 传入的 config < startlux.yml。
1179
- // startlux.yml 放最后,因为它是用户显式保存的唯一入口;DSH 传入的
1244
+ // 合成顺序:DEFAULTS < DSH 传入的 config < config.yml。
1245
+ // config.yml 放最后,因为它是用户显式保存的唯一入口;DSH 传入的
1180
1246
  // config 多为 schema 默认值,不能让它盖掉用户的选择。
1181
1247
  cfg = { ...DEFAULTS, ...stripUndefined(config ?? {}), ...own };
1182
1248
  lastMtime = info.mtimeMs;
@@ -1195,7 +1261,7 @@ export function apply(ctx, config) {
1195
1261
  };
1196
1262
 
1197
1263
  /**
1198
- * 把键值写回 startlux.yml —— 保留原有注释与顺序,只替换/追加目标键。
1264
+ * 把键值写回 config.yml —— 保留原有注释与顺序,只替换/追加目标键。
1199
1265
  *
1200
1266
  * 面板「保存」的落盘实现。刻意不做整文件重写:用户的注释是说明书,
1201
1267
  * 重写一次就全丢。做法是逐行扫描,命中 `key:` 就改值,没命中就在末尾追加。
@@ -1211,7 +1277,14 @@ export function apply(ctx, config) {
1211
1277
  try {
1212
1278
  lines = (await readFile(ownConfigPath, "utf8")).split("\n");
1213
1279
  } catch {
1214
- lines = ["# dsh-decision-engine 配置", ""];
1280
+ // 首次写入:给一份最小骨架,并说明这个文件是什么(用户会直接编辑它)。
1281
+ lines = [
1282
+ "# dsh-decision-engine 配置(插件自有,唯一配置文件)",
1283
+ "#",
1284
+ "# 改这个文件即生效:每次工具调用前都会检查 mtime,改完不用重启 DSH。",
1285
+ "# 完整配置项说明见 config.example.yml。",
1286
+ "",
1287
+ ];
1215
1288
  }
1216
1289
  // 逐行替换而不是整文件重写:用户的注释是说明书,重写一次就全丢。
1217
1290
  // 具体实现与格式规则见 config-file.js(有往返测试)。
@@ -1229,7 +1302,7 @@ export function apply(ctx, config) {
1229
1302
  * 1. `credentials`(默认):写进 `~/.dsh/.credentials.yaml` 的 `refs:` 段,
1230
1303
  * 键名就是该 provider 自己的变量名(TYPESAFE_API_KEY / BAMBOO_API_KEY /
1231
1304
  * custom 的变量名)。此时 yml 里**不保留**明文 key,也不会写显式 key 字段。
1232
- * 2. `config`:写进插件自己的 `startlux.yml`,字段是该 provider 的显式 key
1305
+ * 2. `config`:写进插件自己的 `config.yml`,字段是该 provider 的显式 key
1233
1306
  * 字段(`systemoneApiKey` / `bamboonodeApiKey` / `customApiKey`)。
1234
1307
  * key 存进凭据文件的旧值**不清除**——换回 credentials 模式时还能用。
1235
1308
  * 3. 传空字符串:删除该位置的 key(用户清空即撤销)。
@@ -1293,7 +1366,7 @@ export function apply(ctx, config) {
1293
1366
  * @returns cordis 决策:next() 放行、{kind:'ask'} 询问、{kind:'deny'} 拦截。
1294
1367
  */
1295
1368
  const guardListener = async (exec, next) => {
1296
- // 每次调用前检查 startlux.yml 是否改过:改配置立即生效,无需重启。
1369
+ // 每次调用前检查 config.yml 是否改过:改配置立即生效,无需重启。
1297
1370
  await reloadOwnConfig();
1298
1371
  if (!isLive()) return next();
1299
1372
  if (!cfg.guardEnabled) return next();
@@ -1520,7 +1593,7 @@ export function apply(ctx, config) {
1520
1593
  buildRequestBody(provider, {
1521
1594
  state: { probe: true, cwd: process.cwd() },
1522
1595
  questions: { alive: { type: "noul", instructions: "Is this request well formed?" } },
1523
- thinkMode: "never",
1596
+ thinkMode: cfg.thinkMode,
1524
1597
  }),
1525
1598
  ),
1526
1599
  signal: AbortSignal.timeout(Math.max(5000, cfg.timeoutMs)),
@@ -1636,7 +1709,10 @@ export function apply(ctx, config) {
1636
1709
  // 各 provider 是否已配好 key(不泄露明文,只回报布尔与来源)。
1637
1710
  // 三个预设各自独立,切换时界面能准确显示"这一项有没有 key"。
1638
1711
  keyStatus: buildKeyStatus(),
1639
- thinkMode: cfg.thinkMode,
1712
+ // 思考模式:回传归一化后的值,避免界面把 `thinkMode: sometimes`
1713
+ // 原样回显成「已生效」;可选清单由服务端下发,前端不硬编码。
1714
+ thinkMode: normalizeThinkMode(cfg.thinkMode),
1715
+ thinkModes: THINK_MODES,
1640
1716
  timeoutMs: cfg.timeoutMs,
1641
1717
  keyConfigured: key !== undefined,
1642
1718
  keyHint: key === undefined ? "" : `…${key.slice(-4)}`,
package/lib/providers.js CHANGED
@@ -53,6 +53,40 @@ export const ENDPOINT_STYLES = ["auto", "decisions", "systemone"];
53
53
  /** 端点风格 → 追加的路径段。 */
54
54
  const STYLE_SEGMENT = { decisions: "decisions", systemone: "systemone" };
55
55
 
56
+ /**
57
+ * 合法的思考模式(与 startlux 服务的 `think_mode` 取值一致)。
58
+ *
59
+ * never 不思考——最省延迟(实测约 1.9s vs 7.2s),默认值
60
+ * auto 仅在模型自己判断需要时思考
61
+ * always 总是思考——判断更充分,但热路径上每次多等约 5s
62
+ *
63
+ * 单独导出是为了让校验、前端下拉、文档三处只有一份取值来源。
64
+ */
65
+ export const THINK_MODES = ["never", "auto", "always"];
66
+
67
+ /** 思考模式的默认值:热路径上优先保证不拖慢每次工具调用。 */
68
+ export const DEFAULT_THINK_MODE = "never";
69
+
70
+ /**
71
+ * 把配置里的 thinkMode 归一成合法取值。
72
+ *
73
+ * 为什么需要它:本插件的配置来自**自研的极简 YAML 解析器**,完全绕过
74
+ * Schemastery 的 `z.union([...])` 校验(那是 profile 层的 config 才走的路径)。
75
+ * 于是 `thinkMode: sometimes` 这种手写错误会被原样发到服务端,得到 400 后
76
+ * 静默降级放行——正是本插件最忌讳的「配了没反应」。
77
+ *
78
+ * 特别注意**空字符串**:`parseConfigText` 刻意把 `key:` 解析成 `""`
79
+ *(那是 `model:` 表示「自动」的语义),但 `""` 对 think_mode 是非法的。
80
+ * 因此这里不能用 `??` 兜底(它只挡 null/undefined),必须显式判空。
81
+ *
82
+ * @param raw - 配置里的原始值(可能是任意类型)。
83
+ * @returns 合法取值;非法一律回退默认值。
84
+ */
85
+ export function normalizeThinkMode(raw) {
86
+ const value = typeof raw === "string" ? raw.trim() : "";
87
+ return THINK_MODES.includes(value) ? value : DEFAULT_THINK_MODE;
88
+ }
89
+
56
90
  /**
57
91
  * 内置预设。
58
92
  *
@@ -230,7 +264,7 @@ export function buildEndpoint(baseUrl, style = "decisions") {
230
264
  *
231
265
  * 存储形态是 `Name: Value; Name2: Value2` 的单行字符串——本插件的配置读取器
232
266
  * 是极简 YAML 子集(只认标量与 `[a, b]` 数组),嵌套映射读不出来;用分号
233
- * 分隔既能在 `startlux.yml` 里手写,也能被面板原样回显。
267
+ * 分隔既能在 `config.yml` 里手写,也能被面板原样回显。
234
268
  *
235
269
  * 受保护的头(`authorization` / `content-type` / `host`)会被忽略:鉴权方式
236
270
  * 必须与 system-one 风格保持一致,否则换 provider 就可能悄悄换掉认证协议。
@@ -265,7 +299,7 @@ export function parseCustomHeaders(raw) {
265
299
  * 这是全插件唯一的「provider → 请求参数」解析点。`classify()`、探测、
266
300
  * 模型列表、状态接口都调它,因此三处不可能再各写一套地址规则。
267
301
  *
268
- * 解析顺序(内置预设):预设默认值打底,`startlux.yml` 里显式写的值覆盖。
302
+ * 解析顺序(内置预设):预设默认值打底,`config.yml` 里显式写的值覆盖。
269
303
  * 对 bamboonode 额外保留 `baseUrl` 的覆盖语义——那是老版本唯一的地址字段,
270
304
  * 用户可能已经指向自建的 bamboonode 兼容服务,升级不能把它改回官方地址。
271
305
  *
@@ -293,7 +327,7 @@ export function resolveProvider(cfg = {}) {
293
327
  endpointStyle: style,
294
328
  model,
295
329
  defaultModel: "",
296
- thinkMode: cfg.thinkMode,
330
+ thinkMode: normalizeThinkMode(cfg.thinkMode),
297
331
  apiKeyEnv: firstNonEmpty(cfg.customApiKeyEnv, preset.apiKeyEnv),
298
332
  legacyApiKeyEnvs: [...(preset.legacyApiKeyEnvs ?? [])],
299
333
  explicitKey: typeof cfg[preset.keyField] === "string" ? cfg[preset.keyField].trim() : "",
@@ -340,7 +374,7 @@ export function resolveProvider(cfg = {}) {
340
374
  endpointStyle: preset.endpointStyle,
341
375
  model,
342
376
  defaultModel: preset.defaultModel,
343
- thinkMode: cfg.thinkMode,
377
+ thinkMode: normalizeThinkMode(cfg.thinkMode),
344
378
  apiKeyEnv: configuredKeyEnv,
345
379
  legacyApiKeyEnvs: fallbackKeyEnvs,
346
380
  // 显式 key 也按 provider 分开存:切到 system one 时不会拿 bamboonode 的
@@ -429,7 +463,7 @@ export function buildRequestBody(provider, payload) {
429
463
  ...(model.length > 0 ? { model } : {}),
430
464
  state: payload.state,
431
465
  questions: payload.questions,
432
- think_mode: payload.thinkMode ?? provider.thinkMode ?? "never",
466
+ think_mode: normalizeThinkMode(payload.thinkMode ?? provider.thinkMode),
433
467
  };
434
468
  }
435
469
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — all nine decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, session title, COT translation) are implemented and individually switchable, and it works against any system-one style API (system one, bamboonode, or a custom base URL).",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "lib",
20
20
  "cordis.patch.yml",
21
21
  "README.md",
22
- "startlux.example.yml",
22
+ "config.example.yml",
23
23
  "icon.svg",
24
24
  "locale",
25
25
  "scripts/migrate-profile.mjs"
@@ -99,4 +99,4 @@ await copyFile(profilePath, backup);
99
99
  await writeFile(profilePath, next, "utf8");
100
100
  console.log(`\n已写入。原文件备份在:${backup}`);
101
101
  console.log("接着执行:cd " + join(profilePath, "..") + " && pnpm install");
102
- console.log("然后重启 DSH。配置(startlux.yml)不需要重填,插件会自动读取旧数据目录。");
102
+ console.log("然后重启 DSH。配置(config.yml,旧名 startlux.yml 会被自动迁移)不需要重填。");