@twinsearth/dsh-master 3.2.10 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +3 -0
  2. package/client.js +50 -0
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +28 -2
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +88 -1
  27. package/dist-host/superagent/types.js +8 -0
  28. package/dist-worker/agent-loop.js +449 -7
  29. package/dist-worker/baseline-fit.js +66 -0
  30. package/dist-worker/budget-guard.js +93 -0
  31. package/dist-worker/ccs.js +99 -0
  32. package/dist-worker/cost-hooks.js +115 -0
  33. package/dist-worker/cost-modules.js +330 -0
  34. package/dist-worker/cost-spi.js +3 -0
  35. package/dist-worker/edg.js +372 -0
  36. package/dist-worker/gdr.js +159 -0
  37. package/dist-worker/index.js +80 -2
  38. package/dist-worker/llm-http.js +20 -0
  39. package/dist-worker/sink-http.js +1 -0
  40. package/dist-worker/spill.js +115 -0
  41. package/dist-worker/tool-batch.js +66 -0
  42. package/dist-worker/tool-compact.js +275 -0
  43. package/dist-worker/tool-disclosure.js +90 -0
  44. package/dist-worker/tools.js +16 -0
  45. package/docs/ARCHITECTURE.md +21 -0
  46. package/docs/CHANGELOG.md +50 -0
  47. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  48. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  49. package/docs/THIRD-PARTY.md +87 -0
  50. package/package.json +1 -1
  51. package/src/cost/attribution.ts +186 -0
  52. package/src/cost/audit.ts +170 -0
  53. package/src/cost/budget.ts +226 -0
  54. package/src/cost/bus.ts +97 -0
  55. package/src/cost/cert-chain.ts +102 -0
  56. package/src/cost/experiment.ts +136 -0
  57. package/src/cost/gate.ts +191 -0
  58. package/src/cost/host-surface.ts +97 -0
  59. package/src/cost/index.ts +316 -0
  60. package/src/cost/ledger-report.ts +186 -0
  61. package/src/cost/ledger.ts +385 -0
  62. package/src/cost/metrics.ts +193 -0
  63. package/src/cost/milestones.ts +170 -0
  64. package/src/cost/modules/index.ts +13 -0
  65. package/src/cost/modules/manifests.ts +129 -0
  66. package/src/cost/policy.ts +276 -0
  67. package/src/cost/report.ts +158 -0
  68. package/src/cost/reuse-report.ts +131 -0
  69. package/src/cost/rsi.ts +364 -0
  70. package/src/cost/toolface.ts +151 -0
  71. package/src/cost/types.ts +298 -0
  72. package/src/kernel.ts +27 -2
  73. package/src/plugins/cost-optimizer.ts +271 -26
  74. package/src/plugins/superagent-router.ts +82 -1
  75. package/src/superagent/types.ts +70 -0
  76. package/worker/agent-loop.ts +444 -7
  77. package/worker/baseline-fit.ts +80 -0
  78. package/worker/budget-guard.ts +156 -0
  79. package/worker/ccs.ts +202 -0
  80. package/worker/cost-hooks.ts +133 -0
  81. package/worker/cost-modules.ts +356 -0
  82. package/worker/cost-spi.ts +283 -0
  83. package/worker/edg.ts +424 -0
  84. package/worker/gdr.ts +233 -0
  85. package/worker/index.ts +84 -3
  86. package/worker/llm-http.ts +18 -0
  87. package/worker/sink-http.ts +5 -2
  88. package/worker/spill.ts +133 -0
  89. package/worker/tool-batch.ts +124 -0
  90. package/worker/tool-compact.ts +317 -0
  91. package/worker/tool-disclosure.ts +131 -0
  92. package/worker/tools.ts +553 -531
  93. package/worker/types.ts +191 -0
@@ -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
+ }