oficina-opencode 2.0.1 → 2.1.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 +164 -14
- package/dist/core/atomic.d.ts +85 -0
- package/dist/core/atomic.d.ts.map +1 -0
- package/dist/core/atomic.js +186 -0
- package/dist/core/atomic.js.map +1 -0
- package/dist/core/bus.d.ts +76 -56
- package/dist/core/bus.d.ts.map +1 -1
- package/dist/core/bus.js +141 -100
- package/dist/core/bus.js.map +1 -1
- package/dist/core/catalog.d.ts +27 -0
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +41 -6
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/cost.d.ts +275 -0
- package/dist/core/cost.d.ts.map +1 -0
- package/dist/core/cost.js +724 -0
- package/dist/core/cost.js.map +1 -0
- package/dist/core/docsync.d.ts +124 -0
- package/dist/core/docsync.d.ts.map +1 -0
- package/dist/core/docsync.js +191 -0
- package/dist/core/docsync.js.map +1 -0
- package/dist/core/evidence.d.ts +314 -0
- package/dist/core/evidence.d.ts.map +1 -0
- package/dist/core/evidence.js +602 -0
- package/dist/core/evidence.js.map +1 -0
- package/dist/core/fsx.d.ts.map +1 -1
- package/dist/core/fsx.js +7 -1
- package/dist/core/fsx.js.map +1 -1
- package/dist/core/gates.d.ts +9 -2
- package/dist/core/gates.d.ts.map +1 -1
- package/dist/core/gates.js +19 -3
- package/dist/core/gates.js.map +1 -1
- package/dist/core/ids.d.ts +48 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +87 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/leases.d.ts +45 -0
- package/dist/core/leases.d.ts.map +1 -1
- package/dist/core/leases.js +85 -17
- package/dist/core/leases.js.map +1 -1
- package/dist/core/office.d.ts +72 -1
- package/dist/core/office.d.ts.map +1 -1
- package/dist/core/office.js +173 -6
- package/dist/core/office.js.map +1 -1
- package/dist/core/paths.d.ts +11 -1
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +28 -4
- package/dist/core/paths.js.map +1 -1
- package/dist/core/recovery.d.ts +87 -0
- package/dist/core/recovery.d.ts.map +1 -0
- package/dist/core/recovery.js +325 -0
- package/dist/core/recovery.js.map +1 -0
- package/dist/core/sandbox.d.ts +179 -0
- package/dist/core/sandbox.d.ts.map +1 -0
- package/dist/core/sandbox.js +546 -0
- package/dist/core/sandbox.js.map +1 -0
- package/dist/core/state.d.ts +7 -0
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +15 -1
- package/dist/core/state.js.map +1 -1
- package/dist/core/stores.d.ts +65 -0
- package/dist/core/stores.d.ts.map +1 -0
- package/dist/core/stores.js +115 -0
- package/dist/core/stores.js.map +1 -0
- package/dist/core/tripwire.d.ts +87 -0
- package/dist/core/tripwire.d.ts.map +1 -0
- package/dist/core/tripwire.js +203 -0
- package/dist/core/tripwire.js.map +1 -0
- package/dist/core/types.d.ts +33 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -1
- package/dist/hooks/scopeGuard.d.ts +86 -13
- package/dist/hooks/scopeGuard.d.ts.map +1 -1
- package/dist/hooks/scopeGuard.js +296 -62
- package/dist/hooks/scopeGuard.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -2
- package/dist/index.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +158 -0
- package/dist/tools/index.js.map +1 -1
- package/docs/COMPATIBILITY.md +98 -0
- package/docs/GARANTIAS.md +190 -0
- package/docs/PLAN-V2.1.md +144 -0
- package/docs/QUE-ES.md +15 -0
- package/docs/catalog-facts.json +27 -0
- package/package.json +6 -2
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control de costo: contabilidad real + presupuesto + corte.
|
|
3
|
+
*
|
|
4
|
+
* Los presupuestos que ya existen (maxCards, maxAgentCalls, maxSpawnsTotal) son
|
|
5
|
+
* presupuestos de CONTEO. Contar agentes no es controlar el costo: un agente
|
|
6
|
+
* que relee el mismo contexto veinte veces puede gastar diez veces más que uno
|
|
7
|
+
* que lo hace bien y a la primera. El límite útil es en dólares y en tokens.
|
|
8
|
+
*
|
|
9
|
+
* La buena noticia: el motor de OpenCode ya expone el costo real. En el evento
|
|
10
|
+
* `message.updated`, el `AssistantMessage` trae `cost: number` y
|
|
11
|
+
* `tokens: { input, output, reasoning, cache: { read, write } }`, más `modelID`,
|
|
12
|
+
* `providerID` y `time: { created, completed }` para la latencia. O sea que
|
|
13
|
+
* nadie tiene que adivinar el precio: este módulo lo contabiliza, lo desglosa y
|
|
14
|
+
* lo corta.
|
|
15
|
+
*
|
|
16
|
+
* Reglas de diseño (no negociables):
|
|
17
|
+
* - Determinismo total: no hay `Date.now()` interno, el reloj se inyecta. Dos
|
|
18
|
+
* corridas con el mismo reloj producen el mismo ledger, byte a byte.
|
|
19
|
+
* - El ledger es JSON plano: se persiste en disco tal cual, sin transformarlo.
|
|
20
|
+
* - Sin dependencias externas: los tipos se definen acá y no se piden de
|
|
21
|
+
* `types.ts`, para que este módulo se pueda usar sin acoplarlo al resto.
|
|
22
|
+
*/
|
|
23
|
+
import { nowIso } from "./fsx.js";
|
|
24
|
+
/** Todas las fases, en orden de ciclo de vida de una card. */
|
|
25
|
+
export const WORK_PHASES = [
|
|
26
|
+
"implementation",
|
|
27
|
+
"review",
|
|
28
|
+
"repair",
|
|
29
|
+
"exploration",
|
|
30
|
+
"verification",
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Defaults: una corrida de tamaño medio, no una corrida illimitada.
|
|
34
|
+
*
|
|
35
|
+
* Los presupuestos por fase suman exactamente `maxUsdPerRun` a propósito: si el
|
|
36
|
+
* director reparte mal el gasto entre fases, el desbalance tiene que aparecer en
|
|
37
|
+
* la fase que se pasó, no esconderse adentro del total.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_COST_LIMITS = {
|
|
40
|
+
maxUsdPerRun: 25,
|
|
41
|
+
maxUsdPerCard: 5,
|
|
42
|
+
maxTokensPerRun: 5_000_000,
|
|
43
|
+
maxWallClockMsPerRun: 2 * 60 * 60 * 1000,
|
|
44
|
+
perPhaseUsd: {
|
|
45
|
+
implementation: 12,
|
|
46
|
+
review: 4,
|
|
47
|
+
repair: 6,
|
|
48
|
+
exploration: 2,
|
|
49
|
+
verification: 1,
|
|
50
|
+
},
|
|
51
|
+
warnUsdPerCard: 1,
|
|
52
|
+
};
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// Internos
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
/** Versión del formato del ledger. Sube si cambia la forma del JSON. */
|
|
57
|
+
const LEDGER_VERSION = 1;
|
|
58
|
+
/**
|
|
59
|
+
* Dígitos decimales que se guardan para el USD.
|
|
60
|
+
*
|
|
61
|
+
* Sin esto, 0.1 + 0.2 = 0.30000000000000004 queda en el JSON persistido y los
|
|
62
|
+
* tests de igualdad exacta se rompen por puro ruido binario. Nueve decimales
|
|
63
|
+
* es precisión de nanodólares: de sobra para lo que se factura.
|
|
64
|
+
*/
|
|
65
|
+
const USD_DECIMALS = 9;
|
|
66
|
+
/**
|
|
67
|
+
* Tokens asumidos por llamada para la estimación previa.
|
|
68
|
+
*
|
|
69
|
+
* Supuesto declarado, no medido: 8k de entrada (contexto de la card + repo) y
|
|
70
|
+
* 2k de salida (resolución). Si el supuesto queda corto, la estimación es
|
|
71
|
+
* una cota baja — por eso la nota dice que el costo real puede ser mayor.
|
|
72
|
+
*/
|
|
73
|
+
export const ASSUMED_TOKENS_PER_CALL = 10_000;
|
|
74
|
+
/** Redondea un monto a nanodólares. */
|
|
75
|
+
function roundUsd(value) {
|
|
76
|
+
if (!Number.isFinite(value))
|
|
77
|
+
return value;
|
|
78
|
+
return Math.round(value * 1e9) / 1e9;
|
|
79
|
+
}
|
|
80
|
+
/** Tokens en cero. */
|
|
81
|
+
function emptyTokens() {
|
|
82
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
83
|
+
}
|
|
84
|
+
/** Suma dos conteos de tokens. */
|
|
85
|
+
function addTokens(a, b) {
|
|
86
|
+
return {
|
|
87
|
+
input: a.input + b.input,
|
|
88
|
+
output: a.output + b.output,
|
|
89
|
+
reasoning: a.reasoning + b.reasoning,
|
|
90
|
+
cacheRead: a.cacheRead + b.cacheRead,
|
|
91
|
+
cacheWrite: a.cacheWrite + b.cacheWrite,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Total de tokens facturables de una llamada.
|
|
96
|
+
*
|
|
97
|
+
* Suma input + output + reasoning + cache. El reasoning se cuenta aparte del
|
|
98
|
+
* output porque el motor lo reporta aparte, pero se incluye en el presupuesto:
|
|
99
|
+
* son tokens que se facturan y omitirlos sería subcontar el gasto real.
|
|
100
|
+
*/
|
|
101
|
+
export function totalTokens(tokens) {
|
|
102
|
+
return tokens.input + tokens.output + tokens.reasoning + tokens.cacheRead + tokens.cacheWrite;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* ¿El valor superó el límite?
|
|
106
|
+
*
|
|
107
|
+
* `max <= 0` o no finito significa "sin límite" (ver la convención de
|
|
108
|
+
* `CostLimits`).
|
|
109
|
+
*/
|
|
110
|
+
function exceeds(used, max) {
|
|
111
|
+
if (!Number.isFinite(max) || max <= 0)
|
|
112
|
+
return false;
|
|
113
|
+
return used > max;
|
|
114
|
+
}
|
|
115
|
+
/** Copia profunda del total, para que el decisor no pueda mutar el ledger. */
|
|
116
|
+
function copyTotal(total) {
|
|
117
|
+
return {
|
|
118
|
+
usd: total.usd,
|
|
119
|
+
tokens: { ...total.tokens },
|
|
120
|
+
calls: total.calls,
|
|
121
|
+
wallClockMs: total.wallClockMs,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Comparación de strings estable e independiente del locale. */
|
|
125
|
+
function cmpStr(a, b) {
|
|
126
|
+
if (a < b)
|
|
127
|
+
return -1;
|
|
128
|
+
if (a > b)
|
|
129
|
+
return 1;
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
/** Formatea un entero con separadores de miles (determinista, sin locale). */
|
|
133
|
+
function formatInt(value) {
|
|
134
|
+
return String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
|
135
|
+
}
|
|
136
|
+
/** Formatea dólares con 4 decimales. */
|
|
137
|
+
function formatUsd(value) {
|
|
138
|
+
if (!Number.isFinite(value))
|
|
139
|
+
return "$∞";
|
|
140
|
+
return `$${value.toFixed(4)}`;
|
|
141
|
+
}
|
|
142
|
+
/** Formatea una duración en milisegundos de forma legible. */
|
|
143
|
+
function formatDuration(ms) {
|
|
144
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
145
|
+
return "—";
|
|
146
|
+
if (ms < 1000)
|
|
147
|
+
return `${Math.round(ms)} ms`;
|
|
148
|
+
const seconds = Math.floor(ms / 1000);
|
|
149
|
+
const hours = Math.floor(seconds / 3600);
|
|
150
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
151
|
+
const rest = seconds % 60;
|
|
152
|
+
if (hours > 0)
|
|
153
|
+
return `${hours}h ${minutes}m ${rest}s`;
|
|
154
|
+
if (minutes > 0)
|
|
155
|
+
return `${minutes}m ${rest}s`;
|
|
156
|
+
return `${rest}s`;
|
|
157
|
+
}
|
|
158
|
+
/** Formatea un porcentaje de 0 a 1. */
|
|
159
|
+
function formatPercent(ratio) {
|
|
160
|
+
if (!Number.isFinite(ratio))
|
|
161
|
+
return "∞";
|
|
162
|
+
return `${(ratio * 100).toFixed(1)}%`;
|
|
163
|
+
}
|
|
164
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
// Ledger
|
|
166
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Crea un ledger en cero.
|
|
169
|
+
*
|
|
170
|
+
* `clock` es inyectable porque el módulo tiene que ser reproducible en tests:
|
|
171
|
+
* con el mismo reloj, la misma secuencia de llamadas produce el mismo ledger.
|
|
172
|
+
*/
|
|
173
|
+
export function createLedger(runId, clock = Date.now) {
|
|
174
|
+
const stamp = nowIso(clock);
|
|
175
|
+
return {
|
|
176
|
+
version: LEDGER_VERSION,
|
|
177
|
+
runId,
|
|
178
|
+
byCard: {},
|
|
179
|
+
byAgent: {},
|
|
180
|
+
byPhase: {},
|
|
181
|
+
total: { usd: 0, tokens: emptyTokens(), calls: 0, wallClockMs: 0 },
|
|
182
|
+
startedAt: stamp,
|
|
183
|
+
updatedAt: stamp,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Acumula el uso de una llamada en el ledger.
|
|
188
|
+
*
|
|
189
|
+
* Muta el ledger recibido y lo devuelve, así que funciona igual
|
|
190
|
+
* `recordUsage(l, x)` que `l = recordUsage(l, x)`. Se eligió mutar (y no
|
|
191
|
+
* devolver una copia) porque el ledger es estado compartido y persistido: si
|
|
192
|
+
* una copia, un caller que olvide reasignar pierde el gasto en silencio, que
|
|
193
|
+
* es el peor modo de falla para un control de presupuesto.
|
|
194
|
+
*
|
|
195
|
+
* El wall clock de la corrida se recalcula contra `startedAt`, que se puede
|
|
196
|
+
* recuperar del propio JSON porque el ISO tiene precisión de milisegundos.
|
|
197
|
+
*/
|
|
198
|
+
export function recordUsage(ledger, input, clock = Date.now) {
|
|
199
|
+
const usd = roundUsd(input.cost);
|
|
200
|
+
const tokens = {
|
|
201
|
+
input: input.tokens.input,
|
|
202
|
+
output: input.tokens.output,
|
|
203
|
+
reasoning: input.tokens.reasoning,
|
|
204
|
+
cacheRead: input.tokens.cache?.read ?? 0,
|
|
205
|
+
cacheWrite: input.tokens.cache?.write ?? 0,
|
|
206
|
+
};
|
|
207
|
+
const latencyMs = Math.max(0, input.latencyMs);
|
|
208
|
+
// ── Total de la corrida ──
|
|
209
|
+
ledger.total.usd = roundUsd(ledger.total.usd + usd);
|
|
210
|
+
ledger.total.tokens = addTokens(ledger.total.tokens, tokens);
|
|
211
|
+
ledger.total.calls += 1;
|
|
212
|
+
const elapsed = Math.max(0, clock() - Date.parse(ledger.startedAt));
|
|
213
|
+
ledger.total.wallClockMs = Math.max(ledger.total.wallClockMs, elapsed);
|
|
214
|
+
// ── Por card ──
|
|
215
|
+
if (input.cardId) {
|
|
216
|
+
const card = ledger.byCard[input.cardId];
|
|
217
|
+
if (card) {
|
|
218
|
+
card.usd = roundUsd(card.usd + usd);
|
|
219
|
+
card.tokens = addTokens(card.tokens, tokens);
|
|
220
|
+
card.calls += 1;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
ledger.byCard[input.cardId] = {
|
|
224
|
+
cardId: input.cardId,
|
|
225
|
+
usd,
|
|
226
|
+
tokens: { ...tokens },
|
|
227
|
+
calls: 1,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ── Por agente ──
|
|
232
|
+
const agent = ledger.byAgent[input.agentId];
|
|
233
|
+
if (agent) {
|
|
234
|
+
agent.usd = roundUsd(agent.usd + usd);
|
|
235
|
+
agent.tokens = addTokens(agent.tokens, tokens);
|
|
236
|
+
agent.calls += 1;
|
|
237
|
+
agent.latencyMs += latencyMs;
|
|
238
|
+
if (input.role)
|
|
239
|
+
agent.role = input.role;
|
|
240
|
+
agent.model = input.model;
|
|
241
|
+
agent.provider = input.provider;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
ledger.byAgent[input.agentId] = {
|
|
245
|
+
agentId: input.agentId,
|
|
246
|
+
role: input.role,
|
|
247
|
+
usd,
|
|
248
|
+
tokens: { ...tokens },
|
|
249
|
+
calls: 1,
|
|
250
|
+
latencyMs,
|
|
251
|
+
model: input.model,
|
|
252
|
+
provider: input.provider,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ── Por fase ──
|
|
256
|
+
const phase = ledger.byPhase[input.phase];
|
|
257
|
+
if (phase) {
|
|
258
|
+
phase.usd = roundUsd(phase.usd + usd);
|
|
259
|
+
phase.tokens = addTokens(phase.tokens, tokens);
|
|
260
|
+
phase.calls += 1;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
ledger.byPhase[input.phase] = {
|
|
264
|
+
phase: input.phase,
|
|
265
|
+
usd,
|
|
266
|
+
tokens: { ...tokens },
|
|
267
|
+
calls: 1,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
ledger.updatedAt = nowIso(clock);
|
|
271
|
+
return ledger;
|
|
272
|
+
}
|
|
273
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
// Presupuesto
|
|
275
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
276
|
+
/**
|
|
277
|
+
* Decide si se puede seguir gastando.
|
|
278
|
+
*
|
|
279
|
+
* Precedencia de las comprobaciones (de la más específica a la más global):
|
|
280
|
+
* `card-usd` → `phase-usd` → `run-usd` → `run-tokens` → `wall-clock`. Se
|
|
281
|
+
* empieza por las acotadas porque son las accionables: dicen qué card parar y
|
|
282
|
+
* en qué fase, no sólo que "la corrida se pasó".
|
|
283
|
+
*
|
|
284
|
+
* `cardId` y `phase` son opcionales: si no se pasan, esas dimensiones no se
|
|
285
|
+
* evalúan. El límite de card no se aplica "de rebote" cuando no se sabe de qué
|
|
286
|
+
* card se trata, porque adivinar sería bloquear trabajo válido.
|
|
287
|
+
*/
|
|
288
|
+
export function checkBudget(ledger, limits, cardId, phase) {
|
|
289
|
+
const current = copyTotal(ledger.total);
|
|
290
|
+
const warnings = [];
|
|
291
|
+
const runTokens = totalTokens(ledger.total.tokens);
|
|
292
|
+
// 1. Card
|
|
293
|
+
if (cardId) {
|
|
294
|
+
const cardUsd = ledger.byCard[cardId]?.usd ?? 0;
|
|
295
|
+
if (exceeds(cardUsd, limits.maxUsdPerCard)) {
|
|
296
|
+
return {
|
|
297
|
+
allowed: false,
|
|
298
|
+
reason: `La card ${cardId} gastó ${formatUsd(cardUsd)} y su presupuesto es ` +
|
|
299
|
+
`${formatUsd(limits.maxUsdPerCard)}. La corrida total va en ${formatUsd(ledger.total.usd)}.`,
|
|
300
|
+
breached: "card-usd",
|
|
301
|
+
current,
|
|
302
|
+
warnings,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (exceeds(cardUsd, limits.warnUsdPerCard)) {
|
|
306
|
+
warnings.push(`La card ${cardId} ya gastó ${formatUsd(cardUsd)}, por encima del umbral ` +
|
|
307
|
+
`de ${formatUsd(limits.warnUsdPerCard)}. Costo excesivo para un requisito.`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 2. Fase
|
|
311
|
+
if (phase) {
|
|
312
|
+
const phaseBudget = limits.perPhaseUsd[phase];
|
|
313
|
+
const phaseUsd = ledger.byPhase[phase]?.usd ?? 0;
|
|
314
|
+
if (phaseBudget !== undefined && exceeds(phaseUsd, phaseBudget)) {
|
|
315
|
+
return {
|
|
316
|
+
allowed: false,
|
|
317
|
+
reason: `La fase "${phase}" gastó ${formatUsd(phaseUsd)} y su presupuesto es ` +
|
|
318
|
+
`${formatUsd(phaseBudget)}. La corrida total va en ${formatUsd(ledger.total.usd)}.`,
|
|
319
|
+
breached: "phase-usd",
|
|
320
|
+
current,
|
|
321
|
+
warnings,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// 3. Costo total
|
|
326
|
+
if (exceeds(ledger.total.usd, limits.maxUsdPerRun)) {
|
|
327
|
+
return {
|
|
328
|
+
allowed: false,
|
|
329
|
+
reason: `La corrida gastó ${formatUsd(ledger.total.usd)} y el presupuesto total es ` +
|
|
330
|
+
`${formatUsd(limits.maxUsdPerRun)}.`,
|
|
331
|
+
breached: "run-usd",
|
|
332
|
+
current,
|
|
333
|
+
warnings,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// 4. Tokens
|
|
337
|
+
if (exceeds(runTokens, limits.maxTokensPerRun)) {
|
|
338
|
+
return {
|
|
339
|
+
allowed: false,
|
|
340
|
+
reason: `La corrida consumió ${formatInt(runTokens)} tokens y el presupuesto es ` +
|
|
341
|
+
`${formatInt(limits.maxTokensPerRun)}.`,
|
|
342
|
+
breached: "run-tokens",
|
|
343
|
+
current,
|
|
344
|
+
warnings,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// 5. Wall clock
|
|
348
|
+
if (exceeds(ledger.total.wallClockMs, limits.maxWallClockMsPerRun)) {
|
|
349
|
+
return {
|
|
350
|
+
allowed: false,
|
|
351
|
+
reason: `La corrida lleva ${formatDuration(ledger.total.wallClockMs)} y el presupuesto ` +
|
|
352
|
+
`es ${formatDuration(limits.maxWallClockMsPerRun)}.`,
|
|
353
|
+
breached: "wall-clock",
|
|
354
|
+
current,
|
|
355
|
+
warnings,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
allowed: true,
|
|
360
|
+
reason: `Dentro de presupuesto: ${formatUsd(ledger.total.usd)} de ${formatUsd(limits.maxUsdPerRun)}, ` +
|
|
361
|
+
`${formatInt(runTokens)} de ${formatInt(limits.maxTokensPerRun)} tokens, ` +
|
|
362
|
+
`${formatDuration(ledger.total.wallClockMs)} de ${formatDuration(limits.maxWallClockMsPerRun)}.`,
|
|
363
|
+
breached: null,
|
|
364
|
+
current,
|
|
365
|
+
warnings,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
369
|
+
// Estimación
|
|
370
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
371
|
+
/**
|
|
372
|
+
* Estimación previa a una corrida grande.
|
|
373
|
+
*
|
|
374
|
+
* Es una proyección lineal y declaradamente burda: multiplica cards por
|
|
375
|
+
* agentes por llamadas por costo promedio. Sirve para decidir si la corrida
|
|
376
|
+
* entra en el presupuesto antes de empezarla, no para reemplazar la
|
|
377
|
+
* contabilidad real.
|
|
378
|
+
*
|
|
379
|
+
* La nota dice explícitamente los supuestos, porque una estimación sin
|
|
380
|
+
* supuestos es una prophecy.
|
|
381
|
+
*/
|
|
382
|
+
export function estimateRun(cards, agentsPerCard, avgCallsPerAgent, avgUsdPerCall) {
|
|
383
|
+
const invalid = !Number.isFinite(cards) ||
|
|
384
|
+
!Number.isFinite(agentsPerCard) ||
|
|
385
|
+
!Number.isFinite(avgCallsPerAgent) ||
|
|
386
|
+
!Number.isFinite(avgUsdPerCall) ||
|
|
387
|
+
cards < 0 ||
|
|
388
|
+
agentsPerCard < 0 ||
|
|
389
|
+
avgCallsPerAgent < 0 ||
|
|
390
|
+
avgUsdPerCall < 0;
|
|
391
|
+
const c = invalid ? 0 : cards;
|
|
392
|
+
const a = invalid ? 0 : agentsPerCard;
|
|
393
|
+
const k = invalid ? 0 : avgCallsPerAgent;
|
|
394
|
+
const price = invalid ? 0 : avgUsdPerCall;
|
|
395
|
+
const calls = c * a * k;
|
|
396
|
+
const usd = roundUsd(calls * price);
|
|
397
|
+
const tokens = Math.round(calls * ASSUMED_TOKENS_PER_CALL);
|
|
398
|
+
const note = invalid
|
|
399
|
+
? "Entradas inválidas: se devuelve una estimación de cero. Se esperan números no negativos."
|
|
400
|
+
: `Estimación lineal: ${c} cards × ${a} agentes × ${k} llamadas = ${formatInt(calls)} llamadas. ` +
|
|
401
|
+
`A ${formatUsd(price)} por llamada ≈ ${formatUsd(usd)} de costo total. ` +
|
|
402
|
+
`Tokens estimados a ${formatInt(ASSUMED_TOKENS_PER_CALL)} por llamada ` +
|
|
403
|
+
`(8k de entrada + 2k de salida) ≈ ${formatInt(tokens)}. ` +
|
|
404
|
+
`Supuesto: los agentes no releen el mismo contexto; si lo hacen, el costo real sube. ` +
|
|
405
|
+
`El cache de lectura puede bajar la cifra; los tokens de razonamiento la suben.`;
|
|
406
|
+
return { usd, tokens, note };
|
|
407
|
+
}
|
|
408
|
+
/** Calcula la presión de cada límite para detectar el más cercano de romperse. */
|
|
409
|
+
function limitPressures(ledger, limits) {
|
|
410
|
+
const rows = [];
|
|
411
|
+
const worstCard = Object.values(ledger.byCard).reduce((max, card) => (card.usd > max ? card.usd : max), 0);
|
|
412
|
+
rows.push({
|
|
413
|
+
key: "run-usd",
|
|
414
|
+
label: "Costo total de la corrida",
|
|
415
|
+
used: ledger.total.usd,
|
|
416
|
+
max: limits.maxUsdPerRun,
|
|
417
|
+
ratio: safeRatio(ledger.total.usd, limits.maxUsdPerRun),
|
|
418
|
+
format: "usd",
|
|
419
|
+
});
|
|
420
|
+
rows.push({
|
|
421
|
+
key: "card-usd",
|
|
422
|
+
label: "Costo de la card más cara",
|
|
423
|
+
used: worstCard,
|
|
424
|
+
max: limits.maxUsdPerCard,
|
|
425
|
+
ratio: safeRatio(worstCard, limits.maxUsdPerCard),
|
|
426
|
+
format: "usd",
|
|
427
|
+
});
|
|
428
|
+
const tokens = totalTokens(ledger.total.tokens);
|
|
429
|
+
rows.push({
|
|
430
|
+
key: "run-tokens",
|
|
431
|
+
label: "Tokens totales",
|
|
432
|
+
used: tokens,
|
|
433
|
+
max: limits.maxTokensPerRun,
|
|
434
|
+
ratio: safeRatio(tokens, limits.maxTokensPerRun),
|
|
435
|
+
format: "tokens",
|
|
436
|
+
});
|
|
437
|
+
rows.push({
|
|
438
|
+
key: "wall-clock",
|
|
439
|
+
label: "Wall clock de la corrida",
|
|
440
|
+
used: ledger.total.wallClockMs,
|
|
441
|
+
max: limits.maxWallClockMsPerRun,
|
|
442
|
+
ratio: safeRatio(ledger.total.wallClockMs, limits.maxWallClockMsPerRun),
|
|
443
|
+
format: "ms",
|
|
444
|
+
});
|
|
445
|
+
for (const phase of WORK_PHASES) {
|
|
446
|
+
const budget = limits.perPhaseUsd[phase];
|
|
447
|
+
if (budget === undefined)
|
|
448
|
+
continue;
|
|
449
|
+
const used = ledger.byPhase[phase]?.usd ?? 0;
|
|
450
|
+
rows.push({
|
|
451
|
+
key: `phase-usd:${phase}`,
|
|
452
|
+
label: `Fase ${phase}`,
|
|
453
|
+
used,
|
|
454
|
+
max: budget,
|
|
455
|
+
ratio: safeRatio(used, budget),
|
|
456
|
+
format: "usd",
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return rows;
|
|
460
|
+
}
|
|
461
|
+
/** Uso de un límite, donde 0 / Infinity significa "sin límite". */
|
|
462
|
+
function safeRatio(used, max) {
|
|
463
|
+
if (!Number.isFinite(max) || max <= 0)
|
|
464
|
+
return 0;
|
|
465
|
+
return used / max;
|
|
466
|
+
}
|
|
467
|
+
/** Formatea un valor según el tipo de la fila de límite. */
|
|
468
|
+
function formatLimitValue(value, format) {
|
|
469
|
+
if (format === "usd")
|
|
470
|
+
return formatUsd(value);
|
|
471
|
+
if (format === "tokens")
|
|
472
|
+
return formatInt(value);
|
|
473
|
+
return formatDuration(value);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Renderiza el ledger como markdown.
|
|
477
|
+
*
|
|
478
|
+
* El informe tiene que servir para dos lecturas: la del humano que quiere
|
|
479
|
+
* saber cuánto costó la corrida, y la del director que quiere saber qué
|
|
480
|
+
* cortar primero. Por eso el cierre no es un número suelto sino el límite
|
|
481
|
+
* con más presión.
|
|
482
|
+
*/
|
|
483
|
+
export function renderCostReport(ledger, limits) {
|
|
484
|
+
const lines = [];
|
|
485
|
+
const tokens = ledger.total.tokens;
|
|
486
|
+
lines.push(`# Costo — corrida \`${ledger.runId}\``);
|
|
487
|
+
lines.push("");
|
|
488
|
+
lines.push(`**Inicio:** ${ledger.startedAt} · **Actualizado:** ${ledger.updatedAt}`);
|
|
489
|
+
lines.push("");
|
|
490
|
+
// ── Totales ──
|
|
491
|
+
lines.push("## Totales");
|
|
492
|
+
lines.push("");
|
|
493
|
+
lines.push("| Métrica | Valor |");
|
|
494
|
+
lines.push("|---|---|");
|
|
495
|
+
lines.push(`| USD | ${formatUsd(ledger.total.usd)} |`);
|
|
496
|
+
lines.push(`| Tokens | ${formatInt(totalTokens(tokens))} |`);
|
|
497
|
+
lines.push(`| └ entrada | ${formatInt(tokens.input)} |`);
|
|
498
|
+
lines.push(`| └ salida | ${formatInt(tokens.output)} |`);
|
|
499
|
+
lines.push(`| └ razonamiento | ${formatInt(tokens.reasoning)} |`);
|
|
500
|
+
lines.push(`| └ cache lectura | ${formatInt(tokens.cacheRead)} |`);
|
|
501
|
+
lines.push(`| └ cache escritura | ${formatInt(tokens.cacheWrite)} |`);
|
|
502
|
+
lines.push(`| Llamadas | ${formatInt(ledger.total.calls)} |`);
|
|
503
|
+
lines.push(`| Wall clock | ${formatDuration(ledger.total.wallClockMs)} |`);
|
|
504
|
+
lines.push("");
|
|
505
|
+
// ── Por card ──
|
|
506
|
+
lines.push("## Por card");
|
|
507
|
+
lines.push("");
|
|
508
|
+
const cards = Object.values(ledger.byCard).sort((a, b) => b.usd - a.usd || cmpStr(a.cardId, b.cardId));
|
|
509
|
+
if (cards.length === 0) {
|
|
510
|
+
lines.push("_Sin cards registradas._");
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
lines.push("| Card | USD | Tokens | Llamadas | % del límite |");
|
|
514
|
+
lines.push("|---|---|---|---|---|");
|
|
515
|
+
for (const card of cards) {
|
|
516
|
+
const ratio = safeRatio(card.usd, limits.maxUsdPerCard);
|
|
517
|
+
const flag = exceeds(card.usd, limits.warnUsdPerCard) ? " ⚠️" : "";
|
|
518
|
+
lines.push(`| \`${card.cardId}\`${flag} | ${formatUsd(card.usd)} | ${formatInt(totalTokens(card.tokens))} | ` +
|
|
519
|
+
`${formatInt(card.calls)} | ${formatPercent(ratio)} |`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
lines.push("");
|
|
523
|
+
// ── Por agente ──
|
|
524
|
+
lines.push("## Por agente");
|
|
525
|
+
lines.push("");
|
|
526
|
+
const agents = Object.values(ledger.byAgent).sort((a, b) => b.usd - a.usd || cmpStr(a.agentId, b.agentId));
|
|
527
|
+
if (agents.length === 0) {
|
|
528
|
+
lines.push("_Sin agentes registrados._");
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
lines.push("| Agente | Rol | Modelo | USD | Tokens | Llamadas | Latencia media |");
|
|
532
|
+
lines.push("|---|---|---|---|---|---|---|");
|
|
533
|
+
for (const agent of agents) {
|
|
534
|
+
const avg = agent.calls > 0 ? agent.latencyMs / agent.calls : 0;
|
|
535
|
+
const model = agent.model
|
|
536
|
+
? `${agent.model}${agent.provider ? ` (${agent.provider})` : ""}`
|
|
537
|
+
: "—";
|
|
538
|
+
lines.push(`| \`${agent.agentId}\` | ${agent.role || "—"} | ${model} | ${formatUsd(agent.usd)} | ` +
|
|
539
|
+
`${formatInt(totalTokens(agent.tokens))} | ${formatInt(agent.calls)} | ${formatDuration(avg)} |`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
lines.push("");
|
|
543
|
+
// ── Por fase ──
|
|
544
|
+
lines.push("## Por fase");
|
|
545
|
+
lines.push("");
|
|
546
|
+
const phases = Object.values(ledger.byPhase).sort((a, b) => b.usd - a.usd || cmpStr(a.phase, b.phase));
|
|
547
|
+
if (phases.length === 0) {
|
|
548
|
+
lines.push("_Sin fases registradas._");
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
lines.push("| Fase | USD | Tokens | Llamadas | Presupuesto | % |");
|
|
552
|
+
lines.push("|---|---|---|---|---|---|");
|
|
553
|
+
for (const phase of phases) {
|
|
554
|
+
const budget = limits.perPhaseUsd[phase.phase];
|
|
555
|
+
const ratio = safeRatio(phase.usd, budget ?? 0);
|
|
556
|
+
lines.push(`| ${phase.phase} | ${formatUsd(phase.usd)} | ${formatInt(totalTokens(phase.tokens))} | ` +
|
|
557
|
+
`${formatInt(phase.calls)} | ${budget === undefined ? "—" : formatUsd(budget)} | ${formatPercent(ratio)} |`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
lines.push("");
|
|
561
|
+
// ── Límites ──
|
|
562
|
+
lines.push("## Límites");
|
|
563
|
+
lines.push("");
|
|
564
|
+
lines.push("| Límite | Actual | Máximo | Uso |");
|
|
565
|
+
lines.push("|---|---|---|---|");
|
|
566
|
+
for (const row of limitPressures(ledger, limits)) {
|
|
567
|
+
const unlimited = !Number.isFinite(row.max) || row.max <= 0;
|
|
568
|
+
lines.push(`| ${row.label} | ${formatLimitValue(row.used, row.format)} | ` +
|
|
569
|
+
`${unlimited ? "sin límite" : formatLimitValue(row.max, row.format)} | ${formatPercent(row.ratio)} |`);
|
|
570
|
+
}
|
|
571
|
+
lines.push("");
|
|
572
|
+
// ── El más cerca de romperse ──
|
|
573
|
+
const pressured = limitPressures(ledger, limits)
|
|
574
|
+
.filter((row) => Number.isFinite(row.max) && row.max > 0)
|
|
575
|
+
.sort((a, b) => b.ratio - a.ratio || cmpStr(a.key, b.key));
|
|
576
|
+
const closest = pressured[0];
|
|
577
|
+
if (closest) {
|
|
578
|
+
lines.push(`**Más cerca de romperse:** \`${closest.key}\` — ${formatPercent(closest.ratio)} del presupuesto ` +
|
|
579
|
+
`(${formatLimitValue(closest.used, closest.format)} de ${formatLimitValue(closest.max, closest.format)}).`);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
lines.push("**Más cerca de romperse:** sin límites definidos.");
|
|
583
|
+
}
|
|
584
|
+
lines.push("");
|
|
585
|
+
// ── Avisos ──
|
|
586
|
+
const warned = cards.filter((card) => exceeds(card.usd, limits.warnUsdPerCard));
|
|
587
|
+
if (warned.length > 0) {
|
|
588
|
+
lines.push("## Avisos");
|
|
589
|
+
lines.push("");
|
|
590
|
+
for (const card of warned) {
|
|
591
|
+
lines.push(`- La card \`${card.cardId}\` gastó ${formatUsd(card.usd)}, por encima del umbral de ` +
|
|
592
|
+
`${formatUsd(limits.warnUsdPerCard)}.`);
|
|
593
|
+
}
|
|
594
|
+
lines.push("");
|
|
595
|
+
}
|
|
596
|
+
return lines.join("\n");
|
|
597
|
+
}
|
|
598
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
599
|
+
// Trabajo redundante
|
|
600
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
601
|
+
/** Tools que leen un archivo. */
|
|
602
|
+
const READ_TOOLS = new Set([
|
|
603
|
+
"read",
|
|
604
|
+
"readfile",
|
|
605
|
+
"view",
|
|
606
|
+
"cat",
|
|
607
|
+
"open",
|
|
608
|
+
"openfile",
|
|
609
|
+
"notebookread",
|
|
610
|
+
"getfile",
|
|
611
|
+
]);
|
|
612
|
+
/** Tools que ejecutan un comando. */
|
|
613
|
+
const COMMAND_TOOLS = new Set([
|
|
614
|
+
"bash",
|
|
615
|
+
"sh",
|
|
616
|
+
"shell",
|
|
617
|
+
"zsh",
|
|
618
|
+
"cmd",
|
|
619
|
+
"powershell",
|
|
620
|
+
"pwsh",
|
|
621
|
+
"exec",
|
|
622
|
+
"execute",
|
|
623
|
+
"command",
|
|
624
|
+
"run",
|
|
625
|
+
"runcommand",
|
|
626
|
+
"terminal",
|
|
627
|
+
]);
|
|
628
|
+
/**
|
|
629
|
+
* Clasifica una tool como lectura, comando o ninguna.
|
|
630
|
+
*
|
|
631
|
+
* Se miran los nombres conocidos y, si no alcanzan, prefijos y subcadenas:
|
|
632
|
+
* el nombre exacto de la tool cambia entre versiones del motor y no vale la
|
|
633
|
+
* pena romperse por eso. Las tools de mutación (edit, write) NO se cuentan:
|
|
634
|
+
* repetir una escritura no es trabajo redundante, es una reescritura.
|
|
635
|
+
*/
|
|
636
|
+
function classifyTool(tool) {
|
|
637
|
+
const key = tool.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
638
|
+
if (!key)
|
|
639
|
+
return null;
|
|
640
|
+
if (READ_TOOLS.has(key))
|
|
641
|
+
return "read";
|
|
642
|
+
if (COMMAND_TOOLS.has(key))
|
|
643
|
+
return "command";
|
|
644
|
+
if (key.startsWith("read"))
|
|
645
|
+
return "read";
|
|
646
|
+
if (/(bash|shell|exec|command|terminal|powershell)/.test(key))
|
|
647
|
+
return "command";
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
/** Normaliza un path de lectura: separadores Windows y Unix son el mismo archivo. */
|
|
651
|
+
function normalizeReadTarget(target) {
|
|
652
|
+
return target.trim().replace(/\\/g, "/");
|
|
653
|
+
}
|
|
654
|
+
/** Normaliza un comando: los espacios de más no cambian el comando. */
|
|
655
|
+
function normalizeCommandTarget(target) {
|
|
656
|
+
return target.trim().replace(/\s+/g, " ");
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Detecta trabajo redundante en la traza de tools de un agente.
|
|
660
|
+
*
|
|
661
|
+
* Esto es lo que el review pidió medir: "cuántas veces un agente vuelve a leer
|
|
662
|
+
* el mismo contexto". Un agente que lee el mismo archivo cinco veces está
|
|
663
|
+
* paying cinco veces por la misma información, y eso no lo detecta ningún
|
|
664
|
+
* presupuesto de conteo de agentes.
|
|
665
|
+
*
|
|
666
|
+
* Dos precisiones importantes:
|
|
667
|
+
* - La redundancia se cuenta POR AGENTE: dos agentes distintos que leen el
|
|
668
|
+
* mismo archivo no es redundancia (cada uno necesita su contexto). El
|
|
669
|
+
* `count` que se reporta, en cambio, agrega por archivo entre agentes.
|
|
670
|
+
* - `wastedFraction` se calcula sobre los eventos de lectura y ejecución con
|
|
671
|
+
* objetivo: la fracción que fue repetición de algo que ese mismo agente ya
|
|
672
|
+
* tenía.
|
|
673
|
+
*/
|
|
674
|
+
export function detectRedundantWork(events) {
|
|
675
|
+
const readCounts = new Map();
|
|
676
|
+
const commandCounts = new Map();
|
|
677
|
+
const seen = new Set();
|
|
678
|
+
let considered = 0;
|
|
679
|
+
let redundant = 0;
|
|
680
|
+
for (const event of events) {
|
|
681
|
+
const kind = classifyTool(event.tool);
|
|
682
|
+
if (!kind)
|
|
683
|
+
continue;
|
|
684
|
+
if (!event.target)
|
|
685
|
+
continue;
|
|
686
|
+
const target = kind === "read" ? normalizeReadTarget(event.target) : normalizeCommandTarget(event.target);
|
|
687
|
+
if (!target)
|
|
688
|
+
continue;
|
|
689
|
+
considered += 1;
|
|
690
|
+
const key = `${event.agentId}|${kind}|${target}`;
|
|
691
|
+
if (seen.has(key))
|
|
692
|
+
redundant += 1;
|
|
693
|
+
else
|
|
694
|
+
seen.add(key);
|
|
695
|
+
const counts = kind === "read" ? readCounts : commandCounts;
|
|
696
|
+
counts.set(target, (counts.get(target) ?? 0) + 1);
|
|
697
|
+
}
|
|
698
|
+
const repeatedReads = [...readCounts.entries()]
|
|
699
|
+
.filter(([, count]) => count > 1)
|
|
700
|
+
.map(([target, count]) => ({ target, count }))
|
|
701
|
+
.sort((a, b) => b.count - a.count || cmpStr(a.target, b.target));
|
|
702
|
+
const repeatedCommands = [...commandCounts.entries()]
|
|
703
|
+
.filter(([, count]) => count > 1)
|
|
704
|
+
.map(([target, count]) => ({ target, count }))
|
|
705
|
+
.sort((a, b) => b.count - a.count || cmpStr(a.target, b.target));
|
|
706
|
+
const wastedFraction = considered === 0 ? 0 : redundant / considered;
|
|
707
|
+
const readOccurrences = repeatedReads.reduce((sum, item) => sum + item.count, 0);
|
|
708
|
+
const commandOccurrences = repeatedCommands.reduce((sum, item) => sum + item.count, 0);
|
|
709
|
+
let summary;
|
|
710
|
+
if (considered === 0) {
|
|
711
|
+
summary = "Sin eventos de lectura o ejecución con objetivo: no hay redundancia que medir.";
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
const top = repeatedReads[0] ?? repeatedCommands[0];
|
|
715
|
+
const topText = top ? ` El peor caso es "${top.target}" (${top.count} veces).` : "";
|
|
716
|
+
summary =
|
|
717
|
+
`Se repitieron ${repeatedReads.length} path(s) de lectura (${readOccurrences} ocurrencias) y ` +
|
|
718
|
+
`${repeatedCommands.length} comando(s) (${commandOccurrences} ocurrencias). ` +
|
|
719
|
+
`${redundant} de ${considered} eventos (${formatPercent(wastedFraction)}) fueron repeticiones ` +
|
|
720
|
+
`del mismo agente sobre material que ya tenía.${topText}`;
|
|
721
|
+
}
|
|
722
|
+
return { repeatedReads, repeatedCommands, wastedFraction, summary };
|
|
723
|
+
}
|
|
724
|
+
//# sourceMappingURL=cost.js.map
|