@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.
- package/README.md +3 -0
- package/client.js +50 -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/gate.js +189 -0
- package/dist-host/cost/host-surface.js +80 -0
- package/dist-host/cost/index.js +312 -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 +171 -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 +277 -27
- package/dist-host/plugins/superagent-router.js +88 -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 +21 -0
- package/docs/CHANGELOG.md +50 -0
- package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
- package/docs/EXPERIMENT-M5-real-ab.md +82 -0
- package/docs/THIRD-PARTY.md +87 -0
- package/package.json +1 -1
- 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/gate.ts +191 -0
- package/src/cost/host-surface.ts +97 -0
- package/src/cost/index.ts +316 -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 +170 -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 +271 -26
- package/src/plugins/superagent-router.ts +82 -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,186 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* 3.3.0 · M1 记账报表(`ledger-report`)。
|
|
7
|
+
*
|
|
8
|
+
* 交付物「记账报表」的实体:把账本事件整理成**可读、可核对**的报表,
|
|
9
|
+
* 并且遵守前几节定下的口径纪律:
|
|
10
|
+
*
|
|
11
|
+
* * **估算 ≠ 实测**:两列分开给,`estimatedRatio` 显式展示,不混算;
|
|
12
|
+
* * **两本账不合并**:execution 与 governance 分账,`netSaved` 只在能对上时给;
|
|
13
|
+
* * **外部面单列**:宿主面外部插件自报收益永不并入本项目节省;
|
|
14
|
+
* * **unattributed 必须留位**:归因缺口显式展示为 0 或非 0,不允许"消失";
|
|
15
|
+
* * **非法行不入账**:JSONL 里坏行跳过并计数,不静默丢弃也不假装成功。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { COST_MODULES, COST_PHASES, COST_SURFACES } from './types.ts'
|
|
19
|
+
|
|
20
|
+
const num = (value) => {
|
|
21
|
+
const n = Number(value)
|
|
22
|
+
return Number.isFinite(n) ? n : 0
|
|
23
|
+
}
|
|
24
|
+
const nonNegative = (value) => Math.max(0, num(value))
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 解析落盘的 JSONL 账本(`cost-ledger.jsonl`,逐行追加)。
|
|
28
|
+
* 坏行跳过并计数 —— 报表必须能说明"有多少行没读进来"。
|
|
29
|
+
* @param {string} text
|
|
30
|
+
*/
|
|
31
|
+
export function parseLedgerJsonl(text) {
|
|
32
|
+
const entries = []
|
|
33
|
+
let skipped = 0
|
|
34
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
35
|
+
const trimmed = line.trim()
|
|
36
|
+
if (trimmed === '') continue
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(trimmed)
|
|
39
|
+
if (parsed && typeof parsed === 'object' && (parsed.account === 'execution' || parsed.account === 'governance')) entries.push(parsed)
|
|
40
|
+
else skipped += 1
|
|
41
|
+
} catch {
|
|
42
|
+
skipped += 1
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { entries, skipped }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const emptyRow = () => ({ entries: 0, inputTokens: 0, outputTokens: 0, total: 0, savedTokens: 0, estimated: false })
|
|
49
|
+
function addTo(map, key, entry) {
|
|
50
|
+
if (!map.has(key)) map.set(key, { ...emptyRow(), key })
|
|
51
|
+
const row = map.get(key)
|
|
52
|
+
row.entries += 1
|
|
53
|
+
row.inputTokens += nonNegative(entry.inputTokens)
|
|
54
|
+
row.outputTokens += nonNegative(entry.outputTokens)
|
|
55
|
+
row.total += nonNegative(entry.total)
|
|
56
|
+
row.savedTokens += nonNegative(entry.savedTokens)
|
|
57
|
+
if (entry.estimated === true) row.estimated = true
|
|
58
|
+
return row
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 记账报表。
|
|
63
|
+
*
|
|
64
|
+
* @param {{
|
|
65
|
+
* entries?: object[],
|
|
66
|
+
* from?: number,
|
|
67
|
+
* to?: number,
|
|
68
|
+
* bucketMs?: number,
|
|
69
|
+
* }} [input]
|
|
70
|
+
*/
|
|
71
|
+
export function buildLedgerReport(input = {}) {
|
|
72
|
+
const from = Number.isFinite(Number(input.from)) ? Number(input.from) : null
|
|
73
|
+
const to = Number.isFinite(Number(input.to)) ? Number(input.to) : null
|
|
74
|
+
const bucketMs = Math.max(1, Number(input.bucketMs ?? 60 * 60 * 1000) || 60 * 60 * 1000)
|
|
75
|
+
const raw = Array.isArray(input.entries) ? input.entries : []
|
|
76
|
+
|
|
77
|
+
const byPhase = new Map()
|
|
78
|
+
const byModule = new Map()
|
|
79
|
+
const byTask = new Map()
|
|
80
|
+
const bySurface = new Map()
|
|
81
|
+
const accounts = {
|
|
82
|
+
execution: { entries: 0, total: 0, savedTokens: 0 },
|
|
83
|
+
governance: { entries: 0, total: 0, savedTokens: 0 },
|
|
84
|
+
}
|
|
85
|
+
const tokens = { measuredTokens: 0, estimatedTokens: 0, inputTokens: 0, outputTokens: 0 }
|
|
86
|
+
const buckets = new Map()
|
|
87
|
+
let ownExecutionSaved = 0
|
|
88
|
+
let externalSaved = 0
|
|
89
|
+
let filtered = 0
|
|
90
|
+
let invalid = 0
|
|
91
|
+
|
|
92
|
+
for (const entry of raw) {
|
|
93
|
+
if (!entry || (entry.account !== 'execution' && entry.account !== 'governance')) { invalid += 1; continue }
|
|
94
|
+
const at = num(entry.at)
|
|
95
|
+
if (from !== null && at < from) { filtered += 1; continue }
|
|
96
|
+
if (to !== null && at > to) { filtered += 1; continue }
|
|
97
|
+
|
|
98
|
+
const account = accounts[entry.account]
|
|
99
|
+
account.entries += 1
|
|
100
|
+
account.total += nonNegative(entry.total)
|
|
101
|
+
|
|
102
|
+
const total = nonNegative(entry.total)
|
|
103
|
+
const saved = nonNegative(entry.savedTokens)
|
|
104
|
+
if (entry.estimated === true) tokens.estimatedTokens += total
|
|
105
|
+
else tokens.measuredTokens += total
|
|
106
|
+
tokens.inputTokens += nonNegative(entry.inputTokens)
|
|
107
|
+
tokens.outputTokens += nonNegative(entry.outputTokens)
|
|
108
|
+
|
|
109
|
+
if (entry.account === 'execution' && saved > 0) {
|
|
110
|
+
if (entry.external === true) externalSaved += saved
|
|
111
|
+
else ownExecutionSaved += saved
|
|
112
|
+
}
|
|
113
|
+
account.savedTokens += saved
|
|
114
|
+
|
|
115
|
+
addTo(byPhase, String(entry.phase ?? 'unknown'), entry)
|
|
116
|
+
addTo(byModule, String(entry.module ?? 'unattributed'), entry)
|
|
117
|
+
addTo(byTask, String(entry.taskId ?? 'unattributed'), entry)
|
|
118
|
+
addTo(bySurface, String(entry.surface ?? 'worker'), entry)
|
|
119
|
+
|
|
120
|
+
const bucketKey = Math.floor(at / bucketMs) * bucketMs
|
|
121
|
+
if (!buckets.has(bucketKey)) buckets.set(bucketKey, { at: bucketKey, entries: 0, total: 0, savedTokens: 0, estimatedTokens: 0 })
|
|
122
|
+
const bucket = buckets.get(bucketKey)
|
|
123
|
+
bucket.entries += 1
|
|
124
|
+
bucket.total += total
|
|
125
|
+
bucket.savedTokens += saved
|
|
126
|
+
if (entry.estimated === true) bucket.estimatedTokens += total
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 固定列:模块与相位都按枚举铺满,缺的给 0(报表要能一眼看出"这一项是 0"而不是"没这一列")
|
|
130
|
+
const phaseRows = COST_PHASES.map((phase) => byPhase.get(phase) ?? { ...emptyRow(), key: phase })
|
|
131
|
+
const moduleRows = COST_MODULES.map((module) => byModule.get(module) ?? { ...emptyRow(), key: module })
|
|
132
|
+
const surfaceRows = COST_SURFACES.map((surface) => bySurface.get(surface) ?? { ...emptyRow(), key: surface })
|
|
133
|
+
const taskRows = [...byTask.values()].sort((a, b) => b.total - a.total || a.key.localeCompare(b.key))
|
|
134
|
+
|
|
135
|
+
const allTokens = tokens.measuredTokens + tokens.estimatedTokens
|
|
136
|
+
return Object.freeze({
|
|
137
|
+
ok: true,
|
|
138
|
+
window: Object.freeze({ from, to, bucketMs }),
|
|
139
|
+
counts: Object.freeze({ entries: accounts.execution.entries + accounts.governance.entries, invalid, filtered }),
|
|
140
|
+
accounts: Object.freeze({
|
|
141
|
+
execution: Object.freeze({ ...accounts.execution }),
|
|
142
|
+
governance: Object.freeze({ ...accounts.governance }),
|
|
143
|
+
/** 两本账相减只在 execution 为「本项目(非外部)」口径时成立。 */
|
|
144
|
+
netSaved: ownExecutionSaved - accounts.governance.total,
|
|
145
|
+
}),
|
|
146
|
+
tokens: Object.freeze({ ...tokens, estimatedRatio: allTokens > 0 ? tokens.estimatedTokens / allTokens : 0 }),
|
|
147
|
+
savings: Object.freeze({
|
|
148
|
+
ownExecutionSaved,
|
|
149
|
+
/** 外部面收益:只展示,不计入本项目净节省。 */
|
|
150
|
+
externalSaved,
|
|
151
|
+
}),
|
|
152
|
+
byPhase: Object.freeze(phaseRows.map((row) => Object.freeze(row))),
|
|
153
|
+
byModule: Object.freeze(moduleRows.map((row) => Object.freeze(row))),
|
|
154
|
+
bySurface: Object.freeze(surfaceRows.map((row) => Object.freeze(row))),
|
|
155
|
+
byTask: Object.freeze(taskRows.map((row) => Object.freeze(row))),
|
|
156
|
+
timeline: Object.freeze([...buckets.values()].sort((a, b) => a.at - b.at).map((row) => Object.freeze(row))),
|
|
157
|
+
/** 记账口径声明,供报表阅读者核对(写进报表而不是只写在文档里)。 */
|
|
158
|
+
discipline: Object.freeze({
|
|
159
|
+
estimatedAndMeasuredSeparated: true,
|
|
160
|
+
accountsNotMerged: true,
|
|
161
|
+
externalNeverSummed: true,
|
|
162
|
+
unattributedAlwaysListed: true,
|
|
163
|
+
}),
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 人读行(CLI / 面板共用,避免两处格式分叉)。 */
|
|
168
|
+
export function formatLedgerReport(report) {
|
|
169
|
+
const lines = []
|
|
170
|
+
lines.push(`记账报表 窗口 ${report.window.from ?? '-'} → ${report.window.to ?? '-'}`)
|
|
171
|
+
lines.push(`事件 ${report.counts.entries} 条(非法 ${report.counts.invalid},被窗口过滤 ${report.counts.filtered})`)
|
|
172
|
+
lines.push('')
|
|
173
|
+
lines.push('账本 条数 token 节省')
|
|
174
|
+
for (const [name, row] of Object.entries(report.accounts)) {
|
|
175
|
+
if (name === 'netSaved') continue
|
|
176
|
+
lines.push(`${name.padEnd(14)} ${String(row.entries).padEnd(8)} ${String(row.total).padEnd(10)} ${row.savedTokens}`)
|
|
177
|
+
}
|
|
178
|
+
lines.push(`净节省(本项目) ${report.accounts.netSaved}`)
|
|
179
|
+
lines.push(`外部面收益(不并入) ${report.savings.externalSaved}`)
|
|
180
|
+
lines.push('')
|
|
181
|
+
lines.push(`估算占比 ${(report.tokens.estimatedRatio * 100).toFixed(1)}%(实测 ${report.tokens.measuredTokens} / 估算 ${report.tokens.estimatedTokens})`)
|
|
182
|
+
lines.push('')
|
|
183
|
+
lines.push('按模块 token 节省')
|
|
184
|
+
for (const row of report.byModule) lines.push(`${row.key.padEnd(16)} ${String(row.total).padEnd(10)} ${row.savedTokens}`)
|
|
185
|
+
return lines.join('\n')
|
|
186
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* 3.3.0 系统层 · ledger(计量账本,设计 §5.1)。
|
|
7
|
+
*
|
|
8
|
+
* 按**事件**记账(不是按聚合),每个事件带 task/fork/session/turn/step/phase,
|
|
9
|
+
* 因此可回放、可按维度查询。
|
|
10
|
+
*
|
|
11
|
+
* 口径纪律:
|
|
12
|
+
* * 两本账 `execution` / `governance` 分开;`netSaved` 只减 governance;
|
|
13
|
+
* * 作用面 `worker` / `host` 分开,外部适配器(`external`)永不计入本项目节省;
|
|
14
|
+
* * 估算 token(provider 未返回 usage 的兜底)标记 `estimated: true`,
|
|
15
|
+
* 报表中与实测**分列**、不相加。
|
|
16
|
+
*
|
|
17
|
+
* 存储:进程内环形缓冲(有界)+ 可选 JSONL 落盘(`<dataDir>/cost-ledger.jsonl`)。
|
|
18
|
+
* 选择「逐行追加」而不是整文件重写:账本只增不改,单行 append 是 O(1);
|
|
19
|
+
* 整文件 tmp+rename(ticket-store 的风格)会让每次记账变成 O(n),账本量大时不可接受。
|
|
20
|
+
* 追加失败不影响主流程,只累加 `failed` 计数(成本记账不得打断执行)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
|
|
26
|
+
import { COST_MODULES, COST_SURFACES, COST_PHASES } from './types.ts'
|
|
27
|
+
|
|
28
|
+
/** source → phase 的缺省推导(调用方也可显式给 phase)。 */
|
|
29
|
+
const PHASE_OF_SOURCE = Object.freeze({
|
|
30
|
+
model: 'completion',
|
|
31
|
+
tool: 'tool',
|
|
32
|
+
substitution: 'substitution',
|
|
33
|
+
retry: 'prompt',
|
|
34
|
+
authoring: 'authoring',
|
|
35
|
+
offline: 'authoring',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const LEDGER_FILE = 'cost-ledger.jsonl'
|
|
39
|
+
|
|
40
|
+
function finiteTokens(value) {
|
|
41
|
+
const n = Number(value)
|
|
42
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyBlock() {
|
|
46
|
+
return { input: 0, output: 0, total: 0, saved: 0, entries: 0 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addToBlock(block, entry) {
|
|
50
|
+
block.input += entry.input
|
|
51
|
+
block.output += entry.output
|
|
52
|
+
block.total += entry.total
|
|
53
|
+
block.saved += entry.savedTokens
|
|
54
|
+
block.entries += 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const str = (value) => (value === undefined || value === null ? null : String(value))
|
|
58
|
+
const int = (value, fallback = 0) => {
|
|
59
|
+
const n = Number(value)
|
|
60
|
+
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {{
|
|
65
|
+
* now?: () => number, maxEntries?: number, maxTasks?: number,
|
|
66
|
+
* dataDir?: string, persist?: boolean, logger?: { warn?: Function },
|
|
67
|
+
* }} [options]
|
|
68
|
+
*/
|
|
69
|
+
export function createLedger(options = {}) {
|
|
70
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
71
|
+
const maxEntries = Math.max(1, Number(options.maxEntries ?? 5000) || 5000)
|
|
72
|
+
const maxTasks = Math.max(1, Number(options.maxTasks ?? 200) || 200)
|
|
73
|
+
const dataDir = options.dataDir === undefined || options.dataDir === null ? '' : String(options.dataDir).trim()
|
|
74
|
+
const persistEnabled = options.persist === true && dataDir !== ''
|
|
75
|
+
const file = persistEnabled ? path.join(dataDir, LEDGER_FILE) : null
|
|
76
|
+
let persistedAppends = 0
|
|
77
|
+
let persistedFailures = 0
|
|
78
|
+
|
|
79
|
+
const entries = []
|
|
80
|
+
const totals = { execution: emptyBlock(), governance: emptyBlock() }
|
|
81
|
+
const byModule = new Map()
|
|
82
|
+
const byTask = new Map()
|
|
83
|
+
const byFork = new Map()
|
|
84
|
+
/** 按作用面拆分(worker = 本项目自研;host = 外部适配器,只展示不并入)。 */
|
|
85
|
+
const bySurface = new Map()
|
|
86
|
+
/** 本项目自己的执行节省(worker 面、非外部)与外部适配器自报节省(分开累计)。 */
|
|
87
|
+
let ownExecutionSaved = 0
|
|
88
|
+
let externalExecutionSaved = 0
|
|
89
|
+
/** 实测 vs 估算(不混算)。 */
|
|
90
|
+
const estimateSplit = { measuredTokens: 0, measuredEntries: 0, estimatedTokens: 0, estimatedEntries: 0 }
|
|
91
|
+
let dropped = 0
|
|
92
|
+
let seq = 0
|
|
93
|
+
|
|
94
|
+
const moduleRow = (module) => {
|
|
95
|
+
if (!byModule.has(module)) byModule.set(module, emptyBlock())
|
|
96
|
+
return byModule.get(module)
|
|
97
|
+
}
|
|
98
|
+
const taskRow = (key) => {
|
|
99
|
+
if (!byTask.has(key)) byTask.set(key, emptyBlock())
|
|
100
|
+
return byTask.get(key)
|
|
101
|
+
}
|
|
102
|
+
const forkRow = (key) => {
|
|
103
|
+
if (!byFork.has(key)) byFork.set(key, emptyBlock())
|
|
104
|
+
return byFork.get(key)
|
|
105
|
+
}
|
|
106
|
+
const surfaceRow = (surface, external) => {
|
|
107
|
+
const key = `${surface}|${external ? 'external' : 'own'}`
|
|
108
|
+
if (!bySurface.has(key)) bySurface.set(key, { ...emptyBlock(), surface, external })
|
|
109
|
+
return bySurface.get(key)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 单行追加;失败只计数,不影响主流程。回放期间不重复落盘。 */
|
|
113
|
+
let replaying = false
|
|
114
|
+
function persistLine(entry) {
|
|
115
|
+
if (!file || replaying) return
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
118
|
+
const line = `${JSON.stringify({ v: 1, ...entry })}\n`
|
|
119
|
+
// 单次 append 调用:账本只增不改,避免整文件重写。
|
|
120
|
+
fs.appendFileSync(file, line, 'utf8')
|
|
121
|
+
persistedAppends += 1
|
|
122
|
+
} catch (error) {
|
|
123
|
+
persistedFailures += 1
|
|
124
|
+
try { options.logger?.warn?.(`[cost-ledger] append failed: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function append(input = {}) {
|
|
129
|
+
const account = String(input.account ?? '')
|
|
130
|
+
if (account !== 'execution' && account !== 'governance') {
|
|
131
|
+
return { ok: false, code: 'account-invalid', message: 'account must be execution or governance' }
|
|
132
|
+
}
|
|
133
|
+
const source = input.source === undefined ? 'model' : String(input.source)
|
|
134
|
+
if (!Object.prototype.hasOwnProperty.call(PHASE_OF_SOURCE, source)) {
|
|
135
|
+
return { ok: false, code: 'source-invalid', message: `source must be one of ${Object.keys(PHASE_OF_SOURCE).join('/')}` }
|
|
136
|
+
}
|
|
137
|
+
const phase = input.phase === undefined ? PHASE_OF_SOURCE[source] : String(input.phase)
|
|
138
|
+
if (!COST_PHASES.includes(phase)) {
|
|
139
|
+
return { ok: false, code: 'phase-invalid', message: `phase must be one of ${COST_PHASES.join('/')}` }
|
|
140
|
+
}
|
|
141
|
+
const module = input.module === undefined ? 'unattributed' : String(input.module)
|
|
142
|
+
if (!COST_MODULES.includes(module)) {
|
|
143
|
+
return { ok: false, code: 'module-invalid', message: `module must be one of ${COST_MODULES.join('/')}` }
|
|
144
|
+
}
|
|
145
|
+
const surface = input.surface === undefined ? 'worker' : String(input.surface)
|
|
146
|
+
if (!COST_SURFACES.includes(surface)) {
|
|
147
|
+
return { ok: false, code: 'surface-invalid', message: `surface must be one of ${COST_SURFACES.join('/')}` }
|
|
148
|
+
}
|
|
149
|
+
const external = input.external === true
|
|
150
|
+
const inputTokens = finiteTokens(input.inputTokens ?? 0)
|
|
151
|
+
const outputTokens = finiteTokens(input.outputTokens ?? 0)
|
|
152
|
+
const cachedTokens = finiteTokens(input.cachedTokens ?? 0)
|
|
153
|
+
if (inputTokens === null) return { ok: false, code: 'input-tokens-invalid', message: 'inputTokens must be a non-negative number' }
|
|
154
|
+
if (outputTokens === null) return { ok: false, code: 'output-tokens-invalid', message: 'outputTokens must be a non-negative number' }
|
|
155
|
+
if (cachedTokens === null) return { ok: false, code: 'cached-tokens-invalid', message: 'cachedTokens must be a non-negative number' }
|
|
156
|
+
const totalInput = input.totalTokens === undefined ? inputTokens + outputTokens : finiteTokens(input.totalTokens)
|
|
157
|
+
if (totalInput === null) return { ok: false, code: 'total-tokens-invalid', message: 'totalTokens must be a non-negative number' }
|
|
158
|
+
if (totalInput === 0 && inputTokens + outputTokens > 0) {
|
|
159
|
+
return { ok: false, code: 'total-tokens-invalid', message: 'totalTokens is zero while input/output tokens are not' }
|
|
160
|
+
}
|
|
161
|
+
const baseline = input.baselineTokens === undefined ? null : finiteTokens(input.baselineTokens)
|
|
162
|
+
if (baseline === null && input.baselineTokens !== undefined) {
|
|
163
|
+
return { ok: false, code: 'baseline-tokens-invalid', message: 'baselineTokens must be a non-negative number' }
|
|
164
|
+
}
|
|
165
|
+
const explicitSaved = input.savedTokens === undefined ? null : finiteTokens(input.savedTokens)
|
|
166
|
+
if (explicitSaved === null && input.savedTokens !== undefined) {
|
|
167
|
+
return { ok: false, code: 'saved-tokens-invalid', message: 'savedTokens must be a non-negative number' }
|
|
168
|
+
}
|
|
169
|
+
// 显式 savedTokens 优先(归因条目 total=0,只能由调用方给出节省);
|
|
170
|
+
// 只有 baseline 时按 baseline - total 推导;两者都没有则 0。
|
|
171
|
+
const savedTokens = explicitSaved !== null
|
|
172
|
+
? explicitSaved
|
|
173
|
+
: (baseline === null ? 0 : Math.max(0, baseline - totalInput))
|
|
174
|
+
const estimated = input.estimated === true
|
|
175
|
+
|
|
176
|
+
const at = now()
|
|
177
|
+
seq += 1
|
|
178
|
+
const entry = Object.freeze({
|
|
179
|
+
id: `cost_${at.toString(36)}_${seq.toString(36)}`,
|
|
180
|
+
at,
|
|
181
|
+
account,
|
|
182
|
+
module,
|
|
183
|
+
source,
|
|
184
|
+
phase,
|
|
185
|
+
surface,
|
|
186
|
+
external,
|
|
187
|
+
taskId: str(input.taskId),
|
|
188
|
+
forkId: str(input.forkId),
|
|
189
|
+
sessionId: str(input.sessionId),
|
|
190
|
+
ticketId: str(input.ticketId),
|
|
191
|
+
turn: int(input.turn),
|
|
192
|
+
step: int(input.step),
|
|
193
|
+
provider: str(input.provider),
|
|
194
|
+
model: str(input.model),
|
|
195
|
+
toolName: str(input.toolName),
|
|
196
|
+
input: inputTokens,
|
|
197
|
+
output: outputTokens,
|
|
198
|
+
cached: cachedTokens,
|
|
199
|
+
total: totalInput,
|
|
200
|
+
baselineTokens: baseline,
|
|
201
|
+
savedTokens,
|
|
202
|
+
estimated,
|
|
203
|
+
meta: Object.freeze({ ...(input.meta ?? {}) }),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
entries.push(entry)
|
|
207
|
+
if (entries.length > maxEntries) {
|
|
208
|
+
entries.shift()
|
|
209
|
+
dropped += 1
|
|
210
|
+
}
|
|
211
|
+
addToBlock(totals[account], entry)
|
|
212
|
+
addToBlock(moduleRow(module), entry)
|
|
213
|
+
addToBlock(surfaceRow(surface, external), entry)
|
|
214
|
+
addToBlock(forkRow(entry.forkId ?? 'unattributed'), entry)
|
|
215
|
+
const taskKey = entry.taskId ?? entry.ticketId ?? 'unattributed'
|
|
216
|
+
addToBlock(taskRow(taskKey), entry)
|
|
217
|
+
while (byTask.size > maxTasks) {
|
|
218
|
+
const oldest = byTask.keys().next().value
|
|
219
|
+
byTask.delete(oldest)
|
|
220
|
+
}
|
|
221
|
+
if (account === 'execution') {
|
|
222
|
+
if (external) externalExecutionSaved += savedTokens
|
|
223
|
+
else if (surface === 'worker') ownExecutionSaved += savedTokens
|
|
224
|
+
}
|
|
225
|
+
if (estimated) {
|
|
226
|
+
estimateSplit.estimatedTokens += totalInput
|
|
227
|
+
estimateSplit.estimatedEntries += 1
|
|
228
|
+
} else {
|
|
229
|
+
estimateSplit.measuredTokens += totalInput
|
|
230
|
+
estimateSplit.measuredEntries += 1
|
|
231
|
+
}
|
|
232
|
+
persistLine(entry)
|
|
233
|
+
return { ok: true, entry }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function snapshot() {
|
|
237
|
+
const execution = Object.freeze({ ...totals.execution })
|
|
238
|
+
const governance = Object.freeze({ ...totals.governance })
|
|
239
|
+
const bySurfaceRows = [...bySurface.values()].map((row) => Object.freeze({ ...row })).sort((a, b) => b.total - a.total)
|
|
240
|
+
const byModuleRows = [...byModule.entries()].map(([module, block]) => Object.freeze({ module, ...block })).sort((a, b) => b.total - a.total)
|
|
241
|
+
const byTaskRows = [...byTask.entries()].map(([key, block]) => Object.freeze({ key, ...block })).sort((a, b) => b.total - a.total)
|
|
242
|
+
const byForkRows = [...byFork.entries()].map(([forkId, block]) => Object.freeze({ forkId, ...block })).sort((a, b) => b.total - a.total)
|
|
243
|
+
const allTokens = estimateSplit.measuredTokens + estimateSplit.estimatedTokens
|
|
244
|
+
return Object.freeze({
|
|
245
|
+
ok: true,
|
|
246
|
+
count: entries.length,
|
|
247
|
+
dropped,
|
|
248
|
+
execution,
|
|
249
|
+
governance,
|
|
250
|
+
/** 只算本项目(worker 面)的执行节省减去治理成本;外部适配器不参与。 */
|
|
251
|
+
netSaved: ownExecutionSaved - governance.total,
|
|
252
|
+
ownExecutionSaved,
|
|
253
|
+
externalSaved: externalExecutionSaved,
|
|
254
|
+
bySurface: bySurfaceRows,
|
|
255
|
+
byModule: byModuleRows,
|
|
256
|
+
byTask: byTaskRows,
|
|
257
|
+
byFork: byForkRows,
|
|
258
|
+
tokens: Object.freeze({
|
|
259
|
+
...estimateSplit,
|
|
260
|
+
estimatedRatio: allTokens > 0 ? estimateSplit.estimatedTokens / allTokens : 0,
|
|
261
|
+
}),
|
|
262
|
+
persistence: Object.freeze({
|
|
263
|
+
enabled: persistEnabled,
|
|
264
|
+
file,
|
|
265
|
+
appended: persistedAppends,
|
|
266
|
+
failed: persistedFailures,
|
|
267
|
+
}),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 按维度查询(设计 §5.1):task / fork / session / module / account / phase / 时间窗。
|
|
273
|
+
* 返回最近 limit 条(按时间正序)。
|
|
274
|
+
*/
|
|
275
|
+
function query(filter = {}) {
|
|
276
|
+
const limit = Math.max(1, Number(filter.limit ?? 200) || 200)
|
|
277
|
+
const matches = entries.filter((entry) => {
|
|
278
|
+
if (filter.taskId !== undefined && entry.taskId !== String(filter.taskId)) return false
|
|
279
|
+
if (filter.forkId !== undefined && entry.forkId !== String(filter.forkId)) return false
|
|
280
|
+
if (filter.sessionId !== undefined && entry.sessionId !== String(filter.sessionId)) return false
|
|
281
|
+
if (filter.ticketId !== undefined && entry.ticketId !== String(filter.ticketId)) return false
|
|
282
|
+
if (filter.module !== undefined && entry.module !== String(filter.module)) return false
|
|
283
|
+
if (filter.account !== undefined && entry.account !== String(filter.account)) return false
|
|
284
|
+
if (filter.phase !== undefined && entry.phase !== String(filter.phase)) return false
|
|
285
|
+
if (filter.surface !== undefined && entry.surface !== String(filter.surface)) return false
|
|
286
|
+
if (filter.external !== undefined && entry.external !== filter.external) return false
|
|
287
|
+
if (filter.estimated !== undefined && entry.estimated !== filter.estimated) return false
|
|
288
|
+
if (filter.from !== undefined && entry.at < Number(filter.from)) return false
|
|
289
|
+
if (filter.to !== undefined && entry.at > Number(filter.to)) return false
|
|
290
|
+
return true
|
|
291
|
+
})
|
|
292
|
+
const rows = matches.slice(-limit)
|
|
293
|
+
return Object.freeze({
|
|
294
|
+
ok: true,
|
|
295
|
+
matched: matches.length,
|
|
296
|
+
returned: rows.length,
|
|
297
|
+
tokens: rows.reduce((sum, entry) => sum + entry.total, 0),
|
|
298
|
+
saved: rows.reduce((sum, entry) => sum + entry.savedTokens, 0),
|
|
299
|
+
entries: rows.slice(),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 从 JSONL 重新装载(进程重启后回放);只读文件,不改内存中的既有事件。 */
|
|
304
|
+
function replay(direction = 'forward') {
|
|
305
|
+
if (!file) return { ok: false, code: 'persistence-disabled', message: 'ledger has no dataDir' }
|
|
306
|
+
let text
|
|
307
|
+
try {
|
|
308
|
+
text = fs.readFileSync(file, 'utf8')
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error?.code === 'ENOENT') return { ok: true, loaded: 0, skipped: 0 }
|
|
311
|
+
return { ok: false, code: 'read-failed', message: String(error?.message ?? error) }
|
|
312
|
+
}
|
|
313
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '')
|
|
314
|
+
const ordered = direction === 'reverse' ? [...lines].reverse() : lines
|
|
315
|
+
let loaded = 0
|
|
316
|
+
let skipped = 0
|
|
317
|
+
replaying = true
|
|
318
|
+
try {
|
|
319
|
+
for (const line of ordered) {
|
|
320
|
+
try {
|
|
321
|
+
const parsed = JSON.parse(line)
|
|
322
|
+
if (!parsed || typeof parsed !== 'object') { skipped += 1; continue }
|
|
323
|
+
const result = append({
|
|
324
|
+
account: parsed.account,
|
|
325
|
+
module: parsed.module,
|
|
326
|
+
source: parsed.source,
|
|
327
|
+
phase: parsed.phase,
|
|
328
|
+
surface: parsed.surface,
|
|
329
|
+
external: parsed.external,
|
|
330
|
+
taskId: parsed.taskId ?? undefined,
|
|
331
|
+
forkId: parsed.forkId ?? undefined,
|
|
332
|
+
sessionId: parsed.sessionId ?? undefined,
|
|
333
|
+
ticketId: parsed.ticketId ?? undefined,
|
|
334
|
+
turn: parsed.turn,
|
|
335
|
+
step: parsed.step,
|
|
336
|
+
provider: parsed.provider ?? undefined,
|
|
337
|
+
model: parsed.model ?? undefined,
|
|
338
|
+
toolName: parsed.toolName ?? undefined,
|
|
339
|
+
inputTokens: parsed.input,
|
|
340
|
+
outputTokens: parsed.output,
|
|
341
|
+
cachedTokens: parsed.cached,
|
|
342
|
+
totalTokens: parsed.total,
|
|
343
|
+
baselineTokens: parsed.baselineTokens ?? undefined,
|
|
344
|
+
savedTokens: parsed.savedTokens,
|
|
345
|
+
estimated: parsed.estimated,
|
|
346
|
+
meta: parsed.meta,
|
|
347
|
+
})
|
|
348
|
+
if (result.ok) loaded += 1
|
|
349
|
+
else skipped += 1
|
|
350
|
+
} catch {
|
|
351
|
+
skipped += 1
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} finally {
|
|
355
|
+
replaying = false
|
|
356
|
+
}
|
|
357
|
+
return { ok: true, loaded, skipped }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function list(limit = 50) {
|
|
361
|
+
const n = Math.max(1, Number(limit) || 50)
|
|
362
|
+
return entries.slice(-n)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function reset() {
|
|
366
|
+
entries.length = 0
|
|
367
|
+
byModule.clear()
|
|
368
|
+
byTask.clear()
|
|
369
|
+
byFork.clear()
|
|
370
|
+
bySurface.clear()
|
|
371
|
+
Object.assign(totals.execution, emptyBlock())
|
|
372
|
+
Object.assign(totals.governance, emptyBlock())
|
|
373
|
+
ownExecutionSaved = 0
|
|
374
|
+
externalExecutionSaved = 0
|
|
375
|
+
estimateSplit.measuredTokens = 0
|
|
376
|
+
estimateSplit.measuredEntries = 0
|
|
377
|
+
estimateSplit.estimatedTokens = 0
|
|
378
|
+
estimateSplit.estimatedEntries = 0
|
|
379
|
+
dropped = 0
|
|
380
|
+
seq = 0
|
|
381
|
+
return { ok: true }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return Object.freeze({ append, snapshot, query, replay, list, reset })
|
|
385
|
+
}
|