@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
package/README.md
CHANGED
|
@@ -84,12 +84,16 @@ 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.1 发布线**:M5 收口 —— 瓮城回流接口升级到 `@twinsearth/dsh-fang-contract` **1.1.0**(`fang.relay.publish` / `fang.relay.status`),房主侧 `src/cost/fang-relay.ts` 做真实探测与投递(只有对端回执才算送达,不可用给具名原因);里程碑 **M1–M5 全部 done**。详见 [M5 回流实测](docs/EXPERIMENT-M5-fang-relay.md)。
|
|
96
|
+
- **3.3.0 发布线**:成本优化系统层(ledger / budget / policy / audit)+ Worker 四个成本切点 + A1 紧凑化(默认 shadow)+ A2–A3 / B1–B4;真实 A/B 实测 tokens/task 6263 → 3906.75(−37.6%),成功率 0pp 差,详见 [3.3.0 架构设计](docs/DESIGN-v3.3.0-cost-optimization.md)。
|
|
93
97
|
- 3.2.10 发布线:角色矩阵放宽——Router 进入母体(essence/mother),Worker 与 Worker Agent Loop 进入原身(fork/essence);母体仍不接纳执行体,fork 仍不注册 Router。
|
|
94
98
|
- 3.2.9 发布线:房主面板改注册到 `shell.overlay` 常驻浮层——点击面板之外不收起、侧栏「房主」按钮二次点击收起、正文独立滚动;新增无浏览器 hook 运行时行为测试(开关、无 outside-click 监听、滚动容器、SSE 失败不卸载面板)。
|
|
95
99
|
- 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.1' : '未启用', 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 轮换/审计。'),
|
|
@@ -77,6 +77,13 @@ export function createContractClient(options = {}) {
|
|
|
77
77
|
async resolveDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_RESOLVE, params, invokeOptions); },
|
|
78
78
|
async listSessions(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.SESSION_LIST, params, invokeOptions); },
|
|
79
79
|
async orchestratorStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.ORCHESTRATOR_STATUS, params, invokeOptions); },
|
|
80
|
+
/** contract 1.1:批量投递回流事件(按 event.id 幂等;返回回执与游标)。 */
|
|
81
|
+
async publishRelay(events, invokeOptions = {}) {
|
|
82
|
+
const params = Array.isArray(events) ? { events } : (events && typeof events === 'object' ? events : { events: [] });
|
|
83
|
+
return await client.invoke(METHODS.RELAY_PUBLISH, params, invokeOptions);
|
|
84
|
+
},
|
|
85
|
+
/** contract 1.1:读取对端回流状态(对端自报 peer/cursor/累计计数)。 */
|
|
86
|
+
async relayStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.RELAY_STATUS, params, invokeOptions); },
|
|
80
87
|
};
|
|
81
88
|
return client;
|
|
82
89
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* * 删除/改义必须升 MAJOR,并保留至少一个 MINOR 周期的双写(method@1 与 method@2)。
|
|
10
10
|
*/
|
|
11
11
|
export const CONTRACT_PACKAGE = '@twinsearth/dsh-fang-contract';
|
|
12
|
-
export const CONTRACT_VERSION = '1.
|
|
12
|
+
export const CONTRACT_VERSION = '1.1.0';
|
|
13
13
|
export const PROTOCOL = 'fang-master';
|
|
14
14
|
export const PROTOCOL_VERSION = 1;
|
|
15
15
|
export const MIN_COMPATIBLE_MAJOR = 1;
|
|
@@ -24,6 +24,8 @@ export const METHODS = Object.freeze({
|
|
|
24
24
|
DECISION_RESOLVE: 'fang.decision.resolve',
|
|
25
25
|
SESSION_LIST: 'fang.session.list',
|
|
26
26
|
ORCHESTRATOR_STATUS: 'fang.orchestrator.status',
|
|
27
|
+
RELAY_PUBLISH: 'fang.relay.publish',
|
|
28
|
+
RELAY_STATUS: 'fang.relay.status',
|
|
27
29
|
});
|
|
28
30
|
/** 方法 → 契约版本(用于协商后的方法裁剪)。 */
|
|
29
31
|
export const METHOD_VERSION = Object.freeze({
|
|
@@ -36,9 +38,11 @@ export const METHOD_VERSION = Object.freeze({
|
|
|
36
38
|
[METHODS.DECISION_RESOLVE]: '1.0',
|
|
37
39
|
[METHODS.SESSION_LIST]: '1.0',
|
|
38
40
|
[METHODS.ORCHESTRATOR_STATUS]: '1.0',
|
|
41
|
+
[METHODS.RELAY_PUBLISH]: '1.1',
|
|
42
|
+
[METHODS.RELAY_STATUS]: '1.1',
|
|
39
43
|
});
|
|
40
44
|
export const CAPABILITIES = Object.freeze([
|
|
41
|
-
'sync', 'async', 'idempotent', 'stream-events', 'file-outbox',
|
|
45
|
+
'sync', 'async', 'idempotent', 'stream-events', 'file-outbox', 'event-relay',
|
|
42
46
|
]);
|
|
43
47
|
export const ROLES = Object.freeze(['mother', 'essence', 'fork']);
|
|
44
48
|
export const ERROR_CODES = Object.freeze({
|
|
@@ -68,3 +72,13 @@ export const LEGACY_SERVICE_KEYS = Object.freeze({
|
|
|
68
72
|
sessionCatalog: 'fangSessionCatalog',
|
|
69
73
|
orchestrator: 'fang',
|
|
70
74
|
});
|
|
75
|
+
/**
|
|
76
|
+
* 回流接口的硬上限(1.1)。超限是**协议错误**而不是静默截断:
|
|
77
|
+
* 调用方必须自己分批,服务端不得悄悄丢事件(回流要可对账)。
|
|
78
|
+
*/
|
|
79
|
+
export const RELAY_LIMITS = Object.freeze({
|
|
80
|
+
maxBatch: 128,
|
|
81
|
+
maxEventBytes: 32_000,
|
|
82
|
+
maxPayloadBytes: 512_000,
|
|
83
|
+
maxRecords: 5_000,
|
|
84
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
/**
|
|
4
|
+
* 回流(relay)接口 —— contract 1.1 新增能力。
|
|
5
|
+
*
|
|
6
|
+
* 背景:房主(dsh-Master)的成本总线产生 `cost:event` / `cost:degraded` /
|
|
7
|
+
* `cost:substitution` 三类事件,需要**单向**投递给瓮城(dsh-Fang)做产物回流与
|
|
8
|
+
* 信息栏展示。1.0 的方法表只有请求-响应式的 fork/decision/session,缺一个批量、
|
|
9
|
+
* 可去重、有回执的事件入口,因此 1.1 追加两个方法:
|
|
10
|
+
*
|
|
11
|
+
* * `fang.relay.publish` —— 批量投递事件,按 `event.id` 幂等去重,返回回执与游标;
|
|
12
|
+
* * `fang.relay.status` —— 对端自报回流状态(自身标识 peerLabel、游标、累计计数)。
|
|
13
|
+
*
|
|
14
|
+
* 兼容规则(README §2):新增方法只升 MINOR;1.0 的对端在 `hello` 协商时不会
|
|
15
|
+
* 宣告这两个方法,方法集被自动裁剪,调用方据此判定“回流不可用”,**不得**把
|
|
16
|
+
* 裁剪当成失败重试。
|
|
17
|
+
*
|
|
18
|
+
* 本文件只做协议与参考实现:不读写磁盘、不联网、不持有任何凭据。
|
|
19
|
+
*/
|
|
20
|
+
import { CONTRACT_VERSION, METHODS, RELAY_LIMITS } from './constants.js';
|
|
21
|
+
import { createContractServer } from './server.js';
|
|
22
|
+
function nowMs(now) {
|
|
23
|
+
const value = typeof now === 'function' ? now() : Date.now();
|
|
24
|
+
const n = Number(value);
|
|
25
|
+
return Number.isFinite(n) ? n : Date.now();
|
|
26
|
+
}
|
|
27
|
+
function asLimit(value, fallback) {
|
|
28
|
+
const n = Number(value);
|
|
29
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
30
|
+
}
|
|
31
|
+
function byteLength(value) {
|
|
32
|
+
try {
|
|
33
|
+
return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return Number.POSITIVE_INFINITY;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 校验一批回流事件。逐条判定:合法的接受,非法的计 `rejected` 并给原因,
|
|
41
|
+
* 不因单条坏数据丢掉整批(但整批不可用会抛 `relay-events-invalid`)。
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} events
|
|
44
|
+
* @param {{maxBatch?:number, maxEventBytes?:number, maxPayloadBytes?:number}} [limits]
|
|
45
|
+
*/
|
|
46
|
+
export function validateRelayEvents(events, limits = {}) {
|
|
47
|
+
const maxBatch = asLimit(limits.maxBatch, RELAY_LIMITS.maxBatch);
|
|
48
|
+
const maxEventBytes = asLimit(limits.maxEventBytes, RELAY_LIMITS.maxEventBytes);
|
|
49
|
+
const maxPayloadBytes = asLimit(limits.maxPayloadBytes, RELAY_LIMITS.maxPayloadBytes);
|
|
50
|
+
const errors = [];
|
|
51
|
+
if (!Array.isArray(events)) {
|
|
52
|
+
return { ok: false, code: 'relay-events-invalid', errors: ['events-must-be-array'], accepted: [], rejected: [] };
|
|
53
|
+
}
|
|
54
|
+
if (events.length === 0) {
|
|
55
|
+
return { ok: false, code: 'relay-events-invalid', errors: ['events-empty'], accepted: [], rejected: [] };
|
|
56
|
+
}
|
|
57
|
+
if (events.length > maxBatch) {
|
|
58
|
+
return { ok: false, code: 'relay-events-invalid', errors: [`events-too-many(>${maxBatch})`], accepted: [], rejected: [] };
|
|
59
|
+
}
|
|
60
|
+
if (byteLength(events) > maxPayloadBytes) {
|
|
61
|
+
return { ok: false, code: 'relay-events-invalid', errors: [`payload-too-large(>${maxPayloadBytes})`], accepted: [], rejected: [] };
|
|
62
|
+
}
|
|
63
|
+
const accepted = [];
|
|
64
|
+
const rejected = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const raw of events) {
|
|
67
|
+
const item = raw && typeof raw === 'object' ? raw : null;
|
|
68
|
+
if (!item) {
|
|
69
|
+
rejected.push({ id: null, reason: 'event-not-object' });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const id = typeof item.id === 'string' ? item.id.trim() : '';
|
|
73
|
+
if (!id) {
|
|
74
|
+
rejected.push({ id: null, reason: 'id-required' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof item.type !== 'string' || item.type.trim() === '') {
|
|
78
|
+
rejected.push({ id, reason: 'type-required' });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (byteLength(item) > maxEventBytes) {
|
|
82
|
+
rejected.push({ id, reason: `event-too-large(>${maxEventBytes})` });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// 同一批内重复 id 也算重复投递:只接受第一次,其余计 duplicates。
|
|
86
|
+
if (seen.has(id)) {
|
|
87
|
+
rejected.push({ id, reason: 'duplicate-in-batch' });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
seen.add(id);
|
|
91
|
+
accepted.push({
|
|
92
|
+
id,
|
|
93
|
+
type: String(item.type),
|
|
94
|
+
at: Number.isFinite(Number(item.at)) ? Number(item.at) : 0,
|
|
95
|
+
payload: item.payload && typeof item.payload === 'object' ? item.payload : {},
|
|
96
|
+
meta: item.meta && typeof item.meta === 'object' ? item.meta : {},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return { ok: accepted.length > 0 || rejected.length === 0, errors, accepted, rejected };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 回流接收端(瓮城侧)的参考存储:按 event.id 幂等去重,保留有界回执环。
|
|
103
|
+
*
|
|
104
|
+
* @param {{
|
|
105
|
+
* peerLabel?: string, role?: string, instanceId?: string,
|
|
106
|
+
* maxRecords?: number, now?: () => number, limits?: object,
|
|
107
|
+
* }} [options]
|
|
108
|
+
*/
|
|
109
|
+
export function createRelayStore(options = {}) {
|
|
110
|
+
const peerLabel = String(options.peerLabel ?? 'contract-server');
|
|
111
|
+
const role = String(options.role ?? 'mother');
|
|
112
|
+
const instanceId = String(options.instanceId ?? '');
|
|
113
|
+
const maxRecords = asLimit(options.maxRecords, RELAY_LIMITS.maxRecords);
|
|
114
|
+
const limits = { ...RELAY_LIMITS, ...(options.limits && typeof options.limits === 'object' ? options.limits : {}) };
|
|
115
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
116
|
+
const seen = new Map();
|
|
117
|
+
const receipts = [];
|
|
118
|
+
const counters = { accepted: 0, duplicates: 0, rejected: 0, batches: 0 };
|
|
119
|
+
let cursor = 0;
|
|
120
|
+
let lastAt = 0;
|
|
121
|
+
const lastErrors = [];
|
|
122
|
+
function remember(receipt) {
|
|
123
|
+
receipts.push(receipt);
|
|
124
|
+
if (receipts.length > maxRecords)
|
|
125
|
+
receipts.shift();
|
|
126
|
+
}
|
|
127
|
+
/** @param {{events?: unknown, batchId?: string, taskId?: string}} [params] */
|
|
128
|
+
function publish(params = {}) {
|
|
129
|
+
const validated = validateRelayEvents(params?.events, limits);
|
|
130
|
+
if (validated.ok === false) {
|
|
131
|
+
const error = new Error(validated.errors.join(', ') || 'relay-events-invalid');
|
|
132
|
+
error.code = 'relay-events-invalid';
|
|
133
|
+
error.retryable = false;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
counters.batches += 1;
|
|
137
|
+
const acceptedIds = [];
|
|
138
|
+
let duplicates = 0;
|
|
139
|
+
for (const event of validated.accepted) {
|
|
140
|
+
if (seen.has(event.id)) {
|
|
141
|
+
duplicates += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
seen.set(event.id, { at: nowMs(now), cursor: cursor + 1 });
|
|
145
|
+
cursor += 1;
|
|
146
|
+
counters.accepted += 1;
|
|
147
|
+
acceptedIds.push(event.id);
|
|
148
|
+
remember({ id: event.id, type: event.type, at: event.at, cursor });
|
|
149
|
+
lastAt = nowMs(now);
|
|
150
|
+
}
|
|
151
|
+
counters.duplicates += duplicates;
|
|
152
|
+
for (const item of validated.rejected) {
|
|
153
|
+
// 批内重复计入 duplicates;其余非法条目计入 rejected 并留原因(有界)。
|
|
154
|
+
if (item.reason === 'duplicate-in-batch') {
|
|
155
|
+
duplicates += 1;
|
|
156
|
+
counters.duplicates += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
counters.rejected += 1;
|
|
160
|
+
if (lastErrors.length < 50)
|
|
161
|
+
lastErrors.push({ id: item.id, reason: item.reason });
|
|
162
|
+
}
|
|
163
|
+
const batchId = String(params?.batchId ?? `batch-${counters.batches}`);
|
|
164
|
+
const receipt = Object.freeze({
|
|
165
|
+
ok: true,
|
|
166
|
+
batchId,
|
|
167
|
+
taskId: params?.taskId === undefined ? null : String(params.taskId),
|
|
168
|
+
accepted: acceptedIds.length,
|
|
169
|
+
duplicates,
|
|
170
|
+
rejected: validated.rejected.length - duplicates,
|
|
171
|
+
cursor,
|
|
172
|
+
acceptedIds,
|
|
173
|
+
peer: peerLabel,
|
|
174
|
+
at: nowMs(now),
|
|
175
|
+
});
|
|
176
|
+
if (seen.size > maxRecords * 4) {
|
|
177
|
+
// 幂等窗口有界:按插入顺序淘汰最老的一半,避免长跑进程无界增长。
|
|
178
|
+
const overflow = seen.size - maxRecords * 2;
|
|
179
|
+
let removed = 0;
|
|
180
|
+
for (const key of seen.keys()) {
|
|
181
|
+
if (removed >= overflow)
|
|
182
|
+
break;
|
|
183
|
+
seen.delete(key);
|
|
184
|
+
removed += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return receipt;
|
|
188
|
+
}
|
|
189
|
+
function status() {
|
|
190
|
+
return Object.freeze({
|
|
191
|
+
ok: true,
|
|
192
|
+
available: true,
|
|
193
|
+
peer: peerLabel,
|
|
194
|
+
role,
|
|
195
|
+
instanceId,
|
|
196
|
+
contractVersion: CONTRACT_VERSION,
|
|
197
|
+
cursor,
|
|
198
|
+
accepted: counters.accepted,
|
|
199
|
+
duplicates: counters.duplicates,
|
|
200
|
+
rejected: counters.rejected,
|
|
201
|
+
batches: counters.batches,
|
|
202
|
+
lastAt,
|
|
203
|
+
limits: { ...limits },
|
|
204
|
+
lastErrors: [...lastErrors],
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function describe() {
|
|
208
|
+
return Object.freeze({ peerLabel, records: receipts.length, seen: seen.size, ...counters, cursor });
|
|
209
|
+
}
|
|
210
|
+
return Object.freeze({ publish, status, describe, receipts: () => [...receipts] });
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 把回流 store 包成契约 handler 表(挂到 `createContractServer({handlers})`)。
|
|
214
|
+
* @param {{publish:Function, status:Function}} store
|
|
215
|
+
*/
|
|
216
|
+
export function relayHandlers(store) {
|
|
217
|
+
if (!store || typeof store.publish !== 'function' || typeof store.status !== 'function') {
|
|
218
|
+
throw new Error('relayHandlers requires a relay store with publish/status');
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
[METHODS.RELAY_PUBLISH]: (params) => store.publish(params ?? {}),
|
|
222
|
+
[METHODS.RELAY_STATUS]: () => store.status(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 便捷构造:带回流能力的契约服务端(瓮城侧挂载用)。
|
|
227
|
+
* `peerLabel` 是**自报**标识,房主侧据此记录“对端是谁”,不参与鉴权。
|
|
228
|
+
*/
|
|
229
|
+
export function createRelayContractServer(options = {}) {
|
|
230
|
+
const { peerLabel = 'contract-server', role = 'mother', instanceId = '', now, limits, maxRecords, extraHandlers = {}, ...rest } = options;
|
|
231
|
+
const store = createRelayStore({ peerLabel, role, instanceId, now, limits, maxRecords });
|
|
232
|
+
const server = createContractServer({
|
|
233
|
+
role,
|
|
234
|
+
instanceId,
|
|
235
|
+
handlers: { ...relayHandlers(store), ...(extraHandlers && typeof extraHandlers === 'object' ? extraHandlers : {}) },
|
|
236
|
+
...rest,
|
|
237
|
+
});
|
|
238
|
+
return { server, store };
|
|
239
|
+
}
|
|
@@ -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
|
+
}
|