@master0071/dsh-systemone 0.4.2 → 0.4.4

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
@@ -305,7 +305,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
305
305
  provider: unisound # unisound / http / mock
306
306
  apiKey: '' # 留空则读环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY
307
307
  model: u2-decision
308
- customScenarios: '' # 结构性:需要重启
308
+ customScenarios: '' # 热更新:保存后场景库立即重建
309
309
  autoDecide: false # 结构性:需要重启
310
310
  ```
311
311
 
@@ -332,6 +332,13 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
332
332
 
333
333
  也可以在 profile 的 `cordis.patch.yml` 里配置 `customScenarios`(同样的 JSON 字符串)。同 `id` 会覆盖内置场景,否则新增。
334
334
 
335
+ 覆盖与别名的解析规则:
336
+
337
+ - **同 `id` 覆盖**:自定义场景替换同名内置场景,只写 `title`/`questions` 也够——被替换场景原有的 `aliases` 会被**继承**(省略 `aliases` 不等于清空别名),新声明的别名与继承的别名共存且自动去重。
338
+ - **别名冲突**:别名与 `id` 平权参与查找,冲突时**后登记者获胜**(自定义场景优先于内置场景,自定义之间后声明者优先)。因此自定义场景声明的别名一定指向它自己,不会被内置场景抢走。
339
+ - **冲突提示**:别名撞名是**非致命**的——场景照常可用,但插件会给出提示(宿主日志里带「(提示)」前缀,`systemone_scenario(action: "list")` 的 `warnings` 字段与「配置提示」小节里也会列出),说明谁遮蔽了谁,避免调用方把「别名被占」误判成「场景不存在」。
340
+ - **查找大小写不敏感**,`findScenario` 同时匹配 `id` 与 `aliases`。
341
+
335
342
  ### 用自定义场景做通用意图识别
336
343
 
337
344
  SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开放式意图分类器**:它的标签空间就是你声明的 `criteria`。所以要「通用意图识别」,就自己定义一个意图场景,把标签集写进去,再把「固定场景」指向它:
@@ -425,11 +432,11 @@ SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开
425
432
 
426
433
  | 字段 | 必填 | 说明 |
427
434
  | --- | --- | --- |
428
- | `id` | ✅ | 唯一标识 |
435
+ | `id` | ✅ | 唯一标识;首尾空白会被自动 trim |
429
436
  | `title` | ✅ | 展示名 |
430
437
  | `description` | | 场景说明(`list` 时展示) |
431
- | `aliases` | | 别名数组,便于按中文名/英文名查找 |
432
- | `questions` | ✅ | 问题映射,最多 16 个(延迟随问题数线性增长) |
438
+ | `aliases` | | 别名数组,便于按中文名/英文名查找;与 `id` 平权,冲突时自定义场景优先于内置场景。首尾空白自动 trim,空串/纯空白项会被丢弃 |
439
+ | `questions` | ✅ | 问题映射,最多 16 个(延迟随问题数线性增长)。问题 id 不能是 `__proto__` / `constructor` / `prototype` |
433
440
  | `derive` | | 派生字段:`{ name: { question, values } }` 或 `{ name: { question, map } }` |
434
441
  | `recommendation` | | 建议模板 |
435
442
 
package/lib/auto.js CHANGED
@@ -64,6 +64,8 @@ export function installAutoDecide(ctx, deps) {
64
64
  if (!readBoolean(config, 'autoDecide', false)) return () => {}
65
65
 
66
66
  const disposers = []
67
+ /** agentId → 该 agent 的 per-agent 清理函数;agent 销毁时一并调用并从表中移除,避免无界增长。 */
68
+ const agentDisposers = new Map()
67
69
  /** agentId → { stateKey, text, scenarioId, source, headline, at }:供 context 通道渲染。 */
68
70
  const perAgent = new Map()
69
71
  /** agentId → 上次注入的决策文本:同一决策只注入一次,避免每步重复污染历史。 */
@@ -84,12 +86,23 @@ export function installAutoDecide(ctx, deps) {
84
86
  }
85
87
  }
86
88
 
87
- // agent 销毁时清理状态,避免长会话内存泄漏
89
+ // agent 销毁时清理状态与 per-agent 监听器,避免长会话内存泄漏
88
90
  disposers.push(ctx.on('agent/disposed', (payload) => {
89
91
  const id = payload?.agent?.id
90
92
  if (id !== undefined) {
91
93
  perAgent.delete(id)
92
94
  lastInjected.delete(id)
95
+ const list = agentDisposers.get(id)
96
+ if (list) {
97
+ for (const dispose of list) {
98
+ try {
99
+ dispose()
100
+ } catch {
101
+ // 卸载竞态
102
+ }
103
+ }
104
+ agentDisposers.delete(id)
105
+ }
93
106
  }
94
107
  }))
95
108
 
@@ -101,10 +114,13 @@ export function installAutoDecide(ctx, deps) {
101
114
  const agent = payload?.agent
102
115
  const agentCtx = agent?.ctx
103
116
  if (!agentCtx) return
117
+ const agentId = agent?.id
118
+ // 该 agent 的监听器与动态上下文注册统一记到这里,随 agent 销毁一起移除
119
+ const perAgentDisposers = []
104
120
  // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
105
121
  if (typeof agentCtx.on === 'function') {
106
122
  try {
107
- disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
123
+ const dispose = agentCtx.on('agent/inbox/inserted', (event) => {
108
124
  if (readString(config, 'autoInject', 'message') !== 'context') return
109
125
  const newText = extractUserText([event?.message])
110
126
  if (!newText) return
@@ -112,7 +128,8 @@ export function installAutoDecide(ctx, deps) {
112
128
  if (isTrivialInput(newText, readNumber(config, 'autoMinChars', AUTO_DEFAULTS.autoMinChars))) return
113
129
  // 返回 promise 便于测试等待;宿主事件总线忽略返回值
114
130
  return computeAndStore(agent, newText)
115
- }))
131
+ })
132
+ if (typeof dispose === 'function') perAgentDisposers.push(dispose)
116
133
  } catch (error) {
117
134
  log('warn', `注册入箱预计算失败:${messageOf(error)}`)
118
135
  }
@@ -120,17 +137,36 @@ export function installAutoDecide(ctx, deps) {
120
137
  // ② 把最新决策渲染进作用域动态上下文
121
138
  if (agentCtx?.systemPrompt?.context) {
122
139
  try {
123
- disposers.push(agentCtx.systemPrompt.context({
140
+ const dispose = agentCtx.systemPrompt.context({
124
141
  name: 'systemone-auto-decision',
125
142
  order: 900,
126
143
  text: () => (readString(config, 'autoInject', 'message') === 'context')
127
144
  ? (perAgent.get(agent?.id)?.text || '')
128
145
  : '',
129
- }))
146
+ })
147
+ if (typeof dispose === 'function') perAgentDisposers.push(dispose)
130
148
  } catch (error) {
131
149
  log('warn', `注册动态上下文失败:${messageOf(error)}`)
132
150
  }
133
151
  }
152
+ // 记入 per-agent 表:agent 销毁时移除;同一 agent 重复创建时先清理旧条目。
153
+ // agentId 缺失时退回插件级 disposers,随插件卸载统一清理。
154
+ if (perAgentDisposers.length === 0) return
155
+ if (agentId === undefined) {
156
+ disposers.push(...perAgentDisposers)
157
+ return
158
+ }
159
+ const existing = agentDisposers.get(agentId)
160
+ if (existing) {
161
+ for (const dispose of existing) {
162
+ try {
163
+ dispose()
164
+ } catch {
165
+ // 重复创建竞态
166
+ }
167
+ }
168
+ }
169
+ agentDisposers.set(agentId, perAgentDisposers)
134
170
  }))
135
171
 
136
172
  // ── pre-step:同一步生效(仅 message 通道) ──
@@ -243,6 +279,17 @@ export function installAutoDecide(ctx, deps) {
243
279
  // 卸载竞态
244
280
  }
245
281
  }
282
+ // 兜底:即使宿主未触发 agent/disposed,也清掉所有 per-agent 监听器
283
+ for (const list of agentDisposers.values()) {
284
+ for (const dispose of list) {
285
+ try {
286
+ dispose()
287
+ } catch {
288
+ // 卸载竞态
289
+ }
290
+ }
291
+ }
292
+ agentDisposers.clear()
246
293
  perAgent.clear()
247
294
  lastInjected.clear()
248
295
  cache.clear()
package/lib/index.d.ts CHANGED
@@ -43,6 +43,9 @@ export interface Config {
43
43
  autoMinChars?: number
44
44
  }
45
45
 
46
+ /** 运行时导出的配置 schema(schemastery 对象),与上面的接口同名。 */
47
+ export const Config: any
48
+
46
49
  /** 编程式调用 SystemOne 的参数。 */
47
50
  export interface DecideOptions {
48
51
  state: unknown
package/lib/index.js CHANGED
@@ -44,7 +44,8 @@ function createProviderProxy(current) {
44
44
  *
45
45
  * 标了 `.volatile()` 的字段可以在「插件 → @master0071/dsh-systemone → systemone」配置页
46
46
  * 里直接修改并立即生效,无需重启;读取时必须经 `unwrap()` 脱壳(见 volatile.js)。
47
- * 结构性字段(provider 之外的场景库、autoDecide 开关)不在配置页里,改动需重启。
47
+ * 唯一的非 volatile 字段是 `autoDecide`(结构性:决定装配期要不要挂 pre-step 钩子),
48
+ * 它不在配置页里,改动需重启。
48
49
  */
49
50
  const Config = z.object({
50
51
  /* ── 热更新字段(volatile):配置页可直接编辑 ────────────────────────────── */
@@ -52,8 +53,9 @@ const Config = z.object({
52
53
  /**
53
54
  * 自定义场景:JSON 数组字符串,同 id 覆盖内置场景。
54
55
  *
55
- * 这是「通用意图识别」的入口——把意图标签集写成 choice 的 criteria 即可,
56
- * 场景库会随保存立即重建(见 liveScenarios),无需重启。
56
+ * 这是「通用意图识别」的入口——把意图标签集写成 choice 的 criteria 即可。
57
+ * volatile:配置页(client.js 的 CUSTOM_FIELDS)可直接编辑,场景库会随保存
58
+ * 立即重建(见 liveScenarios),无需重启。
57
59
  */
58
60
  customScenarios: z.string().default('').volatile(),
59
61
  /** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
@@ -71,7 +73,7 @@ const Config = z.object({
71
73
  /** 置信度阈值:任一答案低于该值则建议人工复核 */
72
74
  minConfidence: z.number().min(0).max(1).default(0.6).volatile(),
73
75
 
74
- /* ── 结构性字段:改动需要重启(不在配置页里) ─────────────────────────────── */
76
+ /* ── 结构性字段:不在配置页里,改动需要重启 ───────────────────────────────── */
75
77
 
76
78
  /** 是否在每一步推理前自动捕获上下文并执行决策(挂载 pre-step,改动需重启) */
77
79
  autoDecide: z.boolean().default(false),
@@ -128,6 +130,7 @@ export default class SystemOneService extends Service {
128
130
  this._scenarioSource = null
129
131
  this._scenarioRawValue = undefined
130
132
  this.scenarioProblems = []
133
+ this.scenarioWarnings = []
131
134
  this._scenarioArray = []
132
135
  this._refreshScenarios()
133
136
  const scenarios = this.liveScenarios()
@@ -137,7 +140,11 @@ export default class SystemOneService extends Service {
137
140
  if (!tools || typeof tools.register !== 'function') {
138
141
  throw new Error('@master0071/dsh-systemone: 工具注册表不可用(inject: [\'tools\'] 未生效)')
139
142
  }
140
- this._disposers = createTools(this.provider, config, scenarios).map((tool) => tools.register(tool))
143
+ this._disposers = createTools(this.provider, config, scenarios, {
144
+ // 惰性读取:场景库可能已随 customScenarios 热更新重建,
145
+ // 这里必须每次取最新的 warnings,而不是装配期快照。
146
+ warnings: () => this.scenarioWarnings || [],
147
+ }).map((tool) => tools.register(tool))
141
148
 
142
149
  // 自动决策:默认关闭;开启后在每一步推理前自动捕获上下文并注入判断
143
150
  this._autoDispose = () => {}
@@ -192,31 +199,57 @@ export default class SystemOneService extends Service {
192
199
  * 返回一个 Proxy:任何读取(`.map`、`.length`、`findScenario` 的遍历……)都会
193
200
  * 先按需重建,然后转发到真实数组。这样工具与自动决策在装配期捕获一次引用就够,
194
201
  * 之后 `customScenarios` 怎么改都能立刻看到新场景。
202
+ *
203
+ * 实现要点:**不维护 trap 白名单**,而是统一走 `_forwardTrap()` 转发全部
204
+ * 内部方法([[Get]]/[[HasProperty]]/[[OwnPropertyKeys]]/[[GetPrototypeOf]]/
205
+ * [[IsExtensible]]……)。白名单的毛病是「漏一个就静默读到旧值」——早期版本
206
+ * 只挂了 get/has/ownKeys/getOwnPropertyDescriptor,`getPrototypeOf` 与
207
+ * `isExtensible` 就绕过了刷新(`instanceof`、按原型嗅探 `Array.prototype`
208
+ * 方法的库都会走这两条路),于是同一份引用在不同消费方眼里可能新旧不一。
209
+ * 补齐 trap 后,将来新增的内部方法也自动覆盖,不需要再回来改这里。
210
+ *
211
+ * 注意 `Array.isArray` 是规范内部方法,不经过任何 trap(也无从拦截),
212
+ * 它靠目标数组本身判定,因此始终正确、无需刷新。
195
213
  */
196
214
  liveScenarios() {
197
215
  if (this._scenarioProxy === undefined) {
216
+ // 转发全部内部方法:每个 trap 都先刷新,再调用目标数组上的同名方法。
198
217
  this._scenarioProxy = new Proxy(this._scenarioArray, {
199
- get: (target, prop, receiver) => {
200
- this._refreshScenarios()
201
- return Reflect.get(target, prop, receiver)
202
- },
203
- has: (target, prop) => {
204
- this._refreshScenarios()
205
- return Reflect.has(target, prop)
206
- },
207
- ownKeys: (target) => {
208
- this._refreshScenarios()
209
- return Reflect.ownKeys(target)
210
- },
211
- getOwnPropertyDescriptor: (target, prop) => {
212
- this._refreshScenarios()
213
- return Reflect.getOwnPropertyDescriptor(target, prop)
214
- },
218
+ get: this._forwardTrap('get'),
219
+ set: this._forwardTrap('set'),
220
+ has: this._forwardTrap('has'),
221
+ deleteProperty: this._forwardTrap('deleteProperty'),
222
+ ownKeys: this._forwardTrap('ownKeys'),
223
+ getOwnPropertyDescriptor: this._forwardTrap('getOwnPropertyDescriptor'),
224
+ defineProperty: this._forwardTrap('defineProperty'),
225
+ getPrototypeOf: this._forwardTrap('getPrototypeOf'),
226
+ setPrototypeOf: this._forwardTrap('setPrototypeOf'),
227
+ isExtensible: this._forwardTrap('isExtensible'),
228
+ preventExtensions: this._forwardTrap('preventExtensions'),
229
+ apply: this._forwardTrap('apply'),
230
+ construct: this._forwardTrap('construct'),
215
231
  })
216
232
  }
217
233
  return this._scenarioProxy
218
234
  }
219
235
 
236
+ /**
237
+ * 生成一个「先刷新、再转发」的 Proxy trap。
238
+ *
239
+ * 统一入口而不是逐个手写,保证刷新逻辑只有一份,新增 trap 也不会漏掉刷新。
240
+ *
241
+ * @param {PropertyKey} name - Reflect 上的方法名,与 trap 名一一对应
242
+ * @returns {(...args: unknown[]) => unknown}
243
+ */
244
+ _forwardTrap(name) {
245
+ return (...args) => {
246
+ // 目标数组恒为 this._scenarioArray(原地改写,引用不变),
247
+ // 但刷新必须发生在读取之前,否则拿到的是上一版场景。
248
+ this._refreshScenarios()
249
+ return Reflect[name](...args)
250
+ }
251
+ }
252
+
220
253
  /**
221
254
  * 按需重建场景库:`customScenarios` 的原始字符串变了才重建。
222
255
  *
@@ -232,14 +265,20 @@ export default class SystemOneService extends Service {
232
265
  this._scenarioRawValue = value
233
266
  const source = String(value).trim()
234
267
  if (source === this._scenarioSource) return
235
- const { scenarios, problems } = resolveScenarios(source)
268
+ const { scenarios, problems, warnings } = resolveScenarios(source)
236
269
  this._scenarioArray.length = 0
237
270
  this._scenarioArray.push(...scenarios)
238
271
  this._scenarioSource = source
239
272
  this.scenarioProblems = problems
273
+ this.scenarioWarnings = warnings
274
+ // problems=致命(条目已被跳过),warnings=非致命提示(别名遮蔽等,场景仍可用)。
275
+ // 两者都用 warn 输出,但文案前缀区分,便于一眼看出是否需要处理。
240
276
  for (const problem of problems) {
241
277
  this.ctx?.logger?.warn?.(`@master0071/dsh-systemone: ${problem}`)
242
278
  }
279
+ for (const warning of warnings) {
280
+ this.ctx?.logger?.warn?.(`@master0071/dsh-systemone(提示): ${warning}`)
281
+ }
243
282
  }
244
283
 
245
284
  /** 列出当前全部场景(内置 + 自定义)。 */
package/lib/provider.js CHANGED
@@ -84,7 +84,11 @@ export class HttpProvider {
84
84
  timer.unref?.()
85
85
  const onOuterAbort = () => controller.abort()
86
86
  if (signal && typeof signal.addEventListener === 'function') {
87
- signal.addEventListener('abort', onOuterAbort, { once: true })
87
+ if (signal.aborted) {
88
+ controller.abort()
89
+ } else {
90
+ signal.addEventListener('abort', onOuterAbort, { once: true })
91
+ }
88
92
  }
89
93
 
90
94
  try {
package/lib/scenarios.js CHANGED
@@ -406,6 +406,12 @@ export function validateScenario(spec) {
406
406
  if (ids.length === 0) problems.push('questions 至少需要 1 个问题')
407
407
  if (ids.length > 16) problems.push(`questions 最多 16 个(当前 ${ids.length} 个,延迟随问题数线性增长)`)
408
408
  for (const [qid, q] of Object.entries(questions)) {
409
+ if (isUnsafeKey(qid)) {
410
+ // 提前拦下:这类键在普通对象上会与原型机制冲突(见 normalizeScenario),
411
+ // 与其在规范化时静默丢弃、产出一个零问题的空场景,不如直接报错。
412
+ problems.push(`问题 id 不能是 ${qid}(与对象原型机制冲突)`)
413
+ continue
414
+ }
409
415
  if (!q || typeof q !== 'object') {
410
416
  problems.push(`问题 ${qid} 必须是对象`)
411
417
  continue
@@ -437,28 +443,50 @@ export function validateScenario(spec) {
437
443
  * 从配置的 JSON 字符串加载自定义场景,并与内置场景合并。
438
444
  * 同 id 的自定义场景会覆盖内置场景;非法条目被跳过并记入 problems。
439
445
  *
446
+ * 别名解析的正确性由两件事共同保证:
447
+ * 1. 覆盖内置场景时**继承**被替换者的别名(见下面的 merged),
448
+ * 避免原场景的中文别名变成查不到的死键;
449
+ * 2. `findScenario` 采用「后写优先」的倒序匹配(见该函数注释)。
450
+ * 早期实现用 `Array.find` 正序匹配,等价于「内置永远优先」,
451
+ * 自定义场景声明的别名会被内置场景静默抢走。
452
+ *
453
+ * 返回值分两条通道:
454
+ * - `problems`:**致命**问题,对应场景已被跳过(解析失败、字段非法);
455
+ * - `warnings`:**非致命**提示,场景仍然可用,只是别名解析结果需要知会用户
456
+ * (例如自定义场景的别名与内置场景撞名——按「后写优先」自定义场景获胜,
457
+ * 但被遮蔽者从此查不到,应当告知)。分开是因为 warnings 不该让条目被跳过,
458
+ * 也不该在每次重建时按 problems 的级别刷日志。
459
+ *
440
460
  * @param {string} customJson - 自定义场景 JSON(数组)
441
- * @returns {{ scenarios: object[], problems: string[] }}
461
+ * @returns {{ scenarios: object[], problems: string[], warnings: string[] }}
442
462
  */
443
463
  export function resolveScenarios(customJson) {
444
464
  const scenarios = BUILTIN_SCENARIOS.map((s) => ({ ...s, source: 'builtin' }))
445
465
  const problems = []
466
+ const warnings = []
446
467
  const raw = typeof customJson === 'string' ? customJson.trim() : ''
447
- if (!raw) return { scenarios, problems }
468
+ if (!raw) return { scenarios, problems, warnings }
448
469
 
449
470
  let parsed
450
471
  try {
451
472
  parsed = JSON.parse(raw)
452
473
  } catch (error) {
453
474
  problems.push(`自定义场景 JSON 解析失败:${messageOf(error)}`)
454
- return { scenarios, problems }
475
+ return { scenarios, problems, warnings }
455
476
  }
456
477
  if (!Array.isArray(parsed)) {
457
478
  problems.push('自定义场景必须是 JSON 数组([...])')
458
- return { scenarios, problems }
479
+ return { scenarios, problems, warnings }
459
480
  }
460
481
 
482
+ // 仅用于「按 id 判断是覆盖还是新增」;别名解析不经过这里。
461
483
  const indexById = new Map(scenarios.map((s, i) => [s.id.toLowerCase(), i]))
484
+ // 别名 → 当前持有者,仅用于生成遮蔽提示(不参与查找)。
485
+ const aliasOwner = new Map()
486
+ scenarios.forEach((s) => {
487
+ for (const alias of s.aliases || []) aliasOwner.set(alias.toLowerCase(), s.id)
488
+ })
489
+
462
490
  parsed.forEach((spec, i) => {
463
491
  const issues = validateScenario(spec)
464
492
  if (issues.length > 0) {
@@ -467,20 +495,69 @@ export function resolveScenarios(customJson) {
467
495
  }
468
496
  const entry = normalizeScenario(spec)
469
497
  const key = entry.id.toLowerCase()
470
- if (indexById.has(key)) {
471
- scenarios[indexById.get(key)] = entry
472
- } else {
498
+
499
+ // 别名撞名是一条提示:声明者获胜(后写优先),被遮蔽者从此无法通过该别名查到。
500
+ // 覆盖内置场景时继承的别名不算撞名——那是同一个场景换了实现。
501
+ const shadowed = []
502
+ for (const alias of entry.aliases) {
503
+ const akey = alias.toLowerCase()
504
+ // 与自己的 id 同名是无害的(findScenario 两者都会命中自己)
505
+ if (akey === key) continue
506
+ const owner = aliasOwner.get(akey)
507
+ if (owner !== undefined && owner.toLowerCase() !== key) {
508
+ shadowed.push(`别名「${alias}」原本指向 "${owner}"`)
509
+ }
510
+ }
511
+
512
+ if (!indexById.has(key)) {
473
513
  indexById.set(key, scenarios.length)
474
514
  scenarios.push(entry)
515
+ } else {
516
+ // 覆盖时继承被替换场景的别名:覆盖者通常只想改 title/questions,
517
+ // 不该顺手把原场景的中文别名变成查不到的死键(省略 aliases ≠ 清空别名)。
518
+ const replaced = scenarios[indexById.get(key)]
519
+ const merged = [...(replaced.aliases || [])]
520
+ for (const alias of entry.aliases || []) {
521
+ if (!merged.some((a) => a.toLowerCase() === alias.toLowerCase())) merged.push(alias)
522
+ }
523
+ entry.aliases = merged
524
+ scenarios[indexById.get(key)] = entry
525
+ }
526
+
527
+ // 覆盖已完成,刷新别名归属(继承来的别名也一并登记)
528
+ for (const alias of entry.aliases || []) aliasOwner.set(alias.toLowerCase(), entry.id)
529
+
530
+ if (shadowed.length > 0) {
531
+ warnings.push(
532
+ `自定义场景 "${entry.id}" 的别名与已有场景冲突,按「自定义优先」解析到 "${entry.id}"`
533
+ + `(被遮蔽者不再能通过该别名查到):${shadowed.join(';')}`,
534
+ )
475
535
  }
476
536
  })
477
- return { scenarios, problems }
537
+ return { scenarios, problems, warnings }
478
538
  }
479
539
 
480
- /** 规范化场景定义,补齐可选字段。 */
540
+ /**
541
+ * 规范化场景定义,补齐可选字段。
542
+ *
543
+ * 两处规范化是**必要**的,不是美化:
544
+ *
545
+ * 1. `id` / `aliases` 一律 trim。`validateScenario` 只用 trim 判空,
546
+ * 若这里原样存,而 `findScenario` 又会 trim 查询值,两边就不对称——
547
+ * `id: ' legal\n'` 能通过校验、能出现在 list 里,却连用自己的原值都查不到,
548
+ * 且想覆盖内置场景时只会新增一个永久查不到的副本。
549
+ * trim 后 `indexById` / `aliasOwner` / 去重比较的 key 也与存储值一致。
550
+ *
551
+ * 2. `questions` 用 `Object.create(null)` 建,并跳过 `__proto__` 这类危险键。
552
+ * 普通对象上 `questions['__proto__'] = q` 走的是 [[Set]],改的是**原型**而不是
553
+ * 添加属性;而 `Object.keys()` 又能看到 `__proto__` 这个自有键,
554
+ * 于是「至少 1 个问题」的校验会通过、却产出 0 个问题的空场景,
555
+ * 下游 run 静默返回 ok + 空 answers。无原型的对象天然免疫这类键。
556
+ */
481
557
  function normalizeScenario(spec) {
482
- const questions = {}
558
+ const questions = Object.create(null)
483
559
  for (const [qid, q] of Object.entries(spec.questions)) {
560
+ if (isUnsafeKey(qid)) continue
484
561
  const question = {
485
562
  type: q.type,
486
563
  instructions: String(q.instructions ?? ''),
@@ -496,10 +573,12 @@ function normalizeScenario(spec) {
496
573
  questions[qid] = question
497
574
  }
498
575
  return {
499
- id: spec.id,
576
+ id: spec.id.trim(),
500
577
  title: spec.title,
501
578
  description: typeof spec.description === 'string' ? spec.description : '',
502
- aliases: Array.isArray(spec.aliases) ? spec.aliases.filter((a) => typeof a === 'string') : [],
579
+ aliases: Array.isArray(spec.aliases)
580
+ ? spec.aliases.filter((a) => typeof a === 'string').map((a) => a.trim()).filter(Boolean)
581
+ : [],
503
582
  questions,
504
583
  derive: spec.derive && typeof spec.derive === 'object' ? spec.derive : {},
505
584
  recommendation: typeof spec.recommendation === 'string' ? spec.recommendation : '',
@@ -508,16 +587,39 @@ function normalizeScenario(spec) {
508
587
  }
509
588
  }
510
589
 
590
+ /**
591
+ * 判断一个键名是否会与对象内部机制冲突(原型污染类键)。
592
+ *
593
+ * `__proto__` 在普通对象上是访问器而非普通属性,赋值会改原型;
594
+ * `constructor` / `prototype` 虽然赋值本身无害,但没有理由作为问题 id,
595
+ * 一并拒绝以免下游做 `Object.prototype` 相关操作时踩坑。
596
+ */
597
+ function isUnsafeKey(key) {
598
+ return key === '__proto__' || key === 'constructor' || key === 'prototype'
599
+ }
600
+
511
601
  /**
512
602
  * 按 id 或别名查找场景(大小写不敏感)。
603
+ *
604
+ * 匹配顺序与 `resolveScenarios` 的登记顺序保持一致——**必须倒序**遍历:
605
+ * 后登记的场景在后,覆盖关系由下标大小体现,倒序即「后写优先」。
606
+ *
607
+ * 早期实现用 `Array.find` 正序匹配,等价于「内置永远优先」,于是自定义场景
608
+ * 声明的别名一旦与内置场景撞名,就会被内置场景静默抢走——场景就在库里,
609
+ * 却永远查不到。
610
+ *
513
611
  * @returns {object | undefined}
514
612
  */
515
613
  export function findScenario(scenarios, id) {
516
614
  if (typeof id !== 'string' || !id.trim()) return undefined
517
615
  const needle = id.trim().toLowerCase()
518
- return scenarios.find(
519
- (s) => s.id.toLowerCase() === needle || (s.aliases || []).some((a) => a.toLowerCase() === needle),
520
- )
616
+ for (let i = scenarios.length - 1; i >= 0; i--) {
617
+ const s = scenarios[i]
618
+ if (!s) continue
619
+ if (String(s.id).toLowerCase() === needle) return s
620
+ if ((s.aliases || []).some((a) => String(a).toLowerCase() === needle)) return s
621
+ }
622
+ return undefined
521
623
  }
522
624
 
523
625
  function messageOf(err) {
package/lib/tools.js CHANGED
@@ -20,17 +20,26 @@ import { readNumber, readString } from './volatile.js'
20
20
 
21
21
  const DEFAULT_MIN_CONFIDENCE = 0.6
22
22
 
23
- /** 创建全部工具定义。 */
24
- export function createTools(provider, config, scenarios) {
23
+ /**
24
+ * 创建全部工具定义。
25
+ *
26
+ * @param {object} provider - 提供商
27
+ * @param {object} config - 插件配置
28
+ * @param {object[]} scenarios - 场景库(活引用)
29
+ * @param {object} [hooks] - 可选运行时钩子
30
+ * @param {() => string[]} [hooks.warnings] - 读取当前非致命提示(如别名遮蔽),
31
+ * 用于让 `action=list` 把「哪个别名被抢走了」告诉模型,而不只是写进宿主日志。
32
+ */
33
+ export function createTools(provider, config, scenarios, hooks = {}) {
25
34
  return [
26
- scenarioTool(provider, config, scenarios),
35
+ scenarioTool(provider, config, scenarios, hooks),
27
36
  decideTool(provider, config),
28
37
  ]
29
38
  }
30
39
 
31
40
  /* ─── 1. 场景调度工具 ─────────────────────────────────────────────────────── */
32
41
 
33
- function scenarioTool(provider, config, scenarios) {
42
+ function scenarioTool(provider, config, scenarios, hooks = {}) {
34
43
  return {
35
44
  name: 'systemone_scenario',
36
45
  description: [
@@ -74,7 +83,7 @@ function scenarioTool(provider, config, scenarios) {
74
83
  const action = args.action || 'run'
75
84
 
76
85
  if (action === 'list') {
77
- return listScenarios(scenarios, args.keyword)
86
+ return listScenarios(scenarios, args.keyword, readWarnings(hooks))
78
87
  }
79
88
  if (action === 'describe') {
80
89
  return describeScenario(scenarios, args.scenario)
@@ -98,7 +107,17 @@ function scenarioTool(provider, config, scenarios) {
98
107
  }
99
108
  }
100
109
 
101
- function listScenarios(scenarios, keyword) {
110
+ /** 安全读取钩子里的提示列表:钩子缺失或抛错都不应影响 list。 */
111
+ function readWarnings(hooks) {
112
+ try {
113
+ const value = hooks?.warnings?.()
114
+ return Array.isArray(value) ? value : []
115
+ } catch {
116
+ return []
117
+ }
118
+ }
119
+
120
+ function listScenarios(scenarios, keyword, warnings = []) {
102
121
  const needle = typeof keyword === 'string' ? keyword.trim().toLowerCase() : ''
103
122
  const items = scenarios
104
123
  .filter((s) => {
@@ -120,11 +139,23 @@ function listScenarios(scenarios, keyword) {
120
139
  const summary = [
121
140
  `## SystemOne 场景库(${items.length} 个)`,
122
141
  ...items.map((s) => `- **${s.id}**:${s.title} — ${s.question_count} 个问题(${s.questions.join(',')})`),
142
+ // 别名遮蔽提示只在 list 时给出:它影响「用别名能否调到这个场景」,
143
+ // 不说的话调用方会以为是场景不存在,而不是别名被别的场景占了。
144
+ ...(warnings.length > 0
145
+ ? ['', '### 配置提示', ...warnings.map((w) => `- ${w}`)]
146
+ : []),
123
147
  '',
124
148
  '用 action=describe 查看某场景的问题定义,用 action=run 执行决策。',
125
149
  ].join('\n')
126
150
 
127
- return { ok: true, action: 'list', count: items.length, scenarios: items, summary }
151
+ return {
152
+ ok: true,
153
+ action: 'list',
154
+ count: items.length,
155
+ scenarios: items,
156
+ ...(warnings.length > 0 ? { warnings } : {}),
157
+ summary,
158
+ }
128
159
  }
129
160
 
130
161
  function describeScenario(scenarios, id) {
@@ -265,9 +296,12 @@ function applyParams(questions, params) {
265
296
  // criteria 整体替换;addCriteria 在替换结果之上追加/覆盖单个选项
266
297
  if (override.criteria) {
267
298
  if (question.type === 'choice' && !Array.isArray(override.criteria)) {
268
- next.criteria = Object.fromEntries(
269
- Object.entries(override.criteria).map(([k, v]) => [String(k), String(v)]),
270
- )
299
+ const entries = Object.entries(override.criteria)
300
+ if (entries.length < 2) {
301
+ ignored.push(`${qid}.criteria(choice 至少需要 2 个选项)`)
302
+ } else {
303
+ next.criteria = Object.fromEntries(entries.map(([k, v]) => [String(k), String(v)]))
304
+ }
271
305
  } else if (question.type === 'score' && Array.isArray(override.criteria) && override.criteria.length >= 2) {
272
306
  next.criteria = override.criteria.map((v) => String(v))
273
307
  } else {
package/lib/volatile.js CHANGED
@@ -36,9 +36,11 @@ export function readString(config, key, fallback = '') {
36
36
  return text === '' ? fallback : text
37
37
  }
38
38
 
39
- /** 读取数值配置(去壳 + 非法值兜底)。 */
39
+ /** 读取数值配置(去壳 + 空值/非法值兜底)。 */
40
40
  export function readNumber(config, key, fallback) {
41
- const value = Number(unwrap(config?.[key]))
41
+ const raw = unwrap(config?.[key])
42
+ if (raw === undefined || raw === null || raw === '') return fallback
43
+ const value = Number(raw)
42
44
  return Number.isFinite(value) ? value : fallback
43
45
  }
44
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master0071/dsh-systemone",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 10 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
5
5
  "license": "MIT",
6
6
  "type": "module",