@twinsearth/dsh-master 3.2.10 → 3.3.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.
Files changed (93) hide show
  1. package/README.md +3 -0
  2. package/client.js +50 -0
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +28 -2
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +88 -1
  27. package/dist-host/superagent/types.js +8 -0
  28. package/dist-worker/agent-loop.js +449 -7
  29. package/dist-worker/baseline-fit.js +66 -0
  30. package/dist-worker/budget-guard.js +93 -0
  31. package/dist-worker/ccs.js +99 -0
  32. package/dist-worker/cost-hooks.js +115 -0
  33. package/dist-worker/cost-modules.js +330 -0
  34. package/dist-worker/cost-spi.js +3 -0
  35. package/dist-worker/edg.js +372 -0
  36. package/dist-worker/gdr.js +159 -0
  37. package/dist-worker/index.js +80 -2
  38. package/dist-worker/llm-http.js +20 -0
  39. package/dist-worker/sink-http.js +1 -0
  40. package/dist-worker/spill.js +115 -0
  41. package/dist-worker/tool-batch.js +66 -0
  42. package/dist-worker/tool-compact.js +275 -0
  43. package/dist-worker/tool-disclosure.js +90 -0
  44. package/dist-worker/tools.js +16 -0
  45. package/docs/ARCHITECTURE.md +21 -0
  46. package/docs/CHANGELOG.md +50 -0
  47. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  48. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  49. package/docs/THIRD-PARTY.md +87 -0
  50. package/package.json +1 -1
  51. package/src/cost/attribution.ts +186 -0
  52. package/src/cost/audit.ts +170 -0
  53. package/src/cost/budget.ts +226 -0
  54. package/src/cost/bus.ts +97 -0
  55. package/src/cost/cert-chain.ts +102 -0
  56. package/src/cost/experiment.ts +136 -0
  57. package/src/cost/gate.ts +191 -0
  58. package/src/cost/host-surface.ts +97 -0
  59. package/src/cost/index.ts +316 -0
  60. package/src/cost/ledger-report.ts +186 -0
  61. package/src/cost/ledger.ts +385 -0
  62. package/src/cost/metrics.ts +193 -0
  63. package/src/cost/milestones.ts +170 -0
  64. package/src/cost/modules/index.ts +13 -0
  65. package/src/cost/modules/manifests.ts +129 -0
  66. package/src/cost/policy.ts +276 -0
  67. package/src/cost/report.ts +158 -0
  68. package/src/cost/reuse-report.ts +131 -0
  69. package/src/cost/rsi.ts +364 -0
  70. package/src/cost/toolface.ts +151 -0
  71. package/src/cost/types.ts +298 -0
  72. package/src/kernel.ts +27 -2
  73. package/src/plugins/cost-optimizer.ts +271 -26
  74. package/src/plugins/superagent-router.ts +82 -1
  75. package/src/superagent/types.ts +70 -0
  76. package/worker/agent-loop.ts +444 -7
  77. package/worker/baseline-fit.ts +80 -0
  78. package/worker/budget-guard.ts +156 -0
  79. package/worker/ccs.ts +202 -0
  80. package/worker/cost-hooks.ts +133 -0
  81. package/worker/cost-modules.ts +356 -0
  82. package/worker/cost-spi.ts +283 -0
  83. package/worker/edg.ts +424 -0
  84. package/worker/gdr.ts +233 -0
  85. package/worker/index.ts +84 -3
  86. package/worker/llm-http.ts +18 -0
  87. package/worker/sink-http.ts +5 -2
  88. package/worker/spill.ts +133 -0
  89. package/worker/tool-batch.ts +124 -0
  90. package/worker/tool-compact.ts +317 -0
  91. package/worker/tool-disclosure.ts +131 -0
  92. package/worker/tools.ts +553 -531
  93. package/worker/types.ts +191 -0
package/README.md CHANGED
@@ -84,12 +84,15 @@ dsh plugin add <package> # 加载 dist-host/dist-worker
84
84
  - [Worker Agent Loop](docs/WORKER-AGENT-LOOP.md)
85
85
  - [SuperAgent v3.2.1 设计与契约](docs/SUPERAGENT.md)
86
86
  - [架构设计](docs/ARCHITECTURE.md)
87
+ - [3.3.0 成本优化架构(总体架构 / 实施进度)](docs/DESIGN-v3.3.0-cost-optimization.md)
88
+ - [第三方参考实现与移植边界](docs/THIRD-PARTY.md)
87
89
  - [路线图(3.2.0–3.4.0)](docs/ROADMAP.md)
88
90
  - [安全边界](docs/SECURITY.md)
89
91
  - [变更记录](docs/CHANGELOG.md)
90
92
 
91
93
  ## 状态
92
94
 
95
+ - 3.3.0 M1+M2(dev,未发布):成本优化系统层(ledger / budget / policy / audit)+ Worker 四个成本切点(observe-only)+ A1 工具输出紧凑化(默认 shadow,enforce 需白名单);A2–A3 与决策复用 B1–B4 在 M3–M4 接入,详见 [3.3.0 架构设计](docs/DESIGN-v3.3.0-cost-optimization.md)。
93
96
  - 3.2.10 发布线:角色矩阵放宽——Router 进入母体(essence/mother),Worker 与 Worker Agent Loop 进入原身(fork/essence);母体仍不接纳执行体,fork 仍不注册 Router。
94
97
  - 3.2.9 发布线:房主面板改注册到 `shell.overlay` 常驻浮层——点击面板之外不收起、侧栏「房主」按钮二次点击收起、正文独立滚动;新增无浏览器 hook 运行时行为测试(开关、无 outside-click 监听、滚动容器、SSE 失败不卸载面板)。
95
98
  - 3.2.8 发布线:与瓮城通过 fang-master contract 1.0 互联(local/http/file/legacy 四级保底);GitHub Release + NPM public。
package/client.js CHANGED
@@ -241,6 +241,55 @@ window.__ModuleLoader__.load({
241
241
  )
242
242
  }
243
243
 
244
+ /**
245
+ * §11:成本(只读)块。
246
+ *
247
+ * 只消费 `/dsh-master/api/superagent/cost` 的 `service` 段,**不写任何业务状态**、
248
+ * 不触发任何成本动作;A(单价项)与 B(跳过调用项)分列展示,绝不合并成一个数字。
249
+ */
250
+ function CostCard() {
251
+ const [cost, setCost] = React.useState(null)
252
+ const [failed, setFailed] = React.useState(false)
253
+
254
+ React.useEffect(() => {
255
+ let alive = true
256
+ const load = async () => {
257
+ try {
258
+ const response = await fetch('/dsh-master/api/superagent/cost', { headers: { accept: 'application/json' } })
259
+ const body = await response.json().catch(() => null)
260
+ if (!alive) return
261
+ setCost(response.ok && body ? body : null)
262
+ setFailed(!response.ok)
263
+ } catch { if (alive) setFailed(true) }
264
+ }
265
+ void load()
266
+ const timer = setInterval(load, 5000)
267
+ return () => { alive = false; clearInterval(timer) }
268
+ }, [])
269
+
270
+ const block = cost && cost.service ? cost.service : null
271
+ const columns = block && block.columns ? block.columns : null
272
+ const savings = block && block.savings ? block.savings : null
273
+ const fmt = (value) => (value === null || value === undefined ? '—' : Number(value).toLocaleString())
274
+
275
+ return h('div', { style: S.card, 'data-dsh-master': 'cost-block' },
276
+ h('div', { style: S.row },
277
+ h('span', { style: S.name }, '成本(只读)'),
278
+ tag(block ? '3.3.0' : '未启用', block ? undefined : '#B45309')),
279
+ failed ? h('div', { style: S.error }, '成本接口不可达') : null,
280
+ !block && !failed ? h('div', { style: S.desc }, '成本系统未装配(cost-optimizer 未启用或角色不匹配)') : null,
281
+ columns ? h('div', { style: S.desc },
282
+ '单价项 A:' + fmt(columns.unitPriceSaved) + ' · ',
283
+ '跳过调用 B:' + fmt(columns.skippedCallSaved) + '(' + fmt(columns.skippedCalls) + ' 次)') : null,
284
+ columns ? h('div', { style: S.desc },
285
+ 'governance:' + fmt(columns.governanceTotal) + ' · 净节省:' + fmt(columns.netSaved)) : null,
286
+ columns && columns.netLossTasks && columns.netLossTasks.length > 0
287
+ ? h('div', { style: S.off }, '净亏任务:' + columns.netLossTasks.slice(0, 3).join(', '))
288
+ : null,
289
+ savings ? h('div', { style: S.desc }, '命中率:' + (block.hitRate === undefined || block.hitRate === null ? '—' : (Number(block.hitRate) * 100).toFixed(1) + '%')) : null,
290
+ )
291
+ }
292
+
244
293
  function MasterPanel() {
245
294
  const [status, setStatus] = React.useState(null)
246
295
  const [error, setError] = React.useState('')
@@ -283,6 +332,7 @@ window.__ModuleLoader__.load({
283
332
  h('div', { style: S.desc }, '运行:' + Math.max(1, Math.round((status.uptimeMs || 0) / 60000)) + ' 分钟'),
284
333
  ) : null,
285
334
  ...items.map((item) => h(SubpluginCard, { key: item.id, item })),
335
+ h(CostCard),
286
336
  h(FangCard, { cooperation: status && status.cooperation }),
287
337
  h(LiveFeed),
288
338
  h('div', { style: S.desc }, '3.2.10:角色矩阵放宽(Router→essence/mother,Worker/Loop→fork/essence);面板常驻、按钮二次点击收起、正文可滚动;实时流(SSE)+ token 轮换/审计。'),
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+ /**
5
+ * 3.3.0 系统层 · attribution(归因,设计 §5.4)。
6
+ *
7
+ * 三条硬规则:
8
+ * 1. **每条 savedTokens 必须带 moduleId**(缺失 ⇒ `module-missing`,不猜、不默认);
9
+ * 2. 差额进 `unattributed`,报表**单列**——未归因收益不得算作任何模块收益;
10
+ * 3. 归因算法(工具面):`baselineOutputTokens(toolName, 长度) − actualOutputTokens`,
11
+ * baseline 由 A1 固定拟合式给出(`worker/baseline-fit.ts`,参数在 docs §5.4.1 冻结)。
12
+ *
13
+ * 另守 §5.3 口径:`shadow` 只累计「本可节省」,`enforce` 才写入 execution 账本且
14
+ * `totalTokens=0`(不重复计入已消耗 token)。
15
+ */
16
+ import { COST_MODULES } from "./types.js";
17
+ function finiteNonNegative(value) {
18
+ const n = Number(value);
19
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
20
+ }
21
+ /**
22
+ * @param {{ledger: {append: Function}, now?: () => number}} options
23
+ */
24
+ export function createAttribution(options = {}) {
25
+ const ledger = options.ledger;
26
+ if (!ledger || typeof ledger.append !== 'function')
27
+ throw new Error('attribution requires a ledger');
28
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
29
+ const allowed = new Set(COST_MODULES);
30
+ const modules = new Map();
31
+ const tasks = new Map();
32
+ const moduleRow = (module) => {
33
+ if (!modules.has(module))
34
+ modules.set(module, { module, events: 0, projectedTokens: 0, enforceSavedTokens: 0 });
35
+ return modules.get(module);
36
+ };
37
+ const taskRow = (taskId, module) => {
38
+ const key = `${taskId ?? ''}|${module}`;
39
+ if (!tasks.has(key))
40
+ tasks.set(key, { key, taskId: taskId === undefined ? null : String(taskId), module, events: 0, projectedTokens: 0, enforceSavedTokens: 0 });
41
+ return tasks.get(key);
42
+ };
43
+ /**
44
+ * @param {{taskId?: string, module: string, savedTokens: number, mode?: string, meta?: object,
45
+ * forkId?: string, sessionId?: string}} input
46
+ */
47
+ function attribute(input = {}) {
48
+ const saved = finiteNonNegative(input.savedTokens);
49
+ if (saved === null)
50
+ return { ok: false, code: 'saved-tokens-invalid', message: 'savedTokens must be a non-negative number' };
51
+ // §5.4-1:moduleId 必填,缺失不猜默认值。
52
+ if (input.module === undefined || input.module === null || String(input.module).trim() === '') {
53
+ return { ok: false, code: 'module-missing', message: 'every savedTokens must carry a moduleId' };
54
+ }
55
+ const module = String(input.module);
56
+ if (!allowed.has(module))
57
+ return { ok: false, code: 'module-invalid', message: `module must be one of ${COST_MODULES.join('/')}` };
58
+ const enforce = String(input.mode ?? 'shadow') === 'enforce';
59
+ const row = moduleRow(module);
60
+ const perTask = taskRow(input.taskId, module);
61
+ if (!enforce) {
62
+ row.events += 1;
63
+ row.projectedTokens += saved;
64
+ perTask.events += 1;
65
+ perTask.projectedTokens += saved;
66
+ return {
67
+ ok: true,
68
+ mode: 'shadow',
69
+ module,
70
+ projectedTokens: saved,
71
+ shadow: { events: row.events, projectedTokens: row.projectedTokens, appliedTokens: row.enforceSavedTokens },
72
+ at: now(),
73
+ };
74
+ }
75
+ const appended = ledger.append({
76
+ account: 'execution',
77
+ module,
78
+ source: 'tool',
79
+ phase: 'tool',
80
+ taskId: input.taskId === undefined ? null : String(input.taskId),
81
+ forkId: input.forkId,
82
+ sessionId: input.sessionId,
83
+ step: input.step,
84
+ turn: input.turn,
85
+ totalTokens: 0,
86
+ savedTokens: saved,
87
+ meta: { kind: 'attribution', ...(input.meta && typeof input.meta === 'object' ? input.meta : {}), mode: 'enforce' },
88
+ });
89
+ if (!appended.ok)
90
+ return { ok: false, code: appended.code, message: appended.message };
91
+ row.events += 1;
92
+ row.enforceSavedTokens += saved;
93
+ perTask.events += 1;
94
+ perTask.enforceSavedTokens += saved;
95
+ return {
96
+ ok: true,
97
+ mode: 'enforce',
98
+ module,
99
+ savedTokens: saved,
100
+ ledger: { id: appended.entry.id },
101
+ shadow: { events: row.events, projectedTokens: row.projectedTokens, appliedTokens: row.enforceSavedTokens },
102
+ at: now(),
103
+ };
104
+ }
105
+ /**
106
+ * §5.4-2:按「总额 − 各模块之和」结算,差额自动进 `unattributed`。
107
+ * 任何未提供 moduleId 的条目都会被拒绝(模块收益不得凭空出现)。
108
+ *
109
+ * @param {{taskId?: string, totalSavedTokens: number, items?: Array<{module: string, savedTokens: number}>,
110
+ * mode?: string, forkId?: string, sessionId?: string}} input
111
+ */
112
+ function attributeTotal(input = {}) {
113
+ const total = finiteNonNegative(input.totalSavedTokens);
114
+ if (total === null)
115
+ return { ok: false, code: 'saved-tokens-invalid', message: 'totalSavedTokens must be a non-negative number' };
116
+ const items = Array.isArray(input.items) ? input.items : [];
117
+ let attributed = 0;
118
+ const applied = [];
119
+ for (const item of items) {
120
+ const out = attribute({
121
+ taskId: input.taskId,
122
+ module: item?.module,
123
+ savedTokens: item?.savedTokens,
124
+ mode: input.mode,
125
+ forkId: input.forkId,
126
+ sessionId: input.sessionId,
127
+ meta: { kind: 'module-share' },
128
+ });
129
+ if (!out.ok)
130
+ return out;
131
+ attributed += finiteNonNegative(item?.savedTokens) ?? 0;
132
+ applied.push({ module: String(item.module), savedTokens: finiteNonNegative(item?.savedTokens) ?? 0 });
133
+ }
134
+ if (attributed > total) {
135
+ return { ok: false, code: 'attribution-overflow', message: `attributed ${attributed} exceeds total ${total}` };
136
+ }
137
+ const residual = total - attributed;
138
+ if (residual > 0) {
139
+ const out = attribute({
140
+ taskId: input.taskId,
141
+ module: 'unattributed',
142
+ savedTokens: residual,
143
+ mode: input.mode,
144
+ forkId: input.forkId,
145
+ sessionId: input.sessionId,
146
+ meta: { kind: 'residual' },
147
+ });
148
+ if (!out.ok)
149
+ return out;
150
+ }
151
+ return { ok: true, totalSavedTokens: total, attributedTokens: attributed, unattributedTokens: residual, items: applied, at: now() };
152
+ }
153
+ function snapshot() {
154
+ const moduleRows = [...modules.values()]
155
+ .map((item) => Object.freeze({ ...item }))
156
+ .sort((a, b) => b.projectedTokens + b.enforceSavedTokens - (a.projectedTokens + a.enforceSavedTokens));
157
+ const taskRows = [...tasks.values()]
158
+ .map((item) => Object.freeze({ ...item }))
159
+ .sort((a, b) => b.projectedTokens + b.enforceSavedTokens - (a.projectedTokens + a.enforceSavedTokens));
160
+ const named = moduleRows.filter((row) => row.module !== 'unattributed');
161
+ const unattributed = moduleRows.filter((row) => row.module === 'unattributed');
162
+ const sum = (rows, key) => rows.reduce((total, row) => total + row[key], 0);
163
+ return Object.freeze({
164
+ ok: true,
165
+ modules: moduleRows,
166
+ tasks: taskRows,
167
+ /** 模块收益(不含 unattributed)——报表只能把这一项当「模块业绩」。 */
168
+ attributedSavedTotal: sum(named, 'enforceSavedTokens'),
169
+ attributedProjectedTotal: sum(named, 'projectedTokens'),
170
+ /** 未归因收益:单列,永不并入上面的模块业绩。 */
171
+ unattributedSavedTotal: sum(unattributed, 'enforceSavedTokens'),
172
+ unattributedProjectedTotal: sum(unattributed, 'projectedTokens'),
173
+ projectedTotal: sum(moduleRows, 'projectedTokens'),
174
+ enforceSavedTotal: sum(moduleRows, 'enforceSavedTokens'),
175
+ });
176
+ }
177
+ function reset() {
178
+ modules.clear();
179
+ tasks.clear();
180
+ return { ok: true };
181
+ }
182
+ return Object.freeze({ attribute, attributeTotal, snapshot, reset });
183
+ }
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+ /**
5
+ * 3.3.0 系统层 · audit(哈希链审计,设计 §5.5)。
6
+ *
7
+ * 结构约束:
8
+ * * **只追加**:没有 update/delete/rewrite API,记录 `Object.freeze`;
9
+ * * 每条记录含 `prevHash` 与自身 `hash`,任何篡改或截断都会在 `verify()` 断链;
10
+ * * 每次替换写一条 `SubstitutionCert`(edgeId / toolName / schemaHash / guardExpr /
11
+ * receiptDigest / replacedCallbackId / viaNativeDispatcher: true),
12
+ * 并与 ledger 事件用同一 `taskId + step` 关联 —— 报表可回放「这一省是怎么来的」。
13
+ */
14
+ import crypto from 'node:crypto';
15
+ /** SubstitutionCert 必填字段;缺任何一项都不得落链(否则证书无法复核)。 */
16
+ const CERT_REQUIRED = Object.freeze([
17
+ 'edgeId',
18
+ 'toolName',
19
+ 'schemaHash',
20
+ 'guardExpr',
21
+ 'receiptDigest',
22
+ 'replacedCallbackId',
23
+ 'taskId',
24
+ ]);
25
+ function canonical(value) {
26
+ if (Array.isArray(value))
27
+ return `[${value.map((item) => canonical(item)).join(',')}]`;
28
+ if (value && typeof value === 'object') {
29
+ const keys = Object.keys(value).sort();
30
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
31
+ }
32
+ if (typeof value === 'number')
33
+ return Number.isFinite(value) ? String(value) : 'null';
34
+ if (value === undefined)
35
+ return 'null';
36
+ return JSON.stringify(value) ?? 'null';
37
+ }
38
+ function hashOf(seq, at, type, actor, payload, prevHash) {
39
+ const body = canonical([seq, at, type, actor, payload, prevHash]);
40
+ return `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
41
+ }
42
+ /**
43
+ * @param {{now?: () => number, maxRecords?: number}} [options]
44
+ */
45
+ export function createAuditChain(options = {}) {
46
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
47
+ const maxRecords = Math.max(1, Number(options.maxRecords ?? 2000) || 2000);
48
+ const records = [];
49
+ let seq = 0;
50
+ let dropped = 0;
51
+ function append({ type, actor = 'cost-system', payload = {} } = {}) {
52
+ seq += 1;
53
+ const at = now();
54
+ const prevHash = records.length > 0 ? records[records.length - 1].hash : '';
55
+ const record = Object.freeze({
56
+ seq,
57
+ at,
58
+ type: String(type ?? 'unknown'),
59
+ actor: String(actor ?? 'cost-system'),
60
+ payload: Object.freeze({ ...payload }),
61
+ prevHash,
62
+ hash: hashOf(seq, at, String(type ?? 'unknown'), String(actor ?? 'cost-system'), payload, prevHash),
63
+ });
64
+ records.push(record);
65
+ if (records.length > maxRecords) {
66
+ records.shift();
67
+ dropped += 1;
68
+ }
69
+ return record;
70
+ }
71
+ /**
72
+ * 写一条 SubstitutionCert(§5.5)。fail-closed:
73
+ * * 必填字段缺失/空白 ⇒ 拒绝;
74
+ * * `viaNativeDispatcher` 必须严格为 `true`(非原生派发不得替换);
75
+ * * `taskId + step` 是回放键,step 必须是非负整数。
76
+ */
77
+ function appendSubstitution(cert = {}) {
78
+ const missing = CERT_REQUIRED.filter((key) => {
79
+ const value = cert?.[key];
80
+ return value === undefined || value === null || String(value).trim() === '';
81
+ });
82
+ if (missing.length > 0) {
83
+ return { ok: false, code: 'cert-invalid', message: `substitution cert missing ${missing.join(',')}`, missing };
84
+ }
85
+ if (cert.viaNativeDispatcher !== true) {
86
+ return { ok: false, code: 'cert-not-native', message: 'substitution must be executed via the native dispatcher' };
87
+ }
88
+ const step = Number(cert.step);
89
+ if (!Number.isInteger(step) || step < 0) {
90
+ return { ok: false, code: 'cert-step-invalid', message: 'step must be a non-negative integer' };
91
+ }
92
+ const savedTokens = Number(cert.savedTokens ?? 0);
93
+ if (!Number.isFinite(savedTokens) || savedTokens < 0) {
94
+ return { ok: false, code: 'cert-saved-invalid', message: 'savedTokens must be a non-negative number' };
95
+ }
96
+ const payload = Object.freeze({
97
+ kind: 'substitution-cert',
98
+ edgeId: String(cert.edgeId),
99
+ toolName: String(cert.toolName),
100
+ schemaHash: String(cert.schemaHash),
101
+ guardExpr: String(cert.guardExpr),
102
+ receiptDigest: String(cert.receiptDigest),
103
+ replacedCallbackId: String(cert.replacedCallbackId),
104
+ /** M4:落链即代表「已经过原生派发器真的派发」;证书链核对直接读这一位。 */
105
+ dispatched: true,
106
+ viaNativeDispatcher: true,
107
+ taskId: String(cert.taskId),
108
+ step,
109
+ forkId: cert.forkId === undefined ? null : String(cert.forkId),
110
+ sessionId: cert.sessionId === undefined ? null : String(cert.sessionId),
111
+ ticketId: cert.ticketId === undefined ? null : String(cert.ticketId),
112
+ moduleId: cert.moduleId === undefined ? null : String(cert.moduleId),
113
+ savedTokens,
114
+ });
115
+ const record = append({ type: 'cost.substitution', actor: 'cost-ccs', payload });
116
+ return { ok: true, cert: payload, record };
117
+ }
118
+ function verify(source) {
119
+ const list = Array.isArray(source) ? source : records;
120
+ let prevHash = '';
121
+ let expectedSeq = 1;
122
+ for (const record of list) {
123
+ if (record.seq !== expectedSeq)
124
+ return { ok: false, count: list.length, brokenAt: record.seq, reason: 'seq-gap' };
125
+ if (record.prevHash !== prevHash)
126
+ return { ok: false, count: list.length, brokenAt: record.seq, reason: 'prev-hash-mismatch' };
127
+ const expected = hashOf(record.seq, record.at, record.type, record.actor, record.payload, record.prevHash);
128
+ if (record.hash !== expected)
129
+ return { ok: false, count: list.length, brokenAt: record.seq, reason: 'hash-mismatch' };
130
+ prevHash = record.hash;
131
+ expectedSeq += 1;
132
+ }
133
+ return { ok: true, count: list.length, brokenAt: null, reason: null };
134
+ }
135
+ function snapshot(limit = 50) {
136
+ const n = Math.max(1, Number(limit) || 50);
137
+ return Object.freeze({
138
+ ok: true,
139
+ count: records.length,
140
+ dropped,
141
+ verified: verify(),
142
+ records: records.slice(-n),
143
+ });
144
+ }
145
+ function reset() {
146
+ records.length = 0;
147
+ seq = 0;
148
+ dropped = 0;
149
+ return { ok: true };
150
+ }
151
+ /** 取某任务/步骤的证书(回放用;不改链)。 */
152
+ function substitutions(filter = {}) {
153
+ const taskId = filter.taskId === undefined ? null : String(filter.taskId);
154
+ const step = filter.step === undefined ? null : Number(filter.step);
155
+ const rows = records
156
+ .filter((record) => record.type === 'cost.substitution')
157
+ .filter((record) => (taskId === null ? true : record.payload.taskId === taskId))
158
+ .filter((record) => (step === null ? true : record.payload.step === step));
159
+ return Object.freeze(rows.map((record) => Object.freeze({ ...record.payload, seq: record.seq, at: record.at, hash: record.hash })));
160
+ }
161
+ // 只追加:对外**不暴露**任何修改/删除入口(records() 返回副本)。
162
+ return Object.freeze({ append, appendSubstitution, verify, snapshot, reset, records: () => records.slice(), substitutions });
163
+ }
@@ -0,0 +1,211 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+ /**
5
+ * 3.3.0 系统层 · budget(预算闸,设计 §5.2)。
6
+ *
7
+ * 规格要点:
8
+ * * 规则:`unit(task|fork|day) + limitTokens? + limitCost? + onExhaust(hard-stop|degrade)`;
9
+ * * **检查点只在 ① beforeModelCall**(`authorize()` 即该检查点,其它地方不得自行扣减);
10
+ * * 超限返回 `masterError('budget-exhausted', …)` 形状 + 结构化 `remaining`;
11
+ * * **fail-closed**:额度不足不得「先跑着看」——拒绝时**不扣额度**、不放行;
12
+ * * `degrade` 档只允许降级到**已在白名单**的廉价模型,绝不引入新 provider。
13
+ *
14
+ * 成本维度(`limitCost`)需要调用方给出本次预估成本(`cost` 字段);
15
+ * 未提供成本时成本维度不可判定,会显式标 `costTracked: false`,而不是假装达标。
16
+ */
17
+ import { DEFAULT_BUDGET_LIMITS } from "./types.js";
18
+ const UNITS = ['task', 'fork', 'day'];
19
+ const EXHAUST_ACTIONS = ['hard-stop', 'degrade'];
20
+ /** 与 kernel.masterError 同形状(host 侧不反向依赖 kernel,避免子系统耦合)。 */
21
+ function masterError(code, message, detail) {
22
+ const out = { ok: false, code: String(code), error: String(message ?? code) };
23
+ if (detail !== undefined)
24
+ out.detail = detail;
25
+ return out;
26
+ }
27
+ function positiveLimit(value) {
28
+ const n = Number(value);
29
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
30
+ }
31
+ function keyOf(unit, key) {
32
+ return `${unit}:${String(key ?? 'default')}`;
33
+ }
34
+ /**
35
+ * @param {{
36
+ * limits?: Array<{unit:string, limitTokens?:number, limitCost?:number, onExhaust?:string, degradeTo?:string}>,
37
+ * degradeModels?: string[], // degrade 允许的廉价模型白名单(不在此列表 ⇒ 一律 hard-stop)
38
+ * now?: () => number,
39
+ * }} [options]
40
+ */
41
+ export function createBudget(options = {}) {
42
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
43
+ const source = Array.isArray(options.limits) && options.limits.length > 0 ? options.limits : DEFAULT_BUDGET_LIMITS;
44
+ const degradeModels = Object.freeze((Array.isArray(options.degradeModels) ? options.degradeModels : [])
45
+ .map((item) => String(item ?? '').trim())
46
+ .filter(Boolean));
47
+ const limits = [];
48
+ const invalid = [];
49
+ for (const item of source) {
50
+ const unit = String(item?.unit ?? '');
51
+ const limitTokens = item?.limitTokens === undefined ? null : positiveLimit(item.limitTokens);
52
+ const limitCost = item?.limitCost === undefined ? null : positiveLimit(item.limitCost);
53
+ if (!UNITS.includes(unit) || (limitTokens === null && limitCost === null)) {
54
+ // 既无 token 上限也无成本上限的规则没有意义:配置错误必须显式暴露(fail-closed)。
55
+ invalid.push({ unit, limitTokens: item?.limitTokens, limitCost: item?.limitCost });
56
+ continue;
57
+ }
58
+ if (limits.some((rule) => rule.unit === unit)) {
59
+ // 同一 unit 只能有一条规则(先注册先得),否则判定口径不确定。
60
+ invalid.push({ unit, reason: 'duplicate-unit' });
61
+ continue;
62
+ }
63
+ const onExhaust = EXHAUST_ACTIONS.includes(String(item?.onExhaust)) ? String(item.onExhaust) : 'hard-stop';
64
+ const degradeTo = item?.degradeTo === undefined ? null : String(item.degradeTo);
65
+ // degrade 档必须在白名单内,否则等于开了个后门:降级为 hard-stop。
66
+ const effective = onExhaust === 'degrade' && degradeTo !== null && degradeModels.includes(degradeTo)
67
+ ? 'degrade'
68
+ : 'hard-stop';
69
+ if (onExhaust === 'degrade' && effective !== 'degrade') {
70
+ invalid.push({ unit, degradeTo, reason: 'degrade-model-not-allowlisted' });
71
+ }
72
+ limits.push(Object.freeze({
73
+ unit,
74
+ limitTokens,
75
+ limitCost,
76
+ onExhaust: effective,
77
+ degradeTo: effective === 'degrade' ? degradeTo : null,
78
+ }));
79
+ }
80
+ const limitOf = (unit) => limits.find((item) => item.unit === unit) ?? null;
81
+ const consumed = new Map();
82
+ const used = (unit, key) => consumed.get(keyOf(unit, key)) ?? { tokens: 0, cost: 0 };
83
+ /**
84
+ * ① beforeModelCall 的唯一检查点。
85
+ * @param {{unit: string, key?: string, tokens?: number, cost?: number}} input
86
+ */
87
+ function authorize(input = {}) {
88
+ const unit = String(input.unit);
89
+ const key = String(input.key ?? 'default');
90
+ const requested = Number(input.tokens ?? 0);
91
+ const requestedCost = Number(input.cost ?? 0);
92
+ if (!Number.isFinite(requested) || requested < 0) {
93
+ return masterError('tokens-invalid', 'tokens must be a non-negative number');
94
+ }
95
+ if (!Number.isFinite(requestedCost) || requestedCost < 0) {
96
+ return masterError('cost-invalid', 'cost must be a non-negative number');
97
+ }
98
+ const limit = limitOf(unit);
99
+ if (!limit) {
100
+ return { ok: true, unlimited: true, unit, key, requested, remaining: null, costTracked: false, at: now() };
101
+ }
102
+ const current = used(unit, key);
103
+ const remainingTokens = limit.limitTokens === null ? null : Math.max(0, limit.limitTokens - current.tokens);
104
+ const remainingCost = limit.limitCost === null ? null : Math.max(0, limit.limitCost - current.cost);
105
+ const tokenExhausted = limit.limitTokens !== null && requested > remainingTokens;
106
+ const costExhausted = limit.limitCost !== null && requestedCost > remainingCost;
107
+ const costTracked = limit.limitCost === null ? false : Number(input.cost) > 0;
108
+ if (tokenExhausted || costExhausted) {
109
+ const reason = tokenExhausted ? 'tokens' : 'cost';
110
+ // degrade 档:只降级到白名单里的廉价模型;否则一律 fail-closed。
111
+ if (limit.onExhaust === 'degrade' && limit.degradeTo && degradeModels.includes(limit.degradeTo)) {
112
+ return {
113
+ ok: true,
114
+ degraded: true,
115
+ action: 'degrade',
116
+ unit,
117
+ key,
118
+ model: limit.degradeTo,
119
+ reason,
120
+ limitTokens: limit.limitTokens,
121
+ limitCost: limit.limitCost,
122
+ consumedTokens: current.tokens,
123
+ consumedCost: current.cost,
124
+ remainingTokens,
125
+ remainingCost,
126
+ requested,
127
+ requestedCost,
128
+ costTracked,
129
+ onExhaust: 'degrade',
130
+ at: now(),
131
+ };
132
+ }
133
+ // fail-closed:拒绝且**不扣额度**,绝不「先跑着看」。
134
+ return masterError('budget-exhausted', `预算不足:${unit}/${key} 剩余 ${remainingTokens ?? '∞'} token`, {
135
+ unit,
136
+ key,
137
+ reason,
138
+ limitTokens: limit.limitTokens,
139
+ limitCost: limit.limitCost,
140
+ consumedTokens: current.tokens,
141
+ consumedCost: current.cost,
142
+ remainingTokens,
143
+ remainingCost,
144
+ requested,
145
+ requestedCost,
146
+ costTracked,
147
+ onExhaust: limit.onExhaust,
148
+ /** 剩余信息同时平铺在顶层,便于调用方不需解 detail。 */
149
+ remaining: { tokens: remainingTokens, cost: remainingCost },
150
+ at: now(),
151
+ });
152
+ }
153
+ const nextTokens = current.tokens + Math.floor(requested);
154
+ const nextCost = current.cost + requestedCost;
155
+ consumed.set(keyOf(unit, key), { tokens: nextTokens, cost: nextCost });
156
+ return {
157
+ ok: true,
158
+ unit,
159
+ key,
160
+ requested,
161
+ requestedCost,
162
+ consumedTokens: nextTokens,
163
+ consumedCost: nextCost,
164
+ limitTokens: limit.limitTokens,
165
+ limitCost: limit.limitCost,
166
+ remainingTokens: limit.limitTokens === null ? null : limit.limitTokens - nextTokens,
167
+ remainingCost: limit.limitCost === null ? null : limit.limitCost - nextCost,
168
+ costTracked,
169
+ at: now(),
170
+ };
171
+ }
172
+ function refund({ unit, key = 'default', tokens = 0, cost = 0 } = {}) {
173
+ const n = Number(tokens);
174
+ const c = Number(cost);
175
+ if (!Number.isFinite(n) || n < 0 || !Number.isFinite(c) || c < 0 || (n === 0 && c === 0)) {
176
+ return masterError('refund-invalid', 'refund requires positive tokens and/or cost');
177
+ }
178
+ const k = keyOf(String(unit), key);
179
+ const current = consumed.get(k) ?? { tokens: 0, cost: 0 };
180
+ const next = { tokens: Math.max(0, current.tokens - Math.floor(n)), cost: Math.max(0, current.cost - c) };
181
+ consumed.set(k, next);
182
+ return { ok: true, unit: String(unit), key: String(key), consumedTokens: next.tokens, consumedCost: next.cost, at: now() };
183
+ }
184
+ function state() {
185
+ const rows = [];
186
+ for (const limit of limits) {
187
+ for (const [k, value] of consumed.entries()) {
188
+ const prefix = `${limit.unit}:`;
189
+ if (!k.startsWith(prefix))
190
+ continue;
191
+ rows.push(Object.freeze({
192
+ unit: limit.unit,
193
+ key: k.slice(prefix.length),
194
+ consumedTokens: value.tokens,
195
+ consumedCost: value.cost,
196
+ limitTokens: limit.limitTokens,
197
+ limitCost: limit.limitCost,
198
+ remainingTokens: limit.limitTokens === null ? null : Math.max(0, limit.limitTokens - value.tokens),
199
+ remainingCost: limit.limitCost === null ? null : Math.max(0, limit.limitCost - value.cost),
200
+ onExhaust: limit.onExhaust,
201
+ }));
202
+ }
203
+ }
204
+ return Object.freeze({ ok: true, limits, invalid, degradeModels: degradeModels.slice(), rows, at: now() });
205
+ }
206
+ function reset() {
207
+ consumed.clear();
208
+ return { ok: true };
209
+ }
210
+ return Object.freeze({ authorize, refund, state, reset, limits: () => limits.slice(), degradeModels: () => degradeModels.slice() });
211
+ }