@twinsearth/dsh-master 3.2.10 → 3.3.1
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 +4 -0
- package/client.js +50 -0
- package/dist-host/contract/client.js +7 -0
- package/dist-host/contract/constants.js +16 -2
- package/dist-host/contract/index.js +1 -0
- package/dist-host/contract/relay.js +239 -0
- package/dist-host/cost/attribution.js +183 -0
- package/dist-host/cost/audit.js +163 -0
- package/dist-host/cost/budget.js +211 -0
- package/dist-host/cost/bus.js +95 -0
- package/dist-host/cost/cert-chain.js +100 -0
- package/dist-host/cost/experiment.js +132 -0
- package/dist-host/cost/fang-relay.js +329 -0
- package/dist-host/cost/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +345 -0
- package/dist-host/cost/ledger-report.js +193 -0
- package/dist-host/cost/ledger.js +401 -0
- package/dist-host/cost/metrics.js +198 -0
- package/dist-host/cost/milestones.js +209 -0
- package/dist-host/cost/modules/index.js +5 -0
- package/dist-host/cost/modules/manifests.js +130 -0
- package/dist-host/cost/policy.js +276 -0
- package/dist-host/cost/report.js +156 -0
- package/dist-host/cost/reuse-report.js +128 -0
- package/dist-host/cost/rsi.js +367 -0
- package/dist-host/cost/toolface.js +147 -0
- package/dist-host/cost/types.js +31 -0
- package/dist-host/kernel.js +28 -2
- package/dist-host/plugins/cost-optimizer.js +329 -27
- package/dist-host/plugins/superagent-router.js +104 -1
- package/dist-host/superagent/types.js +8 -0
- package/dist-worker/agent-loop.js +449 -7
- package/dist-worker/baseline-fit.js +66 -0
- package/dist-worker/budget-guard.js +93 -0
- package/dist-worker/ccs.js +99 -0
- package/dist-worker/cost-hooks.js +115 -0
- package/dist-worker/cost-modules.js +330 -0
- package/dist-worker/cost-spi.js +3 -0
- package/dist-worker/edg.js +372 -0
- package/dist-worker/gdr.js +159 -0
- package/dist-worker/index.js +80 -2
- package/dist-worker/llm-http.js +20 -0
- package/dist-worker/sink-http.js +1 -0
- package/dist-worker/spill.js +115 -0
- package/dist-worker/tool-batch.js +66 -0
- package/dist-worker/tool-compact.js +275 -0
- package/dist-worker/tool-disclosure.js +90 -0
- package/dist-worker/tools.js +16 -0
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +66 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1058 -0
- package/docs/EXPERIMENT-M5-fang-relay.md +117 -0
- package/docs/EXPERIMENT-M5-real-ab.md +87 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +2 -2
- package/src/contract/client.js +7 -0
- package/src/contract/constants.js +17 -2
- package/src/contract/index.js +1 -0
- package/src/contract/relay.js +223 -0
- package/src/cost/attribution.ts +186 -0
- package/src/cost/audit.ts +170 -0
- package/src/cost/budget.ts +226 -0
- package/src/cost/bus.ts +97 -0
- package/src/cost/cert-chain.ts +102 -0
- package/src/cost/experiment.ts +136 -0
- package/src/cost/fang-relay.ts +318 -0
- package/src/cost/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +348 -0
- package/src/cost/ledger-report.ts +186 -0
- package/src/cost/ledger.ts +385 -0
- package/src/cost/metrics.ts +193 -0
- package/src/cost/milestones.ts +206 -0
- package/src/cost/modules/index.ts +13 -0
- package/src/cost/modules/manifests.ts +129 -0
- package/src/cost/policy.ts +276 -0
- package/src/cost/report.ts +158 -0
- package/src/cost/reuse-report.ts +131 -0
- package/src/cost/rsi.ts +364 -0
- package/src/cost/toolface.ts +151 -0
- package/src/cost/types.ts +298 -0
- package/src/kernel.ts +27 -2
- package/src/plugins/cost-optimizer.ts +313 -26
- package/src/plugins/superagent-router.ts +96 -1
- package/src/superagent/types.ts +70 -0
- package/worker/agent-loop.ts +444 -7
- package/worker/baseline-fit.ts +80 -0
- package/worker/budget-guard.ts +156 -0
- package/worker/ccs.ts +202 -0
- package/worker/cost-hooks.ts +133 -0
- package/worker/cost-modules.ts +356 -0
- package/worker/cost-spi.ts +283 -0
- package/worker/edg.ts +424 -0
- package/worker/gdr.ts +233 -0
- package/worker/index.ts +84 -3
- package/worker/llm-http.ts +18 -0
- package/worker/sink-http.ts +5 -2
- package/worker/spill.ts +133 -0
- package/worker/tool-batch.ts +124 -0
- package/worker/tool-compact.ts +317 -0
- package/worker/tool-disclosure.ts +131 -0
- package/worker/tools.ts +553 -531
- package/worker/types.ts +191 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
/**
|
|
4
|
+
* 3.3.0 · B1 `edg`(可执行决策图,设计 §7)。
|
|
5
|
+
*
|
|
6
|
+
* 一条边 = 「精确动作 + 确定性参数来源 + 守卫」,这是 HarnessMM 的核心资产:
|
|
7
|
+
* 慢模型只需在**作者步骤**写一次,之后由守卫命中直接复用。
|
|
8
|
+
*
|
|
9
|
+
* 硬约束(写死在实现里):
|
|
10
|
+
* * `schemaHash` 与当前工具 schema 不一致 ⇒ **整图作废**(不得复用旧决策);
|
|
11
|
+
* * 参数只能来自回执的**确定性提取**(literal / regex / jsonpath)——
|
|
12
|
+
* 解析不出来就直接判定该边不可用(**禁止模型幻觉参数**);
|
|
13
|
+
* * `destructive: true` 的边**默认不入池**(validate 直接拒);
|
|
14
|
+
* * 生命周期 `draft → validated → active → exhausted → archived`,非法跃迁一律拒绝;
|
|
15
|
+
* * 图来源两种:慢模型作者步骤(`parseDraftFromModel`)或人工/AI 预置模板库。
|
|
16
|
+
*
|
|
17
|
+
* 作者成本**单独记**:`phase:'authoring'` + `account:'governance'`(由宿主记账,见 cost-optimizer)。
|
|
18
|
+
*/
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
/** 当前工具 schema 的稳定哈希(tools 列表变化即整图作废)。 */
|
|
21
|
+
export function schemaHashOf(toolName, parameters, description) {
|
|
22
|
+
const body = JSON.stringify({ toolName, parameters: parameters ?? {}, description: description ?? '' });
|
|
23
|
+
return `sha256:${crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)}`;
|
|
24
|
+
}
|
|
25
|
+
const tokensOf = (text) => (text.length > 0 ? Math.ceil(text.length / 4) : 0);
|
|
26
|
+
/* ------------------------------------------------------------------ *
|
|
27
|
+
* 确定性提取(禁止幻觉参数)
|
|
28
|
+
* ------------------------------------------------------------------ */
|
|
29
|
+
/** 极简 JSONPath:`$.a.b` / `$.a[0].b`(够用即止,不做完整实现)。 */
|
|
30
|
+
export function jsonPathGet(value, expr) {
|
|
31
|
+
const path = String(expr ?? '').trim();
|
|
32
|
+
if (!path.startsWith('$'))
|
|
33
|
+
return undefined;
|
|
34
|
+
const segments = [];
|
|
35
|
+
const body = path.slice(1);
|
|
36
|
+
const re = /\.([A-Za-z_$][\w$]*)|\[(\d+)\]/gu;
|
|
37
|
+
let match;
|
|
38
|
+
let consumed = 0;
|
|
39
|
+
while ((match = re.exec(body)) !== null) {
|
|
40
|
+
if (match.index !== consumed)
|
|
41
|
+
return undefined;
|
|
42
|
+
consumed = match.index + match[0].length;
|
|
43
|
+
segments.push(match[2] === undefined ? match[1] : Number(match[2]));
|
|
44
|
+
}
|
|
45
|
+
if (consumed !== body.length)
|
|
46
|
+
return undefined;
|
|
47
|
+
let current = value;
|
|
48
|
+
for (const segment of segments) {
|
|
49
|
+
if (current === null || current === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (typeof segment === 'number') {
|
|
52
|
+
if (!Array.isArray(current))
|
|
53
|
+
return undefined;
|
|
54
|
+
current = current[segment];
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (typeof current !== 'object')
|
|
58
|
+
return undefined;
|
|
59
|
+
current = current[segment];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
/** 把回执文本按需解析成 JSON(失败返回 undefined,不猜)。 */
|
|
65
|
+
function asJson(text) {
|
|
66
|
+
const raw = String(text ?? '');
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch { /* 继续尝试提取 JSON 片段 */ }
|
|
71
|
+
// 回执常带前缀(如 shell 的 STDOUT: 或说明行):在**确定性位置**截取首个 { 到末个 }
|
|
72
|
+
// 再解析。这仍然是确定性的文本提取,不是猜测。
|
|
73
|
+
const start = raw.indexOf('{');
|
|
74
|
+
const end = raw.lastIndexOf('}');
|
|
75
|
+
if (start < 0 || end <= start)
|
|
76
|
+
return undefined;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 解析单个 argSource。**失败返回 undefined**(调用方据此判定该边不可用),
|
|
86
|
+
* 绝不回退到"模型给的参数"或默认值。
|
|
87
|
+
*/
|
|
88
|
+
export function resolveArg(source, receiptText) {
|
|
89
|
+
const expr = String(source?.expr ?? '');
|
|
90
|
+
if (source?.from === 'literal') {
|
|
91
|
+
// literal 就是字面量本身(不做变量替换,避免"看起来像模板"的幻觉空间)
|
|
92
|
+
return expr;
|
|
93
|
+
}
|
|
94
|
+
if (source?.from === 'regex') {
|
|
95
|
+
// expr 形如 `/(?<v>...)/` 或裸正则;取第一个捕获组,没有捕获组则取整个匹配。
|
|
96
|
+
const trimmed = expr.trim();
|
|
97
|
+
const body = trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0
|
|
98
|
+
? trimmed.slice(1, trimmed.lastIndexOf('/'))
|
|
99
|
+
: trimmed;
|
|
100
|
+
let re;
|
|
101
|
+
try {
|
|
102
|
+
re = new RegExp(body, 'u');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const match = re.exec(receiptText);
|
|
108
|
+
if (!match)
|
|
109
|
+
return undefined;
|
|
110
|
+
return match.groups?.value ?? match[1] ?? match[0];
|
|
111
|
+
}
|
|
112
|
+
if (source?.from === 'jsonpath') {
|
|
113
|
+
const json = asJson(receiptText);
|
|
114
|
+
if (json === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
return jsonPathGet(json, expr);
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/** 解析整条边的参数;任一参数解析失败 ⇒ null(该边不可用于本次回执)。 */
|
|
121
|
+
export function resolveEdgeArgs(edge, receiptText) {
|
|
122
|
+
const args = {};
|
|
123
|
+
for (const source of edge.argSource) {
|
|
124
|
+
const value = resolveArg(source, receiptText);
|
|
125
|
+
if (value === undefined)
|
|
126
|
+
return null;
|
|
127
|
+
args[source.arg] = value;
|
|
128
|
+
}
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
131
|
+
/* ------------------------------------------------------------------ *
|
|
132
|
+
* 守卫
|
|
133
|
+
* ------------------------------------------------------------------ */
|
|
134
|
+
/** 守卫求值:命中返回 true。表达式非法 ⇒ false(宁可不命中,也不误替换)。 */
|
|
135
|
+
export function evaluateGuard(guard, receiptText) {
|
|
136
|
+
if (!guard || typeof guard !== 'object')
|
|
137
|
+
return false;
|
|
138
|
+
let result = false;
|
|
139
|
+
if (guard.kind === 'regex') {
|
|
140
|
+
const trimmed = String(guard.expr ?? '').trim();
|
|
141
|
+
const body = trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0
|
|
142
|
+
? trimmed.slice(1, trimmed.lastIndexOf('/'))
|
|
143
|
+
: trimmed;
|
|
144
|
+
try {
|
|
145
|
+
result = new RegExp(body, 'u').test(receiptText);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (guard.kind === 'equals') {
|
|
152
|
+
result = receiptText.trim() === String(guard.expr ?? '').trim();
|
|
153
|
+
}
|
|
154
|
+
else if (guard.kind === 'jsonpath') {
|
|
155
|
+
const json = asJson(receiptText);
|
|
156
|
+
if (json === undefined)
|
|
157
|
+
return false;
|
|
158
|
+
const value = jsonPathGet(json, String(guard.expr ?? ''));
|
|
159
|
+
result = value !== undefined && value !== null && value !== false && value !== '' && value !== 0;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return guard.negate === true ? !result : result;
|
|
165
|
+
}
|
|
166
|
+
/* ------------------------------------------------------------------ *
|
|
167
|
+
* 图 store 与生命周期
|
|
168
|
+
* ------------------------------------------------------------------ */
|
|
169
|
+
const LEGAL_TRANSITIONS = {
|
|
170
|
+
draft: ['validated', 'archived'],
|
|
171
|
+
validated: ['active', 'archived'],
|
|
172
|
+
active: ['exhausted', 'archived'],
|
|
173
|
+
exhausted: ['archived', 'active'],
|
|
174
|
+
archived: ['active'],
|
|
175
|
+
};
|
|
176
|
+
export function createEdgStore(options = {}) {
|
|
177
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
178
|
+
let seq = 0;
|
|
179
|
+
const idFactory = typeof options.idFactory === 'function' ? options.idFactory : () => `edg_${now().toString(36)}_${(seq += 1).toString(36)}`;
|
|
180
|
+
const graphs = new Map();
|
|
181
|
+
const clone = (graph) => Object.freeze({ ...graph, edges: graph.edges.map((edge) => Object.freeze({ ...edge, argSource: edge.argSource.map((item) => ({ ...item })) })) });
|
|
182
|
+
function transition(graph, to, reason) {
|
|
183
|
+
if (!LEGAL_TRANSITIONS[graph.state].includes(to)) {
|
|
184
|
+
return { ok: false, state: graph.state, reason: `illegal-transition:${graph.state}->${to}` };
|
|
185
|
+
}
|
|
186
|
+
graph.state = to;
|
|
187
|
+
if (reason !== undefined)
|
|
188
|
+
graph.reason = reason;
|
|
189
|
+
return { ok: true, state: graph.state };
|
|
190
|
+
}
|
|
191
|
+
function addDraft(input) {
|
|
192
|
+
const edges = (Array.isArray(input?.edges) ? input.edges : []).map((edge, index) => Object.freeze({
|
|
193
|
+
id: String(edge?.id ?? `edge_${index + 1}`),
|
|
194
|
+
toolName: String(edge?.toolName ?? ''),
|
|
195
|
+
schemaHash: String(edge?.schemaHash ?? ''),
|
|
196
|
+
argSource: Array.isArray(edge?.argSource) ? edge.argSource.map((item) => Object.freeze({ arg: String(item?.arg ?? ''), from: String(item?.from ?? 'literal'), expr: String(item?.expr ?? '') })) : [],
|
|
197
|
+
guard: Object.freeze({
|
|
198
|
+
kind: String(edge?.guard?.kind ?? 'regex'),
|
|
199
|
+
expr: String(edge?.guard?.expr ?? ''),
|
|
200
|
+
negate: edge?.guard?.negate === true,
|
|
201
|
+
}),
|
|
202
|
+
destructive: edge?.destructive === true,
|
|
203
|
+
dependsOn: Array.isArray(edge?.dependsOn) ? edge.dependsOn.map((item) => String(item)) : undefined,
|
|
204
|
+
}));
|
|
205
|
+
const graph = {
|
|
206
|
+
id: idFactory(),
|
|
207
|
+
taskId: String(input?.taskId ?? ''),
|
|
208
|
+
createdAt: now(),
|
|
209
|
+
source: input?.source === 'template' ? 'template' : 'author',
|
|
210
|
+
state: 'draft',
|
|
211
|
+
model: input?.model === undefined ? undefined : String(input.model),
|
|
212
|
+
frozen: false,
|
|
213
|
+
edges,
|
|
214
|
+
authorTokens: tokensOf(String(input?.authorText ?? '')),
|
|
215
|
+
};
|
|
216
|
+
graphs.set(graph.id, graph);
|
|
217
|
+
return clone(graph);
|
|
218
|
+
}
|
|
219
|
+
function validate(graphId, opts) {
|
|
220
|
+
const graph = graphs.get(String(graphId));
|
|
221
|
+
if (!graph)
|
|
222
|
+
return { ok: false, state: 'draft', reason: 'graph-not-found' };
|
|
223
|
+
const allowed = Array.isArray(opts?.allowedTools) && opts.allowedTools.length > 0 ? new Set(opts.allowedTools) : null;
|
|
224
|
+
const maxEdges = Math.max(1, Number(opts?.maxEdges ?? 32) || 32);
|
|
225
|
+
const ids = new Set(graph.edges.map((edge) => edge.id));
|
|
226
|
+
for (const edge of graph.edges) {
|
|
227
|
+
if (!edge.toolName)
|
|
228
|
+
return { ok: false, state: graph.state, reason: 'edge-missing-tool' };
|
|
229
|
+
if (allowed && !allowed.has(edge.toolName))
|
|
230
|
+
return { ok: false, state: graph.state, reason: `tool-not-allowed:${edge.toolName}` };
|
|
231
|
+
// schema 校验:拿不到当前哈希 ⇒ 视为不一致(fail-closed)
|
|
232
|
+
const current = opts?.schemas?.[edge.toolName];
|
|
233
|
+
if (!current || current !== edge.schemaHash)
|
|
234
|
+
return { ok: false, state: graph.state, reason: `schema-mismatch:${edge.toolName}` };
|
|
235
|
+
for (const source of edge.argSource) {
|
|
236
|
+
if (!source.arg)
|
|
237
|
+
return { ok: false, state: graph.state, reason: 'arg-source-missing-arg' };
|
|
238
|
+
if (!['literal', 'regex', 'jsonpath'].includes(source.from))
|
|
239
|
+
return { ok: false, state: graph.state, reason: `arg-source-invalid:${source.from}` };
|
|
240
|
+
}
|
|
241
|
+
if (!['regex', 'jsonpath', 'equals'].includes(edge.guard.kind))
|
|
242
|
+
return { ok: false, state: graph.state, reason: `guard-kind-invalid:${edge.guard.kind}` };
|
|
243
|
+
// 破坏性动作默认不入池
|
|
244
|
+
if (edge.destructive && opts?.allowDestructive !== true)
|
|
245
|
+
return { ok: false, state: graph.state, reason: `destructive-not-allowed:${edge.toolName}` };
|
|
246
|
+
for (const dep of edge.dependsOn ?? []) {
|
|
247
|
+
if (!ids.has(dep))
|
|
248
|
+
return { ok: false, state: graph.state, reason: `dependency-missing:${dep}` };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (graph.edges.length === 0)
|
|
252
|
+
return { ok: false, state: graph.state, reason: 'graph-empty' };
|
|
253
|
+
if (graph.edges.length > maxEdges)
|
|
254
|
+
return { ok: false, state: graph.state, reason: 'graph-too-large' };
|
|
255
|
+
return transition(graph, 'validated');
|
|
256
|
+
}
|
|
257
|
+
function activate(graphId) {
|
|
258
|
+
const graph = graphs.get(String(graphId));
|
|
259
|
+
if (!graph)
|
|
260
|
+
return { ok: false, state: 'draft', reason: 'graph-not-found' };
|
|
261
|
+
return transition(graph, 'active');
|
|
262
|
+
}
|
|
263
|
+
function exhaust(graphId, reason) {
|
|
264
|
+
const graph = graphs.get(String(graphId));
|
|
265
|
+
if (!graph)
|
|
266
|
+
return { ok: false, state: 'draft' };
|
|
267
|
+
return transition(graph, 'exhausted', reason);
|
|
268
|
+
}
|
|
269
|
+
function archive(graphId) {
|
|
270
|
+
const graph = graphs.get(String(graphId));
|
|
271
|
+
if (!graph)
|
|
272
|
+
return { ok: false, state: 'draft' };
|
|
273
|
+
return transition(graph, 'archived');
|
|
274
|
+
}
|
|
275
|
+
/** schema 变化 ⇒ 整图作废(有边的 schemaHash 与当前不符即整图归档)。 */
|
|
276
|
+
function invalidateBySchema(schemas) {
|
|
277
|
+
const invalidated = [];
|
|
278
|
+
for (const graph of graphs.values()) {
|
|
279
|
+
if (graph.state === 'archived')
|
|
280
|
+
continue;
|
|
281
|
+
const bad = graph.edges.find((edge) => schemas?.[edge.toolName] !== edge.schemaHash);
|
|
282
|
+
if (!bad)
|
|
283
|
+
continue;
|
|
284
|
+
graph.state = 'archived';
|
|
285
|
+
graph.reason = `schema-drift:${bad.toolName}`;
|
|
286
|
+
invalidated.push(graph.id);
|
|
287
|
+
}
|
|
288
|
+
return invalidated;
|
|
289
|
+
}
|
|
290
|
+
function get(graphId) {
|
|
291
|
+
const graph = graphs.get(String(graphId));
|
|
292
|
+
return graph ? clone(graph) : null;
|
|
293
|
+
}
|
|
294
|
+
function list(state) {
|
|
295
|
+
return [...graphs.values()].filter((graph) => (state === undefined ? true : graph.state === state)).map((graph) => clone(graph));
|
|
296
|
+
}
|
|
297
|
+
function authorCost() {
|
|
298
|
+
let authorTokens = 0;
|
|
299
|
+
let count = 0;
|
|
300
|
+
for (const graph of graphs.values()) {
|
|
301
|
+
if (graph.source !== 'author')
|
|
302
|
+
continue;
|
|
303
|
+
count += 1;
|
|
304
|
+
authorTokens += graph.authorTokens;
|
|
305
|
+
}
|
|
306
|
+
return { graphs: count, authorTokens };
|
|
307
|
+
}
|
|
308
|
+
function reset() {
|
|
309
|
+
graphs.clear();
|
|
310
|
+
}
|
|
311
|
+
return Object.freeze({ addDraft, validate, activate, exhaust, archive, get, list, invalidateBySchema, authorCost, reset });
|
|
312
|
+
}
|
|
313
|
+
/* ------------------------------------------------------------------ *
|
|
314
|
+
* 作者步骤:从慢模型输出里抽候选图(确定性解析,不猜)
|
|
315
|
+
* ------------------------------------------------------------------ */
|
|
316
|
+
/**
|
|
317
|
+
* 从模型输出中抽取 `{ "edges": [...] }` JSON 块。
|
|
318
|
+
* 只接受 `edges` 数组里的合法字段;解析不出来返回 null(**不生成空图、不猜参数**)。
|
|
319
|
+
*/
|
|
320
|
+
export function parseDraftFromModel(text) {
|
|
321
|
+
const raw = String(text ?? '');
|
|
322
|
+
if (raw.trim() === '')
|
|
323
|
+
return null;
|
|
324
|
+
const blocks = [...raw.matchAll(/```(?:json)?\s*([\s\S]*?)```/gu)].map((match) => match[1]);
|
|
325
|
+
const candidates = blocks.length > 0 ? blocks : [raw];
|
|
326
|
+
for (const candidate of candidates) {
|
|
327
|
+
const start = candidate.indexOf('{');
|
|
328
|
+
const end = candidate.lastIndexOf('}');
|
|
329
|
+
if (start < 0 || end <= start)
|
|
330
|
+
continue;
|
|
331
|
+
let parsed;
|
|
332
|
+
try {
|
|
333
|
+
parsed = JSON.parse(candidate.slice(start, end + 1));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!parsed || typeof parsed !== 'object')
|
|
339
|
+
continue;
|
|
340
|
+
const edges = parsed.edges;
|
|
341
|
+
if (!Array.isArray(edges) || edges.length === 0)
|
|
342
|
+
continue;
|
|
343
|
+
const cleaned = edges
|
|
344
|
+
.filter((edge) => Boolean(edge) && typeof edge === 'object')
|
|
345
|
+
.map((edge) => ({
|
|
346
|
+
toolName: String(edge.toolName ?? ''),
|
|
347
|
+
schemaHash: String(edge.schemaHash ?? ''),
|
|
348
|
+
argSource: Array.isArray(edge.argSource) ? edge.argSource : [],
|
|
349
|
+
guard: (edge.guard && typeof edge.guard === 'object' ? edge.guard : { kind: 'regex', expr: '' }),
|
|
350
|
+
destructive: edge.destructive === true,
|
|
351
|
+
dependsOn: Array.isArray(edge.dependsOn) ? edge.dependsOn.map((item) => String(item)) : undefined,
|
|
352
|
+
}))
|
|
353
|
+
.filter((edge) => edge.toolName !== '');
|
|
354
|
+
if (cleaned.length === 0)
|
|
355
|
+
continue;
|
|
356
|
+
return { edges: cleaned, authorText: raw };
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
export function createTemplateLibrary(templates = []) {
|
|
361
|
+
const items = (Array.isArray(templates) ? templates : []).filter((item) => item && typeof item === 'object' && item.id);
|
|
362
|
+
return Object.freeze({
|
|
363
|
+
list: () => items.map((item) => ({ ...item, edges: item.edges.map((edge) => ({ ...edge })) })),
|
|
364
|
+
find: (id) => {
|
|
365
|
+
const found = items.find((item) => item.id === String(id));
|
|
366
|
+
return found ? { ...found, edges: found.edges.map((edge) => ({ ...edge })) } : null;
|
|
367
|
+
},
|
|
368
|
+
forTool: (toolName) => items
|
|
369
|
+
.filter((item) => item.edges.some((edge) => String(edge.toolName ?? '') === String(toolName)))
|
|
370
|
+
.map((item) => ({ ...item, edges: item.edges.map((edge) => ({ ...edge })) })),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
import { evaluateGuard, resolveEdgeArgs } from "./edg.js";
|
|
4
|
+
const EMPTY_REASONS = () => ({
|
|
5
|
+
'no-edge': 0,
|
|
6
|
+
'tool-mismatch': 0,
|
|
7
|
+
'schema-drift': 0,
|
|
8
|
+
'dependency-incomplete': 0,
|
|
9
|
+
'guard-miss': 0,
|
|
10
|
+
'arg-unextractable': 0,
|
|
11
|
+
});
|
|
12
|
+
const EMPTY_STAGES = () => ({
|
|
13
|
+
toolName: 0,
|
|
14
|
+
schemaHash: 0,
|
|
15
|
+
dependencies: 0,
|
|
16
|
+
guard: 0,
|
|
17
|
+
args: 0,
|
|
18
|
+
});
|
|
19
|
+
export function createGdr(options = {}) {
|
|
20
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
21
|
+
let matches = 0;
|
|
22
|
+
let hits = 0;
|
|
23
|
+
let lastAt = null;
|
|
24
|
+
const byReason = EMPTY_REASONS();
|
|
25
|
+
const byStage = EMPTY_STAGES();
|
|
26
|
+
const byTool = new Map();
|
|
27
|
+
function toolRow(toolName) {
|
|
28
|
+
if (!byTool.has(toolName))
|
|
29
|
+
byTool.set(toolName, { matches: 0, hits: 0 });
|
|
30
|
+
return byTool.get(toolName);
|
|
31
|
+
}
|
|
32
|
+
function miss(reason, stage, receiptDigest, toolName) {
|
|
33
|
+
matches += 1;
|
|
34
|
+
byReason[reason] += 1;
|
|
35
|
+
byStage[stage] += 1;
|
|
36
|
+
const row = toolRow(toolName);
|
|
37
|
+
row.matches += 1;
|
|
38
|
+
lastAt = now();
|
|
39
|
+
return { hit: false, reason, stage, receiptDigest, at: lastAt };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 五步匹配。**顺序不可调换**:先便宜的后昂贵的,且 schema 漂移必须早于守卫判定——
|
|
43
|
+
* 否则旧图可能在新 schema 上"命中",那是最危险的误替换。
|
|
44
|
+
*/
|
|
45
|
+
function match(input) {
|
|
46
|
+
const receipt = input?.receipt ?? { toolName: '', text: '', digest: '' };
|
|
47
|
+
const graph = input?.graph;
|
|
48
|
+
const toolName = String(receipt.toolName ?? '');
|
|
49
|
+
const digest = String(receipt.digest ?? '');
|
|
50
|
+
const edges = Array.isArray(graph?.edges) ? graph.edges : [];
|
|
51
|
+
// ① 工具名
|
|
52
|
+
if (edges.length === 0)
|
|
53
|
+
return miss('no-edge', 'toolName', digest, toolName);
|
|
54
|
+
const candidates = edges.filter((edge) => String(edge?.toolName ?? '') === toolName);
|
|
55
|
+
if (candidates.length === 0)
|
|
56
|
+
return miss('tool-mismatch', 'toolName', digest, toolName);
|
|
57
|
+
const completed = input?.completed instanceof Set
|
|
58
|
+
? input.completed
|
|
59
|
+
: new Set(Array.isArray(input?.completed) ? input.completed : []);
|
|
60
|
+
// 逐条候选走完 ②③④⑤;任一条全过即命中。
|
|
61
|
+
let firstMiss = null;
|
|
62
|
+
for (const edge of candidates) {
|
|
63
|
+
// ② schemaHash:漂移即整图作废(不匹配就直接 miss,绝不继续)
|
|
64
|
+
const current = typeof input?.schemaHashOf === 'function' ? input.schemaHashOf(edge.toolName) : undefined;
|
|
65
|
+
if (current !== undefined && current !== edge.schemaHash) {
|
|
66
|
+
if (!firstMiss)
|
|
67
|
+
firstMiss = { reason: 'schema-drift', stage: 'schemaHash' };
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// ③ 依赖边完成
|
|
71
|
+
const deps = Array.isArray(edge.dependsOn) ? edge.dependsOn : [];
|
|
72
|
+
if (deps.some((dep) => !completed.has(String(dep)))) {
|
|
73
|
+
if (!firstMiss)
|
|
74
|
+
firstMiss = { reason: 'dependency-incomplete', stage: 'dependencies' };
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// ④ 守卫表达式
|
|
78
|
+
if (!evaluateGuard(edge.guard, String(receipt.text ?? ''))) {
|
|
79
|
+
if (!firstMiss)
|
|
80
|
+
firstMiss = { reason: 'guard-miss', stage: 'guard' };
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// ⑤ 参数可确定性提取
|
|
84
|
+
const args = resolveEdgeArgs(edge, String(receipt.text ?? ''));
|
|
85
|
+
if (args === null) {
|
|
86
|
+
if (!firstMiss)
|
|
87
|
+
firstMiss = { reason: 'arg-unextractable', stage: 'args' };
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// 五步全过 ⇒ 命中
|
|
91
|
+
matches += 1;
|
|
92
|
+
hits += 1;
|
|
93
|
+
const row = toolRow(toolName);
|
|
94
|
+
row.matches += 1;
|
|
95
|
+
row.hits += 1;
|
|
96
|
+
lastAt = now();
|
|
97
|
+
const wouldSaveTokens = typeof input?.estimateSavedTokens === 'function'
|
|
98
|
+
? Math.max(0, Number(input.estimateSavedTokens(edge, args)) || 0)
|
|
99
|
+
: 0;
|
|
100
|
+
return {
|
|
101
|
+
hit: true,
|
|
102
|
+
edgeId: edge.id,
|
|
103
|
+
stage: null,
|
|
104
|
+
receiptDigest: digest,
|
|
105
|
+
args,
|
|
106
|
+
wouldSaveTokens,
|
|
107
|
+
at: lastAt,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const reason = firstMiss ?? { reason: 'guard-miss', stage: 'guard' };
|
|
111
|
+
return miss(reason.reason, reason.stage, digest, toolName);
|
|
112
|
+
}
|
|
113
|
+
function stats() {
|
|
114
|
+
return {
|
|
115
|
+
matches,
|
|
116
|
+
hits,
|
|
117
|
+
hitRate: matches > 0 ? hits / matches : 0,
|
|
118
|
+
byReason: { ...byReason },
|
|
119
|
+
byTool: Object.fromEntries([...byTool.entries()].map(([tool, row]) => [tool, { ...row }])),
|
|
120
|
+
byStage: { ...byStage },
|
|
121
|
+
lastAt,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** 报表 + 可执行的改进建议(喂给 RSI:哪一环最该优化)。 */
|
|
125
|
+
function report() {
|
|
126
|
+
const current = stats();
|
|
127
|
+
const suggestions = [];
|
|
128
|
+
if (current.matches === 0)
|
|
129
|
+
suggestions.push('样本为空:先跑 shadow 累积回执再评估');
|
|
130
|
+
if (current.byReason['schema-drift'] > 0) {
|
|
131
|
+
suggestions.push(`schema-drift ${current.byReason['schema-drift']} 次:优先复算 schemaHash 并回收旧图`);
|
|
132
|
+
}
|
|
133
|
+
if (current.byReason['guard-miss'] > 0) {
|
|
134
|
+
suggestions.push(`guard-miss ${current.byReason['guard-miss']} 次:守卫表达式过严或回执形态变化`);
|
|
135
|
+
}
|
|
136
|
+
if (current.byReason['arg-unextractable'] > 0) {
|
|
137
|
+
suggestions.push(`arg-unextractable ${current.byReason['arg-unextractable']} 次:参数来源应改为 jsonpath/regex 可命中的字段`);
|
|
138
|
+
}
|
|
139
|
+
if (current.byReason['dependency-incomplete'] > 0) {
|
|
140
|
+
suggestions.push(`dependency-incomplete ${current.byReason['dependency-incomplete']} 次:依赖链过长,考虑拆分图`);
|
|
141
|
+
}
|
|
142
|
+
if (current.byReason['tool-mismatch'] > 0) {
|
|
143
|
+
suggestions.push(`tool-mismatch ${current.byReason['tool-mismatch']} 次:图里缺少该工具的边`);
|
|
144
|
+
}
|
|
145
|
+
if (current.matches >= 20 && current.hitRate < 0.5) {
|
|
146
|
+
suggestions.push(`命中率 ${(current.hitRate * 100).toFixed(1)}% 偏低:建议先扩充图或放宽守卫`);
|
|
147
|
+
}
|
|
148
|
+
return { stats: current, suggestions };
|
|
149
|
+
}
|
|
150
|
+
function reset() {
|
|
151
|
+
matches = 0;
|
|
152
|
+
hits = 0;
|
|
153
|
+
lastAt = null;
|
|
154
|
+
Object.assign(byReason, EMPTY_REASONS());
|
|
155
|
+
Object.assign(byStage, EMPTY_STAGES());
|
|
156
|
+
byTool.clear();
|
|
157
|
+
}
|
|
158
|
+
return Object.freeze({ match, stats, report, reset });
|
|
159
|
+
}
|
package/dist-worker/index.js
CHANGED
|
@@ -98,6 +98,7 @@ export function buildWorkerDeps(options = {}) {
|
|
|
98
98
|
limits: options.limits,
|
|
99
99
|
model: String(options.model ?? env.WORKER_LLM_MODEL ?? '') || undefined,
|
|
100
100
|
redactValues: secretValuesOf(env),
|
|
101
|
+
compactMode: String(env.DSH_MASTER_COMPACT_MODE ?? '') || undefined,
|
|
101
102
|
},
|
|
102
103
|
};
|
|
103
104
|
}
|
|
@@ -125,6 +126,10 @@ function cordisSink(ctx) {
|
|
|
125
126
|
ctx.emit?.('superagent:stream', delta);
|
|
126
127
|
}
|
|
127
128
|
catch { /* ignore */ } },
|
|
129
|
+
cost: (event) => { try {
|
|
130
|
+
ctx.emit?.('superagent:cost', event);
|
|
131
|
+
}
|
|
132
|
+
catch { /* ignore */ } },
|
|
128
133
|
};
|
|
129
134
|
}
|
|
130
135
|
/** 组合 sink:Cordis 事件 + Router HTTP 实时通道(任一失败不影响另一个)。 */
|
|
@@ -147,7 +152,7 @@ function composeSink(local, remote) {
|
|
|
147
152
|
catch { /* ignore */ }
|
|
148
153
|
});
|
|
149
154
|
};
|
|
150
|
-
return { log: wrap('log'), step: wrap('step'), phase: wrap('phase'), stream: wrap('stream') };
|
|
155
|
+
return { log: wrap('log'), step: wrap('step'), phase: wrap('phase'), stream: wrap('stream'), cost: wrap('cost') };
|
|
151
156
|
}
|
|
152
157
|
/** 归档真实 token 用量到 fork 内的 HarnessMM observe 服务;无该服务时不影响 Worker。 */
|
|
153
158
|
export function recordWorkerCost(ctx, ticketId, result) {
|
|
@@ -156,8 +161,81 @@ export function recordWorkerCost(ctx, ticketId, result) {
|
|
|
156
161
|
if (!cost || typeof cost.record !== 'function')
|
|
157
162
|
return { ok: false, code: 'cost-service-unavailable' };
|
|
158
163
|
const tokens = Math.max(0, Number(result.tokenUsage ?? 0) || 0);
|
|
164
|
+
const estimatedTokens = Math.max(0, Number(result.cost?.tokens?.estimated ?? 0) || 0);
|
|
159
165
|
// 尚未接入 EGD/GDR 优化,优化前后相等,绝不虚报节省;只记录真实 token 与成功率。
|
|
160
|
-
|
|
166
|
+
// §5.1:实测与估算分别入账(estimatedTokens 由 Agent Loop 的兜底估算给出)。
|
|
167
|
+
const recorded = cost.record({
|
|
168
|
+
taskId: ticketId,
|
|
169
|
+
baselineTokens: tokens,
|
|
170
|
+
optimizedTokens: tokens,
|
|
171
|
+
estimatedTokens,
|
|
172
|
+
estimated: estimatedTokens > 0 && estimatedTokens >= tokens,
|
|
173
|
+
success: result.ok === true,
|
|
174
|
+
});
|
|
175
|
+
// 3.3.0 M2:A1 紧凑化的归因单独上报(shadow 只记「本可节省」,enforce 才入账)。
|
|
176
|
+
const compaction = result.cost?.compaction;
|
|
177
|
+
let attribution = null;
|
|
178
|
+
if (compaction && compaction.estimatedTokens > 0 && typeof cost.recordAttribution === 'function') {
|
|
179
|
+
attribution = cost.recordAttribution({
|
|
180
|
+
taskId: ticketId,
|
|
181
|
+
module: 'a1-compact',
|
|
182
|
+
savedTokens: compaction.estimatedTokens,
|
|
183
|
+
mode: compaction.mode,
|
|
184
|
+
meta: {
|
|
185
|
+
evaluated: compaction.evaluated,
|
|
186
|
+
applied: compaction.applied,
|
|
187
|
+
originalChars: compaction.originalChars,
|
|
188
|
+
compactChars: compaction.compactChars,
|
|
189
|
+
savedChars: compaction.savedChars,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// 目标 2.1-5:降级必须留痕(审计 + 事件总线),且绝不折算成节省。
|
|
194
|
+
let degraded = null;
|
|
195
|
+
const degradedEvents = result.cost?.degraded?.events ?? [];
|
|
196
|
+
const recordDegraded = cost.recordDegraded;
|
|
197
|
+
if (degradedEvents.length > 0 && typeof recordDegraded === 'function') {
|
|
198
|
+
degraded = degradedEvents.map((event) => recordDegraded({
|
|
199
|
+
taskId: ticketId,
|
|
200
|
+
module: event.module,
|
|
201
|
+
phase: event.phase,
|
|
202
|
+
message: event.message,
|
|
203
|
+
count: event.count,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
// §6 A2:工具装载成本记 governance(与 execution 节省分开,报表并列)。
|
|
207
|
+
let governance = null;
|
|
208
|
+
const disclosure = result.cost?.disclosure;
|
|
209
|
+
const recordGovernance = cost.recordGovernance;
|
|
210
|
+
if (disclosure && disclosure.loadEvents > 0 && disclosure.schemaTokens > 0 && typeof recordGovernance === 'function') {
|
|
211
|
+
governance = recordGovernance({
|
|
212
|
+
taskId: ticketId,
|
|
213
|
+
module: 'a2-disclosure',
|
|
214
|
+
tokens: disclosure.schemaTokens,
|
|
215
|
+
kind: 'tools.load',
|
|
216
|
+
meta: { loadEvents: disclosure.loadEvents, schemaCount: disclosure.schemaCount, loaded: disclosure.loaded },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// §7 B3:替换证书上报宿主(审计链 + 账本,用同一 taskId+step 关联)。
|
|
220
|
+
let substitutions = null;
|
|
221
|
+
const certs = result.cost?.ccs?.substitutions ?? [];
|
|
222
|
+
const recordSubstitution = cost.recordSubstitution;
|
|
223
|
+
if (certs.length > 0 && typeof recordSubstitution === 'function') {
|
|
224
|
+
substitutions = certs.map((cert) => recordSubstitution({
|
|
225
|
+
edgeId: cert.edgeId,
|
|
226
|
+
toolName: cert.toolName,
|
|
227
|
+
schemaHash: cert.schemaHash,
|
|
228
|
+
guardExpr: cert.guardExpr,
|
|
229
|
+
receiptDigest: cert.receiptDigest,
|
|
230
|
+
replacedCallbackId: cert.replacedCallbackId,
|
|
231
|
+
viaNativeDispatcher: cert.viaNativeDispatcher,
|
|
232
|
+
taskId: cert.taskId || ticketId,
|
|
233
|
+
step: cert.step,
|
|
234
|
+
savedTokens: cert.savedTokens ?? 0,
|
|
235
|
+
moduleId: cert.moduleId ?? 'b3-ccs',
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
return { recorded, attribution, degraded, governance, substitutions };
|
|
161
239
|
}
|
|
162
240
|
catch (error) {
|
|
163
241
|
return { ok: false, code: 'cost-record-failed', error: String(error instanceof Error ? error.message : error) };
|
package/dist-worker/llm-http.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
/**
|
|
4
|
+
* §11:从 provider 的 usage 里读缓存命中量。
|
|
5
|
+
* 兼容 `prompt_tokens_details.cached_tokens`(OpenAI 风格)与顶层 `cached_tokens`。
|
|
6
|
+
* **拿不到就返回 undefined**,不写 0 —— 否则报表会把"未知"伪装成"没有缓存命中"。
|
|
7
|
+
*/
|
|
8
|
+
function readCachedTokens(usage) {
|
|
9
|
+
if (!usage || typeof usage !== 'object')
|
|
10
|
+
return undefined;
|
|
11
|
+
const details = usage.prompt_tokens_details;
|
|
12
|
+
const direct = usage.cached_tokens;
|
|
13
|
+
const raw = details?.cached_tokens ?? direct;
|
|
14
|
+
if (raw === undefined || raw === null)
|
|
15
|
+
return undefined;
|
|
16
|
+
const value = Number(raw);
|
|
17
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
18
|
+
}
|
|
3
19
|
function endpointOf(baseUrl) {
|
|
4
20
|
const trimmed = String(baseUrl ?? '').trim().replace(/\/+$/, '');
|
|
5
21
|
if (!trimmed)
|
|
@@ -49,6 +65,8 @@ function fromWireResponse(payload) {
|
|
|
49
65
|
promptTokens: Number(payload.usage.prompt_tokens ?? 0),
|
|
50
66
|
completionTokens: Number(payload.usage.completion_tokens ?? 0),
|
|
51
67
|
totalTokens: Number(payload.usage.total_tokens ?? 0),
|
|
68
|
+
// §11:prompt cache 命中量(未返回就省略,不冒充 0 节省)。
|
|
69
|
+
...(readCachedTokens(payload.usage) === undefined ? {} : { cachedTokens: readCachedTokens(payload.usage) }),
|
|
52
70
|
}
|
|
53
71
|
: undefined,
|
|
54
72
|
finishReason: choice?.finish_reason,
|
|
@@ -111,10 +129,12 @@ function createStreamAccumulator(onDelta) {
|
|
|
111
129
|
finishReason = choice.finish_reason;
|
|
112
130
|
if (chunk?.usage && typeof chunk.usage === 'object') {
|
|
113
131
|
const raw = chunk.usage;
|
|
132
|
+
const cached = readCachedTokens(raw);
|
|
114
133
|
usage = {
|
|
115
134
|
promptTokens: Number(raw.prompt_tokens ?? 0),
|
|
116
135
|
completionTokens: Number(raw.completion_tokens ?? 0),
|
|
117
136
|
totalTokens: Number(raw.total_tokens ?? 0),
|
|
137
|
+
...(cached === undefined ? {} : { cachedTokens: cached }),
|
|
118
138
|
};
|
|
119
139
|
}
|
|
120
140
|
return Boolean(chunk?.choices || chunk?.usage);
|