@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/scenarios.js CHANGED
@@ -7,10 +7,10 @@
7
7
  * 场景定义格式:
8
8
  * {
9
9
  * id: string, // 唯一标识(必填)
10
- * title: string, // 展示名(必填)
10
+ * title: string, // 展示名(必填;patch 条目可省略,继承目标场景)
11
11
  * description?: string, // 场景说明
12
12
  * aliases?: string[], // 别名,便于模型用中文名/英文名查找
13
- * questions: { // 问题映射(必填,至少 1 个)
13
+ * questions: { // 问题映射(必填,至少 1 个;patch 条目可省略)
14
14
  * [qid]: {
15
15
  * type: 'choice' | 'noul' | 'score',
16
16
  * instructions: string, // 给模型的问题描述
@@ -22,6 +22,7 @@
22
22
  * [name]: { question: string, values?: string[], map?: object }
23
23
  * },
24
24
  * recommendation?: string, // 可选建议模板
25
+ * patch?: boolean, // true=与同 id 已有场景做字段级合并(只覆盖给出的字段),默认整体替换
25
26
  * }
26
27
  *
27
28
  * 建议模板语法:
@@ -32,7 +33,18 @@
32
33
  * {qid?文案A|文案B} → 条件文案(noul 为真 / 分值≥1 / choice 非空非 none 时取 A)
33
34
  */
34
35
 
35
- /** 内置场景:10 个业务域,每个 3 个结构化问题。 */
36
+ /**
37
+ * choice 题型的选项数上限。
38
+ *
39
+ * 单一真源:场景校验(validateScenario)与自动路由的分批逻辑都读这里。
40
+ * 上游决策模型对 choice 的选项数有硬限制,超过会被拒——因此不仅是"建议"。
41
+ */
42
+ export const MAX_CHOICE_OPTIONS = 26
43
+
44
+ /** choice 题型的选项数下限。 */
45
+ export const MIN_CHOICE_OPTIONS = 2
46
+
47
+ /** 内置场景:11 个业务域,每个 3 个结构化问题。 */
36
48
  export const BUILTIN_SCENARIOS = [
37
49
  {
38
50
  id: 'customer_service',
@@ -384,6 +396,44 @@ export const BUILTIN_SCENARIOS = [
384
396
  derive: { effort: { question: 'complexity', values: ['S', 'M', 'L', 'XL'] } },
385
397
  recommendation: '任务类型「{task_type}」,改动规模 {effort}。{needs_context?建议先检索代码库并确认相关实现|可直接动手}',
386
398
  },
399
+ {
400
+ id: 'requirement_clarity',
401
+ title: '需求预检 · 明确度与澄清点',
402
+ description: '接到开发需求先做结构化预检:判断明确程度、最缺什么信息、不澄清就推进的返工风险。',
403
+ // 别名刻意避开 requirements 已占用的 requirement / 需求 / 变更管理
404
+ aliases: ['clarity', '预检', '需求预检'],
405
+ questions: {
406
+ clarity: {
407
+ type: 'score',
408
+ label: '明确度',
409
+ instructions: '该开发任务的需求明确程度是?',
410
+ criteria: [
411
+ '模糊:目标都不清楚',
412
+ '偏低:目标清楚但范围或关键约束不明',
413
+ '较高:目标范围清楚,细节可自行决策',
414
+ '明确:目标、范围、验收都清楚',
415
+ ],
416
+ },
417
+ missing: {
418
+ type: 'choice',
419
+ label: '缺失项',
420
+ instructions: '当前最需要澄清的信息是?',
421
+ criteria: {
422
+ goal: '目标/预期结果',
423
+ scope: '范围与边界',
424
+ acceptance: '验收标准',
425
+ constraint: '技术约束或依赖',
426
+ none: '无明显缺失',
427
+ },
428
+ },
429
+ proceed: {
430
+ type: 'noul',
431
+ label: '可直接推进',
432
+ instructions: '不做额外澄清就推进,返工风险是否可接受?',
433
+ },
434
+ },
435
+ recommendation: '明确度 {clarity},最缺 {missing}。{proceed?可直接推进|建议先澄清目标再动手}',
436
+ },
387
437
  ]
388
438
 
389
439
  const VALID_TYPES = new Set(['choice', 'noul', 'score'])
@@ -426,10 +476,10 @@ export function validateScenario(spec) {
426
476
  if (q.type === 'choice') {
427
477
  if (!q.criteria || typeof q.criteria !== 'object' || Array.isArray(q.criteria)) {
428
478
  problems.push(`问题 ${qid}(choice)的 criteria 必须是"选项 key → 说明"的对象`)
429
- } else if (Object.keys(q.criteria).length < 2) {
430
- problems.push(`问题 ${qid}(choice)至少需要 2 个选项`)
431
- } else if (Object.keys(q.criteria).length > 26) {
432
- problems.push(`问题 ${qid}(choice)选项建议不超过 26 个`)
479
+ } else if (Object.keys(q.criteria).length < MIN_CHOICE_OPTIONS) {
480
+ problems.push(`问题 ${qid}(choice)至少需要 ${MIN_CHOICE_OPTIONS} 个选项`)
481
+ } else if (Object.keys(q.criteria).length > MAX_CHOICE_OPTIONS) {
482
+ problems.push(`问题 ${qid}(choice)选项最多 ${MAX_CHOICE_OPTIONS} 个(当前 ${Object.keys(q.criteria).length} 个)`)
433
483
  }
434
484
  }
435
485
  if (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
@@ -488,12 +538,24 @@ export function resolveScenarios(customJson) {
488
538
  })
489
539
 
490
540
  parsed.forEach((spec, i) => {
491
- const issues = validateScenario(spec)
541
+ let candidate = spec
542
+ // patch 条目:先与同 id 已有场景做字段级合并,再走与整体替换完全相同的
543
+ // 校验/规范化/别名继承路径——patch 与替换的唯一区别就在这一步合并。
544
+ if (spec && typeof spec === 'object' && !Array.isArray(spec) && spec.patch === true) {
545
+ const id = typeof spec.id === 'string' ? spec.id.trim() : ''
546
+ const key = id.toLowerCase()
547
+ if (!id || !indexById.has(key)) {
548
+ problems.push(`自定义场景 #${i + 1} 已跳过:patch 目标 "${id || '(未提供)'}" 不在场景库中(patch 只能修改已有场景)`)
549
+ return
550
+ }
551
+ candidate = mergeScenario(scenarios[indexById.get(key)], spec)
552
+ }
553
+ const issues = validateScenario(candidate)
492
554
  if (issues.length > 0) {
493
555
  problems.push(`自定义场景 #${i + 1} 已跳过:${issues.join(';')}`)
494
556
  return
495
557
  }
496
- const entry = normalizeScenario(spec)
558
+ const entry = normalizeScenario(candidate)
497
559
  const key = entry.id.toLowerCase()
498
560
 
499
561
  // 别名撞名是一条提示:声明者获胜(后写优先),被遮蔽者从此无法通过该别名查到。
@@ -537,6 +599,47 @@ export function resolveScenarios(customJson) {
537
599
  return { scenarios, problems, warnings }
538
600
  }
539
601
 
602
+ /**
603
+ * 把 patch 条目与已有场景做字段级合并,产出「完整场景定义」形状的对象:
604
+ * - title/description/recommendation:patch 给了才覆盖,否则保留目标的值;
605
+ * - questions:按 qid 合并——patch 里给出的字段(instructions/label/criteria)
606
+ * 覆盖目标同名问题的对应字段,其余问题原样保留,新 qid 直接追加;
607
+ * - derive:同名派生覆盖,其余保留;
608
+ * - aliases:目标与 patch 求并集(与整体替换的继承规则一致)。
609
+ *
610
+ * 返回值随后会重新过 validateScenario:patch 把合法问题改非法(如 criteria
611
+ * 少于 2 个选项)在校验处被拦下,而不是静默产出坏场景。
612
+ */
613
+ function mergeScenario(target, spec) {
614
+ // Object.create(null):patch 里的 __proto__ 这类键只会长成普通属性,
615
+ // 由 validateScenario 的 isUnsafeKey 统一拦下,不会走到原型 setter
616
+ const questions = Object.create(null)
617
+ for (const [qid, question] of Object.entries(target.questions || {})) {
618
+ questions[qid] = question
619
+ }
620
+ for (const [qid, patch] of Object.entries(spec.questions || {})) {
621
+ questions[qid] = patch && typeof patch === 'object'
622
+ ? { ...(questions[qid] || {}), ...patch }
623
+ : patch
624
+ }
625
+ return {
626
+ id: target.id,
627
+ title: typeof spec.title === 'string' && spec.title.trim() ? spec.title : target.title,
628
+ description: typeof spec.description === 'string' ? spec.description : target.description,
629
+ aliases: [
630
+ ...(target.aliases || []),
631
+ ...(Array.isArray(spec.aliases) ? spec.aliases : []),
632
+ ],
633
+ questions,
634
+ derive: {
635
+ ...(target.derive || {}),
636
+ ...(spec.derive && typeof spec.derive === 'object' && !Array.isArray(spec.derive) ? spec.derive : {}),
637
+ },
638
+ recommendation: typeof spec.recommendation === 'string' ? spec.recommendation : target.recommendation,
639
+ minConfidence: typeof spec.minConfidence === 'number' ? spec.minConfidence : target.minConfidence,
640
+ }
641
+ }
642
+
540
643
  /**
541
644
  * 规范化场景定义,补齐可选字段。
542
645
  *
package/lib/tools.js CHANGED
@@ -5,21 +5,52 @@
5
5
  * 1. systemone_scenario — action: list / describe / run,覆盖全部内置与自定义场景
6
6
  * 2. systemone_decide — 直接提交自定义 questions 的低层入口
7
7
  *
8
- * 场景全部来自 scenarios.js 的场景库(内置 10 个 + 配置追加),新增场景无需改代码。
8
+ * 场景全部来自 scenarios.js 的场景库(内置 11 个 + 配置追加),新增场景无需改代码。
9
9
  */
10
10
  import {
11
- computeNeedsHumanReview,
12
11
  deriveFields,
13
12
  formatAnswerLines,
14
13
  formatSummary,
15
14
  normalizeAnswers,
16
15
  renderTemplate,
16
+ reviewAssessment,
17
17
  } from './format.js'
18
18
  import { findScenario } from './scenarios.js'
19
+ import { recordUsage } from './usage.js'
19
20
  import { readNumber, readString } from './volatile.js'
20
21
 
21
22
  const DEFAULT_MIN_CONFIDENCE = 0.6
22
23
 
24
+ /**
25
+ * 工具入参 state 的硬上限。自动决策路径有自己的截断(见 auto.js 的
26
+ * MAX_CURRENT_CHARS / 每条历史 2000 字),这里只防工具路径被塞进超大输入
27
+ * 直接打到上游 API;超长内容对决策质量也没有增量。
28
+ */
29
+ export const MAX_TOOL_STATE_CHARS = 16000
30
+
31
+ /**
32
+ * 给 state 套硬上限:超长字符串截断;对象/数组按序列化长度衡量,
33
+ * 超限降级为截断后的 JSON 文本(决策模型反正是按文本理解的)。
34
+ * undefined/null/空字符串原样返回,让「缺少 state」的校验路径保持不变。
35
+ */
36
+ function capState(state) {
37
+ if (state === undefined || state === null) return state
38
+ if (typeof state === 'string') {
39
+ return state.length <= MAX_TOOL_STATE_CHARS
40
+ ? state
41
+ : `${state.slice(0, MAX_TOOL_STATE_CHARS)}…(已截断,原 ${state.length} 字符)`
42
+ }
43
+ let serialized
44
+ try {
45
+ serialized = JSON.stringify(state)
46
+ } catch {
47
+ // 循环引用等无法序列化的结构:原样交给下游,由序列化失败的报错路径兜底
48
+ return state
49
+ }
50
+ if (serialized.length <= MAX_TOOL_STATE_CHARS) return state
51
+ return `${serialized.slice(0, MAX_TOOL_STATE_CHARS)}…(已截断,原 ${serialized.length} 字符)`
52
+ }
53
+
23
54
  /**
24
55
  * 创建全部工具定义。
25
56
  *
@@ -29,11 +60,12 @@ const DEFAULT_MIN_CONFIDENCE = 0.6
29
60
  * @param {object} [hooks] - 可选运行时钩子
30
61
  * @param {() => string[]} [hooks.warnings] - 读取当前非致命提示(如别名遮蔽),
31
62
  * 用于让 `action=list` 把「哪个别名被抢走了」告诉模型,而不只是写进宿主日志。
63
+ * @param {object} [hooks.usage] - 用量台账(createUsageLedger 的返回值)。有则记录每次上游调用。
32
64
  */
33
65
  export function createTools(provider, config, scenarios, hooks = {}) {
34
66
  return [
35
67
  scenarioTool(provider, config, scenarios, hooks),
36
- decideTool(provider, config),
68
+ decideTool(provider, config, hooks),
37
69
  ]
38
70
  }
39
71
 
@@ -45,7 +77,7 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
45
77
  description: [
46
78
  'SystemOne 场景决策:把业务状态与预置的结构化问题一次性提交给决策模型,返回带概率分布的判断与处置建议。',
47
79
  'action=list 列出全部场景;action=describe 查看某个场景的问题定义与可覆盖参数;action=run 执行决策。',
48
- '内置 10 大业务场景,可用 params 覆盖问题定义。',
80
+ '内置 11 大业务场景,可用 params 覆盖问题定义。',
49
81
  ].join(' '),
50
82
  parameters: {
51
83
  type: 'object',
@@ -83,7 +115,7 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
83
115
  const action = args.action || 'run'
84
116
 
85
117
  if (action === 'list') {
86
- return listScenarios(scenarios, args.keyword, readWarnings(hooks))
118
+ return listScenarios(scenarios, args.keyword, readWarnings(hooks), hooks?.usage)
87
119
  }
88
120
  if (action === 'describe') {
89
121
  return describeScenario(scenarios, args.scenario)
@@ -93,9 +125,10 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
93
125
  config,
94
126
  scenarios,
95
127
  scenario: args.scenario,
96
- state: args.state,
128
+ state: capState(args.state),
97
129
  params: args.params,
98
130
  exec,
131
+ usage: hooks?.usage,
99
132
  })
100
133
  },
101
134
  presentCall: (args) => ({
@@ -117,7 +150,7 @@ function readWarnings(hooks) {
117
150
  }
118
151
  }
119
152
 
120
- function listScenarios(scenarios, keyword, warnings = []) {
153
+ function listScenarios(scenarios, keyword, warnings = [], usage) {
121
154
  const needle = typeof keyword === 'string' ? keyword.trim().toLowerCase() : ''
122
155
  const items = scenarios
123
156
  .filter((s) => {
@@ -136,6 +169,19 @@ function listScenarios(scenarios, keyword, warnings = []) {
136
169
  questions: Object.entries(s.questions).map(([qid, q]) => `${qid}:${q.type}`),
137
170
  }))
138
171
 
172
+ // 用量台账:list 是调用方最容易顺手调用的入口,把今日花销挂在这里,
173
+ // 让「花了多少」不需要另开一个工具就能看到。
174
+ let usageLine = null
175
+ let usageSnapshot = null
176
+ try {
177
+ if (typeof usage?.today === 'function') {
178
+ usageSnapshot = usage.today()
179
+ usageLine = typeof usage.formatToday === 'function' ? usage.formatToday() : null
180
+ }
181
+ } catch {
182
+ // 台账读取失败不影响 list
183
+ }
184
+
139
185
  const summary = [
140
186
  `## SystemOne 场景库(${items.length} 个)`,
141
187
  ...items.map((s) => `- **${s.id}**:${s.title} — ${s.question_count} 个问题(${s.questions.join(',')})`),
@@ -144,6 +190,7 @@ function listScenarios(scenarios, keyword, warnings = []) {
144
190
  ...(warnings.length > 0
145
191
  ? ['', '### 配置提示', ...warnings.map((w) => `- ${w}`)]
146
192
  : []),
193
+ ...(usageLine ? ['', '### 用量', `- ${usageLine}`] : []),
147
194
  '',
148
195
  '用 action=describe 查看某场景的问题定义,用 action=run 执行决策。',
149
196
  ].join('\n')
@@ -154,6 +201,7 @@ function listScenarios(scenarios, keyword, warnings = []) {
154
201
  count: items.length,
155
202
  scenarios: items,
156
203
  ...(warnings.length > 0 ? { warnings } : {}),
204
+ ...(usageSnapshot ? { usage_today: usageSnapshot } : {}),
157
205
  summary,
158
206
  }
159
207
  }
@@ -207,6 +255,7 @@ function describeScenario(scenarios, id) {
207
255
  * @param {object} [options.params] - 按问题 id 覆盖问题定义
208
256
  * @param {object} [options.exec] - 工具执行上下文(取 signal)
209
257
  * @param {number} [options.minConfidence] - 覆盖置信度阈值
258
+ * @param {object} [options.usage] - 用量台账;有则记录本次调用
210
259
  * @returns {Promise<object>} 与工具输出同构的决策结果
211
260
  */
212
261
  export async function runScenarioDecision({
@@ -218,6 +267,7 @@ export async function runScenarioDecision({
218
267
  params,
219
268
  exec,
220
269
  minConfidence,
270
+ usage,
221
271
  }) {
222
272
  const scenario = findScenario(scenarios, scenarioRef)
223
273
  if (!scenario) {
@@ -256,6 +306,7 @@ export async function runScenarioDecision({
256
306
  config,
257
307
  minConfidence: threshold,
258
308
  exec,
309
+ usage,
259
310
  warnings: ignored.length > 0
260
311
  ? [`以下 params 覆盖无效,已忽略:${ignored.join(';')}`]
261
312
  : [],
@@ -325,7 +376,7 @@ function applyParams(questions, params) {
325
376
 
326
377
  /* ─── 2. 通用决策工具 ─────────────────────────────────────────────────────── */
327
378
 
328
- function decideTool(provider, config) {
379
+ function decideTool(provider, config, hooks = {}) {
329
380
  return {
330
381
  name: 'systemone_decide',
331
382
  description: [
@@ -367,13 +418,14 @@ function decideTool(provider, config) {
367
418
  return executeDecision({
368
419
  title: 'SystemOne 通用决策',
369
420
  scenarioId: null,
370
- state: args.state,
421
+ state: capState(args.state),
371
422
  questions: args.questions || {},
372
423
  provider,
373
424
  config,
374
425
  minConfidence,
375
426
  exec,
376
427
  model: args.model,
428
+ usage: hooks?.usage,
377
429
  finalize(answers) {
378
430
  const { decision, labels, confidences } = normalizeAnswers(answers, args.questions || {})
379
431
  return { decision, labels, derived: {}, confidences, recommendation: '' }
@@ -403,6 +455,7 @@ async function executeDecision({
403
455
  model,
404
456
  finalize,
405
457
  warnings = [],
458
+ usage,
406
459
  }) {
407
460
  try {
408
461
  const started = Date.now()
@@ -413,9 +466,69 @@ async function executeDecision({
413
466
  model: model || configuredModel,
414
467
  signal: exec?.signal,
415
468
  })
469
+ // 记账:成功与失败都记(上游可能已计费)。记账失败绝不影响决策。
470
+ recordUsage(usage, {
471
+ source: 'tool',
472
+ ok: true,
473
+ model: response.model || model || configuredModel,
474
+ provider: provider.name,
475
+ scenario: scenarioId || '',
476
+ usage: response.usage,
477
+ latencyMs: response.latency_ms,
478
+ })
416
479
  const answers = response.answers || {}
417
480
  const { decision, labels, derived, confidences, recommendation } = finalize(answers)
418
- const needsHumanReview = computeNeedsHumanReview(answers, minConfidence)
481
+
482
+ // ── 空决策守卫 ────────────────────────────────────────────────────────────
483
+ // 必须同时覆盖两种"沉默"形态:
484
+ // a) answers 为空 / 全是无法识别的 type → decision 里一个键都没有;
485
+ // b) 有 answers 但关键字段缺失(如 noul 答案没有 noul 数值)→
486
+ // normalizeAnswers 会把该问题写成 null("无法判断")。
487
+ // 注意 type 的取值是 `question.type || raw.type`:上游给个无法识别的 type、
488
+ // 而问题定义是合法 type 时,会走 (b) 这条路——所以只看 decision 是否为空
489
+ // 是不够的,必须看**有没有任何一个问题真正产出了判断**。
490
+ // 若此时仍报 ok:true,调用方(模型)会以为"判断已完成",实际拿到的是空判断;
491
+ // 对决策工具来说,这种静默空成功比显式失败更危险。
492
+ const asked = Object.keys(questions || {})
493
+ const usable = asked.filter((qid) => {
494
+ const value = decision[qid]
495
+ return value !== null && value !== undefined
496
+ })
497
+ if (usable.length === 0) {
498
+ const returned = Object.keys(answers)
499
+ const unusable = asked.filter((qid) => qid in decision)
500
+ let detail
501
+ if (asked.length === 0) {
502
+ detail = '本次没有提交任何问题'
503
+ } else if (returned.length === 0) {
504
+ detail = `提交了 ${asked.length} 个问题(${asked.join('、')});上游未返回任何 answers`
505
+ } else if (unusable.length > 0) {
506
+ detail = `提交了 ${asked.length} 个问题(${asked.join('、')});上游返回的 answers 里没有可用取值`
507
+ + `(无法判断:${unusable.join('、')})`
508
+ } else {
509
+ detail = `提交了 ${asked.length} 个问题(${asked.join('、')});`
510
+ + `上游仅返回了 ${returned.join('、')},与所问的问题对不上`
511
+ }
512
+ const error = `SystemOne 未产出任何可用的决策:${detail}`
513
+ return {
514
+ ok: false,
515
+ scenario: scenarioId,
516
+ provider: provider.name,
517
+ error,
518
+ answers,
519
+ decision,
520
+ labels,
521
+ derived: {},
522
+ confidences: {},
523
+ recommendation: '',
524
+ needs_human_review: true,
525
+ warnings,
526
+ summary: `## ${title}\n\n**未产出任何可用的决策**:${detail}\n\n无法给出置信度与概率分布。请核对问题定义(type 必须是 choice / noul / score;choice 的 criteria 需 2~26 个选项;score 的 criteria 需 ≥2 级标签)后重试。`,
527
+ }
528
+ }
529
+
530
+ const assessment = reviewAssessment(answers, minConfidence)
531
+ const needsHumanReview = assessment.needed
419
532
  const lines = formatAnswerLines(answers, questions)
420
533
  const latencyMs = Number.isFinite(Number(response.latency_ms))
421
534
  ? Number(response.latency_ms)
@@ -431,6 +544,7 @@ async function executeDecision({
431
544
  recommendation,
432
545
  notes: warnings,
433
546
  needsHumanReview,
547
+ reviewReasons: assessment.reasons,
434
548
  })
435
549
  return {
436
550
  ok: true,
@@ -445,12 +559,14 @@ async function executeDecision({
445
559
  derived,
446
560
  confidences,
447
561
  needs_human_review: needsHumanReview,
562
+ review_reasons: assessment.reasons,
448
563
  recommendation,
449
564
  warnings,
450
565
  summary,
451
566
  }
452
567
  } catch (err) {
453
568
  const msg = messageOf(err)
569
+ recordUsage(usage, { source: 'tool', ok: false, model, provider: provider?.name, scenario: scenarioId || '' })
454
570
  return {
455
571
  ok: false,
456
572
  scenario: scenarioId,
package/lib/usage.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * usage.js — 调用与 token 用量台账。
3
+ *
4
+ * 为什么需要它:SystemOne 调用是**按量计费**的,而本插件默认关闭不了的两条路径
5
+ * (自动路由 + 场景决策)会随会话推进持续发请求。上游返回的 `usage.input_tokens`
6
+ * 原本只是塞进工具返回值里,既不累计也不设上限——用户看不到花销,也就没有刹车。
7
+ *
8
+ * 设计取舍(对齐生态里同类做法,如 dsh-jev-plugin 的 ledger + 双日限):
9
+ * - 每次调用**追加**一行 JSONL,包括失败/超时的调用(失败也可能已计费);
10
+ * - 日限在**发请求之前**判定并拒绝,附上该改哪个配置项;
11
+ * - 台账不可写时降级为「仅内存」,绝不因为记账失败而让决策失败(fail-open 记账);
12
+ * - 计数按**本地日**切分,跨日自动归零,不需要定时任务。
13
+ *
14
+ * 模块无第三方依赖,使用 node:fs / node:os / node:path。
15
+ */
16
+ import { appendFileSync, mkdirSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ /** token 数缺失时的兜底(上游不保证返回 usage)。 */
21
+ const UNKNOWN_TOKENS = 0
22
+
23
+ /** 本地日键,形如 2026-10-05。 */
24
+ function localDayKey(now = Date.now()) {
25
+ const d = new Date(now)
26
+ const pad = (n) => String(n).padStart(2, '0')
27
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
28
+ }
29
+
30
+ /** 解析 DSH 主目录(尊重 DSH_HOME);provider.js 的凭证读取与此同源。 */
31
+ export function dshHome() {
32
+ const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
33
+ return fromEnv || join(homedir(), '.dsh')
34
+ }
35
+
36
+ /**
37
+ * 安全记账:台账是**旁路**设施,任何异常都不得影响决策或注入。
38
+ *
39
+ * 台账自身已经吞掉了写盘失败,但调用方可能传入别的实现(测试桩、将来的远端
40
+ * 上报),所以这里再兜一层——「记账失败绝不影响决策」是写在文档里的承诺,
41
+ * 不能只靠台账实现自觉。auto.js 与 tools.js 共用本函数。
42
+ */
43
+ export function recordUsage(usage, entry) {
44
+ try {
45
+ usage?.record?.(entry)
46
+ } catch {
47
+ // 静默忽略
48
+ }
49
+ }
50
+
51
+ /** 台账文件绝对路径。 */
52
+ export function usageLogPath(dir) {
53
+ const base = typeof dir === 'string' && dir.trim() ? dir.trim() : join(dshHome(), 'dsh-systemone')
54
+ return join(base, 'usage.jsonl')
55
+ }
56
+
57
+ /** 从任意形态的 usage 字段里取 input_tokens(容忍缺字段与字符串)。 */
58
+ function inputTokensOf(usage) {
59
+ const value = Number(usage?.input_tokens)
60
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : UNKNOWN_TOKENS
61
+ }
62
+
63
+ /**
64
+ * 创建用量台账。
65
+ *
66
+ * @param {object} [options]
67
+ * @param {string} [options.path] - 台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl`
68
+ * @param {object} [options.logger] - 可选 logger,用于告警(写盘失败等)
69
+ * @returns {{ record: Function, today: Function, check: Function, formatToday: Function }}
70
+ */
71
+ export function createUsageLedger(options = {}) {
72
+ const file = typeof options.path === 'string' && options.path.trim()
73
+ ? options.path.trim()
74
+ : usageLogPath('')
75
+ const logger = options.logger
76
+
77
+ /** 内存累计:{ day, calls, tokens, failures, refused } —— 读取时先按天校验再复用 */
78
+ let totals = { day: localDayKey(), calls: 0, tokens: 0, failures: 0 }
79
+ /** 写盘是否失败过(只告警一次,避免每一步刷日志) */
80
+ let writeFailed = false
81
+
82
+ function rollIfNeeded(now = Date.now()) {
83
+ const day = localDayKey(now)
84
+ if (totals.day !== day) {
85
+ totals = { day, calls: 0, tokens: 0, failures: 0 }
86
+ }
87
+ return totals
88
+ }
89
+
90
+ /** 记录一次调用。永不抛错——记账失败不影响决策。 */
91
+ function record(entry = {}) {
92
+ const now = Number.isFinite(entry.at) ? entry.at : Date.now()
93
+ const tokens = inputTokensOf(entry.usage)
94
+ const ok = entry.ok !== false
95
+ const current = rollIfNeeded(now)
96
+ current.calls += 1
97
+ current.tokens += tokens
98
+ if (!ok) current.failures += 1
99
+
100
+ try {
101
+ mkdirSync(join(file, '..'), { recursive: true })
102
+ appendFileSync(file, `${JSON.stringify({
103
+ ts: now,
104
+ day: current.day,
105
+ source: String(entry.source || 'unknown'),
106
+ model: entry.model ? String(entry.model) : '',
107
+ provider: entry.provider ? String(entry.provider) : '',
108
+ scenario: entry.scenario ? String(entry.scenario) : '',
109
+ ok,
110
+ input_tokens: tokens,
111
+ latency_ms: Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : null,
112
+ })}\n`, 'utf8')
113
+ } catch (error) {
114
+ if (!writeFailed) {
115
+ writeFailed = true
116
+ try {
117
+ logger?.warn?.(`@master0071/dsh-systemone[usage]: 台账写入失败,本次起降级为仅内存统计:${error?.message || error}`)
118
+ } catch {
119
+ // 日志失败不影响决策
120
+ }
121
+ }
122
+ }
123
+ return { calls: current.calls, tokens: current.tokens, failures: current.failures, day: current.day }
124
+ }
125
+
126
+ /** 今日累计(只读快照)。 */
127
+ function today(now = Date.now()) {
128
+ const current = rollIfNeeded(now)
129
+ return { day: current.day, calls: current.calls, tokens: current.tokens, failures: current.failures }
130
+ }
131
+
132
+ /**
133
+ * 发请求前的限额判定。
134
+ * @param {{dailyCallLimit:number, dailyTokenLimit:number}} limits - 0 表示不限
135
+ * @returns {{allowed:boolean, reason?:string, snapshot:object}}
136
+ */
137
+ function check(limits = {}) {
138
+ const snapshot = today()
139
+ const callLimit = Number(limits.dailyCallLimit)
140
+ const tokenLimit = Number(limits.dailyTokenLimit)
141
+ if (Number.isFinite(callLimit) && callLimit > 0 && snapshot.calls >= callLimit) {
142
+ return {
143
+ allowed: false,
144
+ snapshot,
145
+ reason: `今日调用数已达上限 ${callLimit}(当前 ${snapshot.calls})。调大 dailyCallLimit,或设为 0 关闭上限。`,
146
+ }
147
+ }
148
+ if (Number.isFinite(tokenLimit) && tokenLimit > 0 && snapshot.tokens >= tokenLimit) {
149
+ return {
150
+ allowed: false,
151
+ snapshot,
152
+ reason: `今日 input token 已达上限 ${tokenLimit}(当前 ${snapshot.tokens})。调大 dailyTokenLimit,或设为 0 关闭上限。`,
153
+ }
154
+ }
155
+ return { allowed: true, snapshot }
156
+ }
157
+
158
+ /** 一行人类可读的今日用量(供工具输出与日志)。 */
159
+ function formatToday(now = Date.now()) {
160
+ const s = today(now)
161
+ const tokens = s.tokens >= 1000 ? `${(s.tokens / 1000).toFixed(1)}k` : String(s.tokens)
162
+ const fail = s.failures > 0 ? `,其中失败 ${s.failures} 次` : ''
163
+ return `今日:${s.calls} 次判断 · ${tokens} input tokens(${s.day})${fail}`
164
+ }
165
+
166
+ return { record, today, check, formatToday, get path() { return file } }
167
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@master0071/dsh-systemone",
3
- "version": "0.4.4",
4
- "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 10 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
3
+ "version": "0.6.0",
4
+ "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、需求预检、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 11 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "files": [
19
19
  "lib/",
20
20
  "cordis.patch.yml",
21
- "README.md"
21
+ "README.md",
22
+ "LICENSE"
22
23
  ],
23
24
  "main": "./lib/index.js",
24
25
  "types": "./lib/index.d.ts",
@@ -31,6 +32,7 @@
31
32
  "./package.json": "./package.json"
32
33
  },
33
34
  "dsh": {
35
+ "manifestVersion": 1,
34
36
  "bundle": {
35
37
  "patch": "cordis.patch.yml"
36
38
  },
@@ -46,14 +48,18 @@
46
48
  "node": ">=22.19"
47
49
  },
48
50
  "scripts": {
49
- "check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/tools.js && node --check lib/format.js && node --check lib/scenarios.js && node --check lib/auto.js && node --check lib/volatile.js && node --check lib/client.js",
50
- "test": "node --test \"test/**/*.test.mjs\""
51
+ "check": "node -e \"const{readdirSync}=require('node:fs'),{spawnSync}=require('node:child_process');const files=readdirSync('lib').filter((f)=>f.endsWith('.js')).sort();if(files.length===0){console.error('check: lib/ 下没有 .js 文件,检查失败');process.exit(1)}const bad=files.filter((f)=>spawnSync(process.execPath,['--check','lib/'+f],{stdio:'inherit'}).status!==0);if(bad.length>0){console.error('check: 语法检查失败 -> '+bad.join(', '));process.exit(1)}console.log('check: '+files.length+' 个 lib/*.js 语法检查通过 -> '+files.join(', '))\"",
52
+ "test": "node --test \"test/**/*.test.mjs\"",
53
+ "probe": "node scripts/probe-host.mjs",
54
+ "verify:docs": "node scripts/verify-doc-claims.mjs",
55
+ "fix:docs": "node scripts/verify-doc-claims.mjs --update"
51
56
  },
52
57
  "dependencies": {
53
58
  "@deepseek-ai/schemastery": "^3.18.1"
54
59
  },
55
60
  "peerDependencies": {
56
61
  "@deepseek-ai/cordis": "^4.0.1",
62
+ "@deepseek-ai/dsh": ">=0.1.7-rc.1 <0.3.0",
57
63
  "@deepseek-ai/schemastery": "^3.18.1"
58
64
  }
59
65
  }