@master0071/dsh-systemone 0.4.4 → 0.6.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/lib/index.js CHANGED
@@ -7,17 +7,18 @@
7
7
  * - 注册 2 个 Agent 工具:
8
8
  * systemone_scenario — 统一场景调度(list / describe / run)
9
9
  * systemone_decide — 自定义问题的低层入口
10
- * - 场景库内置 10 大业务域,并支持在配置里用 JSON 追加/覆盖场景
10
+ * - 场景库内置 11 大业务域,并支持在配置里用 JSON 追加/覆盖/patch 场景
11
11
  * - 暴露 `ctx.systemone.decide()` 供其他插件编程调用
12
12
  * - 提供商可配置(unisound / http / mock),切换厂商不影响工具层
13
13
  */
14
14
  import { Service } from '@deepseek-ai/cordis'
15
15
  import z from '@deepseek-ai/schemastery'
16
- import { createProvider } from './provider.js'
17
- import { createTools } from './tools.js'
16
+ import { createProvider, redactRuleSummary } from './provider.js'
17
+ import { createTools, MAX_TOOL_STATE_CHARS } from './tools.js'
18
18
  import { resolveScenarios } from './scenarios.js'
19
- import { installAutoDecide, AUTO_DEFAULTS } from './auto.js'
20
- import { readBoolean, readString, unwrap } from './volatile.js'
19
+ import { installAutoDecide, AUTO_DEFAULTS, MAX_CURRENT_CHARS, HISTORY_CHARS_PER_MESSAGE } from './auto.js'
20
+ import { createUsageLedger } from './usage.js'
21
+ import { readBoolean, readNumber, readString, unwrap } from './volatile.js'
21
22
 
22
23
  /** 合法的提供商类型;其他值会按 unisound 处理并告警一次。 */
23
24
  const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
@@ -27,16 +28,29 @@ const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
27
28
  *
28
29
  * 工具与自动决策在装配期就拿到了这个对象并长期持有;有了它,
29
30
  * 配置页里切换 `provider` 后它们无需重新装配即可用上新提供商。
31
+ *
32
+ * 与 liveScenarios 同一原则:**不做 trap 白名单**,统一转发全部
33
+ * (对象目标上合法的)内部方法——白名单的毛病是「漏一个就静默拿不到
34
+ * 新实例的成员」。provider 将来新增 health()/close() 之类接口时,
35
+ * 走代理的调用方自动可见,不需要回来改这里。
36
+ * 注意 apply/construct 两个 trap 只对函数目标合法,provider 是普通对象,不适用。
30
37
  */
31
38
  function createProviderProxy(current) {
32
- return {
33
- get name() {
34
- return current().name
35
- },
36
- decide(options) {
37
- return current().decide(options)
38
- },
39
- }
39
+ // trap 的第一个参数是创建时固定的代理目标(占位空对象),必须丢弃、
40
+ // 换成「当前提供商实例」——转发全部 trap 参数会把占位对象错当成属性名
41
+ const forward = (name) => (_target, ...args) => Reflect[name](current(), ...args)
42
+ return new Proxy({}, {
43
+ get: forward('get'),
44
+ set: forward('set'),
45
+ has: forward('has'),
46
+ deleteProperty: forward('deleteProperty'),
47
+ ownKeys: forward('ownKeys'),
48
+ getOwnPropertyDescriptor: forward('getOwnPropertyDescriptor'),
49
+ defineProperty: forward('defineProperty'),
50
+ getPrototypeOf: forward('getPrototypeOf'),
51
+ setPrototypeOf: forward('setPrototypeOf'),
52
+ isExtensible: forward('isExtensible'),
53
+ })
40
54
  }
41
55
 
42
56
  /**
@@ -60,7 +74,21 @@ const Config = z.object({
60
74
  customScenarios: z.string().default('').volatile(),
61
75
  /** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
62
76
  provider: z.string().default('unisound').volatile(),
63
- /** Unisound API Key(也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY) */
77
+ /**
78
+ * 凭证引用名:从 DSH 凭证缝 `$DSH_HOME/.credentials.yaml` 的 `refs.<名字>` 读取密钥。
79
+ *
80
+ * 这是**推荐的凭证方式**:`apiKey` 虽然标了 `.role('secret')`(表单不回显),
81
+ * 但值仍以明文落在 profile 的 `cordis.patch.yml` 里。用引用名则配置文件里只有
82
+ * 一个名字,真正的密钥留在凭证文件(或环境变量)中。
83
+ *
84
+ * 留空时按顺序尝试内置默认引用名:SYSTEMONE_API_KEY → UNISOUND_API_KEY → TYPESAFE_API_KEY。
85
+ */
86
+ apiKeyRef: z.string().default('').volatile(),
87
+ /**
88
+ * Unisound API Key(明文;不想落明文请改用 apiKeyRef 或环境变量)。
89
+ *
90
+ * 解析顺序:apiKey → 环境变量 → 凭证引用(apiKeyRef 或内置默认名)。
91
+ */
64
92
  apiKey: z.string().default('').role('secret').volatile(),
65
93
  /** Unisound API 基础地址(默认 https://maas-api.unisound.com/v1) */
66
94
  baseUrl: z.string().default('https://maas-api.unisound.com/v1').volatile(),
@@ -72,6 +100,12 @@ const Config = z.object({
72
100
  timeoutMs: z.number().min(1000).default(30000).volatile(),
73
101
  /** 置信度阈值:任一答案低于该值则建议人工复核 */
74
102
  minConfidence: z.number().min(0).max(1).default(0.6).volatile(),
103
+ /**
104
+ * 发送前对 state 做内置脱敏:手机号/身份证/邮箱/银行卡号替换为
105
+ * `[手机号]` 等类型标签(判断语义保留)。state 会发给上游决策服务,
106
+ * 处理含隐私数据的场景(尤其 data_governance)时建议开启。
107
+ */
108
+ redact: z.boolean().default(false).volatile(),
75
109
 
76
110
  /* ── 结构性字段:不在配置页里,改动需要重启 ───────────────────────────────── */
77
111
 
@@ -81,8 +115,27 @@ const Config = z.object({
81
115
  /* ── 自动决策运行参数(volatile):钩子已挂载时实时生效 ────────────────────── */
82
116
  /** 自动决策固定使用的场景 id;留空则用一次 choice 问句自动路由 */
83
117
  autoScenario: z.string().default('').volatile(),
84
- /** 注入通道:message=本步生效(pre-step 注入);context=官方动态上下文(晚一步生效) */
85
- autoInject: z.string().default('message').volatile(),
118
+ /**
119
+ * 注入通道:message=本步生效(pre-step 注入);context=官方动态上下文(晚一步生效)。
120
+ *
121
+ * 必须是枚举而不是裸 string:auto.js 的三处门控都是精确比较
122
+ * (`!== 'context'` / `=== 'context'` / `!== 'message'`),写错大小写
123
+ * (如 `Context`)会静默掉进 message 分支、产生意料之外的历史写入。
124
+ * 枚举让非法值在加载期就报错,而不是运行时无提示地走错通道。
125
+ */
126
+ autoInject: z.union(['message', 'context']).default('message').volatile(),
127
+ /**
128
+ * 自动决策失败语义:open=放行(注入用途的正确默认);closed=否决本轮
129
+ * (pre-step 返回 `{ kind: 'reject' }`,宿主把本轮 turn 记为 blocked)。
130
+ *
131
+ * 必须是枚举而不是裸 string,理由同 autoInject:auto.js 的分支是精确比较,
132
+ * 写错值会静默落回 open。closed 只对"本应得到决策却没有"的真失败生效
133
+ * (请求异常、超时、场景执行失败、路由无可用品);"主动不注入"
134
+ * (寒暄、低置信度、斜杠命令)永远放行。仅 message 通道会拦截——
135
+ * context 通道只注入参考信息,不 gate 任何东西。本档是为将来做闸门
136
+ * (形态 C)预留的语义缝,当前注入用途请保持 open。
137
+ */
138
+ autoFailMode: z.union(['open', 'closed']).default('open').volatile(),
86
139
  /** 自动决策超时(毫秒),超时即放行,绝不阻塞会话 */
87
140
  autoTimeoutMs: z.number().min(500).default(AUTO_DEFAULTS.autoTimeoutMs).volatile(),
88
141
  /** 捕获的历史消息条数(不含本轮新消息) */
@@ -95,6 +148,11 @@ const Config = z.object({
95
148
  autoRouteMinConfidence: z.number().min(0).max(1).default(AUTO_DEFAULTS.autoRouteMinConfidence).volatile(),
96
149
  /** 触发自动决策的最小输入长度(字符);低于该长度且整体是寒暄才跳过 */
97
150
  autoMinChars: z.number().min(0).default(AUTO_DEFAULTS.autoMinChars).volatile(),
151
+
152
+ /* ── 用量台账(volatile)────────────────────────────────────────────────── */
153
+
154
+ /** 用量台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl` */
155
+ usageLogPath: z.string().default('').volatile(),
98
156
  })
99
157
 
100
158
  export default class SystemOneService extends Service {
@@ -135,6 +193,15 @@ export default class SystemOneService extends Service {
135
193
  this._refreshScenarios()
136
194
  const scenarios = this.liveScenarios()
137
195
 
196
+ // 外发声明:装载时打印一行,之后 provider / redact 变化时再打印一次。
197
+ // 见 _announceEgress 的说明——state 会原样发往上游,这件事必须是可审计的。
198
+ this._egressStamp = null
199
+ this._announceEgress()
200
+
201
+ // 用量台账:工具路径与自动路径都记账(含路由请求),让花销可见。
202
+ // 台账写盘失败会降级为仅内存统计,绝不影响决策。
203
+ this.usageLedger = createUsageLedger({ path: readString(config, 'usageLogPath', ''), logger: ctx.logger })
204
+
138
205
  // inject 保证 ctx.tools 存在;注册失败必须显式抛出,绝不静默降级
139
206
  const tools = ctx.get('tools') ?? ctx.tools
140
207
  if (!tools || typeof tools.register !== 'function') {
@@ -144,6 +211,7 @@ export default class SystemOneService extends Service {
144
211
  // 惰性读取:场景库可能已随 customScenarios 热更新重建,
145
212
  // 这里必须每次取最新的 warnings,而不是装配期快照。
146
213
  warnings: () => this.scenarioWarnings || [],
214
+ usage: this.usageLedger,
147
215
  }).map((tool) => tools.register(tool))
148
216
 
149
217
  // 自动决策:默认关闭;开启后在每一步推理前自动捕获上下文并注入判断
@@ -155,6 +223,7 @@ export default class SystemOneService extends Service {
155
223
  config,
156
224
  scenarios,
157
225
  logger: ctx.logger,
226
+ usage: this.usageLedger,
158
227
  })
159
228
  } catch (error) {
160
229
  ctx.logger?.warn?.(`@master0071/dsh-systemone: 自动决策安装失败,工具仍可用:${error?.message || error}`)
@@ -168,6 +237,66 @@ export default class SystemOneService extends Service {
168
237
  )
169
238
  }
170
239
 
240
+ /**
241
+ * 外发声明:把「这次会不会联网、往哪发、发什么」打成一行可审计的日志。
242
+ *
243
+ * 为什么需要它:`state`(含对话历史与用户原文)会原样发往上游决策服务,
244
+ * 而 `redact` 默认是 **false**。生态里同类插件被审计出的最常见问题正是
245
+ * 「名为 guard/gate 的模块同时在往外发数据,而 README 不说」。一行启动声明
246
+ * 让这件事在日志里可见,而不是只藏在配置文档里。
247
+ *
248
+ * `provider` 与 `redact` 都是热更新字段,所以本方法在**值变化时**会再打一次;
249
+ * 相同状态不重复输出,避免每步刷日志。
250
+ */
251
+ _announceEgress() {
252
+ try {
253
+ const kind = readString(this.config, 'provider', 'unisound')
254
+ const redact = readBoolean(this.config, 'redact', false)
255
+ const stamp = `${kind}|${redact}`
256
+ if (stamp === this._egressStamp) return
257
+ this._egressStamp = stamp
258
+
259
+ const logger = this.ctx?.logger
260
+ if (kind === 'mock') {
261
+ logger?.info?.('@master0071/dsh-systemone[egress]: OFF — provider=mock,不会发起任何网络请求')
262
+ return
263
+ }
264
+
265
+ const budget = this._stateBudget()
266
+ // 尽量报告真实端点;解析失败(配错 URL 等)时退回配置原文,不让声明本身抛错
267
+ let target = ''
268
+ try {
269
+ target = this.currentProvider().resolveEndpoint()
270
+ } catch {
271
+ target = readString(this.config, 'endpoint', '') || readString(this.config, 'baseUrl', '')
272
+ }
273
+ const where = target || '(端点未配置)'
274
+
275
+ const redactLine = redact
276
+ ? `ON — 发送前把 ${redactRuleSummary()} 替换为 [类型] 标签(尽力而为:不在规则内的敏感串仍会外发)`
277
+ : `OFF — ${redactRuleSummary()} 等敏感内容**原样外发**,建议在处理含隐私数据的内容时开启 redact`
278
+
279
+ logger?.info?.(`@master0071/dsh-systemone[egress]: ON → ${where}`)
280
+ logger?.info?.(
281
+ `@master0071/dsh-systemone[egress]: SENDS state(<=${budget} 字符,含对话历史与用户原文) + questions`
282
+ + ` + model;redact=${redactLine}`,
283
+ )
284
+ } catch {
285
+ // 声明失败绝不能影响装配
286
+ }
287
+ }
288
+
289
+ /**
290
+ * state 的字符预算:工具路径截断到 MAX_TOOL_STATE_CHARS,自动决策路径拼装后可能更大。
291
+ * 声明里报一个保守上界,让「发出去多大」有个量级概念。
292
+ * 数字全部同源引用(tools.js / auto.js 的截断常量),改一处声明自动跟随。
293
+ */
294
+ _stateBudget() {
295
+ if (!readBoolean(this.config, 'autoDecide', false)) return MAX_TOOL_STATE_CHARS
296
+ const messages = readNumber(this.config, 'autoMaxMessages', 6)
297
+ return Math.max(MAX_TOOL_STATE_CHARS, MAX_CURRENT_CHARS + messages * HISTORY_CHARS_PER_MESSAGE)
298
+ }
299
+
171
300
  /**
172
301
  * 返回当前应当使用的提供商;`provider` 配置变化时按需重建。
173
302
  *
@@ -181,6 +310,10 @@ export default class SystemOneService extends Service {
181
310
  this._provider = createProvider(this.config)
182
311
  this._providerKind = kind
183
312
  }
313
+ // 所有请求路径(工具/自动决策/服务 decide)取提供商都经过这里:
314
+ // provider **或 redact** 任一变化(stamp 去重)即重发外发声明。
315
+ // 放在方法末尾,保证声明解析端点用的是(可能刚重建的)新提供商。
316
+ this._announceEgress()
184
317
  return this._provider
185
318
  }
186
319
 
package/lib/provider.js CHANGED
@@ -15,10 +15,15 @@
15
15
  * - "mock" :本地确定性模拟(无需 API Key,用于测试与演示)
16
16
  */
17
17
 
18
- import { readNumber, readString } from './volatile.js'
18
+ import { readFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import { readBoolean, readNumber, readString } from './volatile.js'
19
21
 
20
22
  const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
21
23
  const DEFAULT_MODEL = 'u2-decision'
24
+ /** 临时故障(5xx/429/网络错误)自动重试次数与退避间隔;决策请求是幂等读,重发安全。 */
25
+ const RETRY_LIMIT = 1
26
+ const RETRY_DELAY_MS = 300
22
27
 
23
28
  /** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。未知 provider 值回退到 unisound(入口层负责告警)。 */
24
29
  export function createProvider(config) {
@@ -49,21 +54,31 @@ export class HttpProvider {
49
54
  if (!ep) {
50
55
  throw new Error('SystemOne: provider=http 需要配置 endpoint 或 baseUrl')
51
56
  }
52
- return ep
57
+ return assertValidUrl(ep)
53
58
  }
54
59
  const base = String(baseUrl || DEFAULT_BASE_URL).trim().replace(/\/+$/, '')
55
- return `${base}/systemone`
60
+ return assertValidUrl(`${base}/systemone`)
56
61
  }
57
62
 
58
- /** 解析 API Key:配置优先,其次环境变量。 */
63
+ /**
64
+ * 解析 API Key,顺序为:插件配置明文 → 环境变量 → DSH 凭证缝引用。
65
+ *
66
+ * 推荐用最后一种(`apiKeyRef`):配置里只留一个引用名,密钥本体留在
67
+ * `$DSH_HOME/.credentials.yaml`,避免明文落进 profile 的 `cordis.patch.yml`。
68
+ * 三种来源都没有时给出可操作的报错。
69
+ */
59
70
  resolveApiKey() {
60
71
  const key = readString(this.config, 'apiKey', '')
61
72
  || process.env.UNISOUND_API_KEY
62
73
  || process.env.SYSTEMONE_API_KEY
74
+ || readCredentialRef(readString(this.config, 'apiKeyRef', ''))
63
75
  || ''
64
76
  if (!key) {
65
77
  throw new Error(
66
- 'SystemOne: 未配置 apiKey(请在插件配置中填写,或设置环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY)',
78
+ 'SystemOne: 未配置凭证。按序尝试了:插件配置的 apiKey → 环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY '
79
+ + `→ 凭证缝 ${credentialsPath()} 的 refs.<apiKeyRef>`
80
+ + `${readString(this.config, 'apiKeyRef', '') ? '' : '(apiKeyRef 留空,回退默认名 ' + DEFAULT_KEY_REFS.join(' / ') + ')'}。`
81
+ + '推荐在凭证文件里写 `refs: { SYSTEMONE_API_KEY: <你的 key> }` 并保持 apiKey 为空。',
67
82
  )
68
83
  }
69
84
  return key
@@ -78,6 +93,11 @@ export class HttpProvider {
78
93
  const endpoint = this.resolveEndpoint()
79
94
  const apiKey = this.resolveApiKey()
80
95
  const timeoutMs = readNumber(this.config, 'timeoutMs', 30000) || 30000
96
+ const body = JSON.stringify({
97
+ model: model || readString(this.config, 'model', DEFAULT_MODEL) || DEFAULT_MODEL,
98
+ state: redactValue(state, readBoolean(this.config, 'redact', false)),
99
+ questions,
100
+ })
81
101
 
82
102
  const controller = new AbortController()
83
103
  const timer = setTimeout(() => controller.abort(new Error(`SystemOne: 请求超时(${timeoutMs}ms)`)), timeoutMs)
@@ -91,35 +111,49 @@ export class HttpProvider {
91
111
  }
92
112
  }
93
113
 
94
- try {
114
+ /** 单次请求:非 2xx 抛错并带上 status,供重试判定。 */
115
+ const sendOnce = async () => {
95
116
  const res = await fetch(endpoint, {
96
117
  method: 'POST',
97
118
  headers: {
98
119
  Authorization: `Bearer ${apiKey}`,
99
120
  'Content-Type': 'application/json',
100
121
  },
101
- body: JSON.stringify({
102
- model: model || readString(this.config, 'model', DEFAULT_MODEL) || DEFAULT_MODEL,
103
- state,
104
- questions,
105
- }),
122
+ body,
106
123
  signal: controller.signal,
107
124
  })
108
-
109
125
  const text = await res.text().catch(() => '')
110
126
  if (!res.ok) {
111
- throw new Error(
127
+ const error = new Error(
112
128
  `SystemOne: HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''} — ${text.slice(0, 500) || '无响应体'}`,
113
129
  )
130
+ error.status = res.status
131
+ throw error
114
132
  }
115
-
116
- let data
117
133
  try {
118
- data = JSON.parse(text)
134
+ return JSON.parse(text)
119
135
  } catch {
120
136
  throw new Error(`SystemOne: 响应不是合法 JSON — ${text.slice(0, 300) || '空响应'}`)
121
137
  }
122
- return data
138
+ }
139
+
140
+ try {
141
+ let lastError
142
+ for (let attempt = 0; attempt <= RETRY_LIMIT; attempt += 1) {
143
+ if (attempt > 0) {
144
+ // 已中止(超时/外部取消)不再重试,让上一次的原始错误穿透
145
+ if (controller.signal.aborted) throw lastError
146
+ await sleep(RETRY_DELAY_MS)
147
+ }
148
+ try {
149
+ return await sendOnce()
150
+ } catch (error) {
151
+ lastError = error
152
+ if (attempt < RETRY_LIMIT && !controller.signal.aborted && isRetryable(error)) continue
153
+ throw error
154
+ }
155
+ }
156
+ throw lastError
123
157
  } finally {
124
158
  clearTimeout(timer)
125
159
  if (signal && typeof signal.removeEventListener === 'function') {
@@ -195,11 +229,12 @@ function mockAnswer(id, q, seedSource) {
195
229
  confidence: probabilities[top],
196
230
  }
197
231
  }
198
- case 'noul':
199
- return {
200
- type: 'noul',
201
- noul: Math.round((rand() * 0.6 + 0.2) * 1000) / 1000,
202
- }
232
+ case 'noul': {
233
+ // 与真实响应对齐:noul 也带 confidence(取倾向一侧的概率)。
234
+ // mock 的分布是合成的,**不代表真实模型行为**,只能用于契约与确定性回归。
235
+ const v = Math.round((rand() * 0.6 + 0.2) * 1000) / 1000
236
+ return { type: 'noul', noul: v, confidence: Math.max(v, 1 - v) }
237
+ }
203
238
  case 'score': {
204
239
  const list = Array.isArray(q.criteria) ? q.criteria : []
205
240
  const len = Math.max(list.length, 1)
@@ -247,4 +282,154 @@ function mulberry32(seed) {
247
282
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
248
283
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
249
284
  }
285
+ }
286
+
287
+ /* ─── 敏感数据脱敏(redact=true 时,发送前对 state 生效) ──────────────────── */
288
+
289
+ /**
290
+ * 内置脱敏规则:手机号/身份证/邮箱/银行卡号替换为类型标签。
291
+ * 替换成标签(而不是抹成 ***)是有意的:决策模型仍能看出「这段内容
292
+ * 包含手机号」——对 data_governance 这类场景,这个信号正是判断依据。
293
+ * 顺序有意义:身份证(18 位)先于银行卡(13~19 位)匹配,
294
+ * 环视断言保证不会在更长的数字串内部截取匹配。
295
+ */
296
+ const REDACT_RULES = [
297
+ { pattern: /(?<![\dXx])\d{17}[\dXx](?![\dXx])/g, tag: '[身份证]' },
298
+ { pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, tag: '[邮箱]' },
299
+ { pattern: /(?<!\d)1[3-9]\d{9}(?!\d)/g, tag: '[手机号]' },
300
+ { pattern: /(?<!\d)\d{13,19}(?!\d)/g, tag: '[银行卡号]' },
301
+ ]
302
+
303
+ function redactString(text) {
304
+ return REDACT_RULES.reduce((acc, rule) => acc.replace(rule.pattern, rule.tag), text)
305
+ }
306
+
307
+ /**
308
+ * 递归脱敏 state:字符串叶子做规则替换,对象/数组逐层下钻。
309
+ * 不修改原对象(调用方可能还要用),返回脱敏后的副本;非字符串叶子原样保留。
310
+ */
311
+ function redactValue(value, enabled) {
312
+ if (!enabled) return value
313
+ if (typeof value === 'string') return redactString(value)
314
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, enabled))
315
+ if (value !== null && typeof value === 'object') {
316
+ const out = {}
317
+ for (const [key, item] of Object.entries(value)) {
318
+ out[key] = redactValue(item, enabled)
319
+ }
320
+ return out
321
+ }
322
+ return value
323
+ }
324
+
325
+ /* ─── DSH 凭证缝读取 ───────────────────────────────────────────────────────── */
326
+
327
+ /** DSH 主目录:与用量台账同源(usage.js 导出)。 */
328
+ import { dshHome } from './usage.js'
329
+
330
+ /** 未配置 apiKeyRef 时依次尝试的内置引用名(覆盖 SystemOne 各家后端)。 */
331
+ const DEFAULT_KEY_REFS = ['SYSTEMONE_API_KEY', 'UNISOUND_API_KEY', 'TYPESAFE_API_KEY']
332
+
333
+ /** 凭证文件路径(用于报错文案与读取)。 */
334
+ function credentialsPath() {
335
+ return join(dshHome(), '.credentials.yaml')
336
+ }
337
+
338
+ /**
339
+ * 从凭证文件里取 `refs.<name>` 的值。
340
+ *
341
+ * 只解析 `refs:` 下的**单层标量**,不引入 YAML 依赖(插件对 `@deepseek-ai/*` 之外的
342
+ * 依赖保持为零)。解析要点:
343
+ * - 只认 `refs:` 之后、缩进更深的行,避免误取段外同名键;
344
+ * - 值两端的成对引号会被剥掉(`TYPESAFE_API_KEY: "sk-x"` 应得到 `sk-x`)——
345
+ * 这是同类实现常见的一个缺陷:直接把捕获组当值,会连引号一起塞进 Authorization 头;
346
+ * - 行内 ` #` 注释会被裁掉(仅对未加引号的值);
347
+ * - 多个候选名都在文件里时,**按 `wanted` 的顺序**返回,而不是按文件出现顺序。
348
+ *
349
+ * @param {string} name - 引用名;留空时依次尝试 DEFAULT_KEY_REFS
350
+ * @returns {string|undefined} 找到的凭证;找不到返回 undefined
351
+ */
352
+ function readCredentialRef(name) {
353
+ const wanted = name && String(name).trim() ? [String(name).trim()] : DEFAULT_KEY_REFS
354
+ let raw
355
+ try {
356
+ raw = readFileSync(credentialsPath(), 'utf8')
357
+ } catch {
358
+ return undefined // 文件不存在/不可读 → 视为未配置
359
+ }
360
+ const refs = new Map()
361
+ let inRefs = false
362
+ let refsIndent = -1
363
+ for (const line of raw.split(/\r?\n/)) {
364
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
365
+ const indent = line.length - line.trimStart().length
366
+ if (!inRefs) {
367
+ if (/^refs\s*:\s*$/.test(line.trim())) {
368
+ inRefs = true
369
+ refsIndent = indent
370
+ }
371
+ continue
372
+ }
373
+ // 缩进不深于 refs: 即说明已离开该段
374
+ if (indent <= refsIndent) break
375
+ const match = /^\s*([^\s:#][^:]*?)\s*:\s*(.*)$/.exec(line)
376
+ if (!match) continue
377
+ const value = unquoteScalar(match[2]) ?? ''
378
+ if (value) refs.set(match[1].trim(), value)
379
+ }
380
+ // 按候选名给定的优先级取值(不是文件顺序)
381
+ for (const key of wanted) {
382
+ const hit = refs.get(key)
383
+ if (hit) return hit
384
+ }
385
+ return undefined
386
+ }
387
+
388
+ /** 剥掉标量两端成对的引号并裁掉未加引号值后的行内注释。 */
389
+ function unquoteScalar(raw) {
390
+ const trimmed = String(raw ?? '').trim()
391
+ if (trimmed === '') return ''
392
+ const first = trimmed[0]
393
+ if (first === '"' || first === "'") {
394
+ // 引号值:取到配对的收尾引号为止,引号内的 # 不算注释
395
+ const end = trimmed.indexOf(first, 1)
396
+ return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
397
+ }
398
+ const hash = trimmed.indexOf(' #')
399
+ return hash === -1 ? trimmed : trimmed.slice(0, hash).trim()
400
+ }
401
+
402
+ /* ─── 脱敏规则清单(供外发声明复用,避免文案与实现漂移)────────────────────── */
403
+
404
+ /** 规则的中文类型标签,按 REDACT_RULES 的顺序。 */
405
+ const REDACT_TAGS = REDACT_RULES.map((rule) => rule.tag.replace(/^\[|\]$/g, ''))
406
+
407
+ /** 脱敏规则的中文清单,形如 `手机号/身份证/邮箱/银行卡号`。 */
408
+ export function redactRuleSummary() {
409
+ return REDACT_TAGS.join('/')
410
+ }
411
+
412
+ /* ─── 通用内部工具 ─────────────────────────────────────────────────────────── */
413
+
414
+ /** URL 合法性在发请求前拦下:漏写协议时 fetch 的报错非常隐晦,不如直接说清楚。 */
415
+ function assertValidUrl(url) {
416
+ try {
417
+ new URL(url)
418
+ } catch {
419
+ throw new Error(`SystemOne: 端点不是合法 URL:${url}(需以 http:// 或 https:// 开头)`)
420
+ }
421
+ return url
422
+ }
423
+
424
+ /** 仅临时故障值得重试:5xx、429 限流、网络层错误(fetch 失败抛 TypeError)。 */
425
+ function isRetryable(error) {
426
+ if (error instanceof TypeError) return true
427
+ return error?.status === 429 || (typeof error?.status === 'number' && error.status >= 500)
428
+ }
429
+
430
+ function sleep(ms) {
431
+ return new Promise((resolve) => {
432
+ const timer = setTimeout(resolve, ms)
433
+ timer.unref?.()
434
+ })
250
435
  }