wendkeep 0.37.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,90 +1,90 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync } from 'fs';
3
- import { basename, dirname, join } from 'path';
4
- import { fileURLToPath } from 'url';
5
- import {
6
- getVaultBase,
7
- readControl,
8
- truncate,
9
- } from './obsidian-common.mjs';
10
-
11
- // Preços API por milhão de tokens. cachedInput = cache read.
12
- // Cache write: 5m = 1.25x input, 1h = 2x input (multiplicadores em calculateCost).
13
- // Tabela editável em pricing.json (mesma pasta); esta é o fallback embutido
14
- // usado quando o JSON some ou fica inválido — o hook nunca deve quebrar por isso.
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
3
+ import { basename, dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import {
6
+ getVaultBase,
7
+ readControl,
8
+ truncate,
9
+ } from './obsidian-common.mjs';
10
+
11
+ // Preços API por milhão de tokens. cachedInput = cache read.
12
+ // Cache write: 5m = 1.25x input, 1h = 2x input (multiplicadores em calculateCost).
13
+ // Tabela editável em pricing.json (mesma pasta); esta é o fallback embutido
14
+ // usado quando o JSON some ou fica inválido — o hook nunca deve quebrar por isso.
15
15
  const DEFAULT_PRICE_REFERENCE = {
16
16
  'gpt-5.6-sol': { label: 'GPT-5.6 Sol API', provider: 'openai', input: 5, cachedInput: 0.5, output: 30 },
17
17
  'gpt-5.6-terra': { label: 'GPT-5.6 Terra API', provider: 'openai', input: 2.5, cachedInput: 0.25, output: 15 },
18
18
  'gpt-5.6-luna': { label: 'GPT-5.6 Luna API', provider: 'openai', input: 1, cachedInput: 0.1, output: 6 },
19
- 'gpt-5.5': {
20
- label: 'GPT-5.5 API',
21
- provider: 'openai',
22
- input: 5,
23
- cachedInput: 0.5,
24
- output: 30,
25
- },
26
- 'claude-opus-4.7': {
27
- label: 'Claude Opus 4.7 API',
28
- provider: 'anthropic',
29
- input: 5,
30
- cachedInput: 0.5,
31
- output: 25,
32
- },
33
- 'claude-opus-4.8': {
34
- label: 'Claude Opus 4.8 API',
35
- provider: 'anthropic',
36
- input: 5,
37
- cachedInput: 0.5,
38
- output: 25,
39
- },
40
- 'claude-sonnet-4.6': {
41
- label: 'Claude Sonnet 4.6 API',
42
- provider: 'anthropic',
43
- input: 3,
44
- cachedInput: 0.3,
45
- output: 15,
46
- },
47
- 'claude-sonnet-5': {
48
- label: 'Claude Sonnet 5 API',
49
- provider: 'anthropic',
50
- input: 3,
51
- cachedInput: 0.3,
52
- output: 15,
53
- },
54
- 'claude-haiku-4.5': {
55
- label: 'Claude Haiku 4.5 API',
56
- provider: 'anthropic',
57
- input: 1,
58
- cachedInput: 0.1,
59
- output: 5,
60
- },
61
- 'claude-fable-5': {
62
- label: 'Claude Fable 5 API',
63
- provider: 'anthropic',
64
- input: 10,
65
- cachedInput: 1,
66
- output: 50,
67
- },
68
- };
69
-
70
- const PRICING_FILE = join(dirname(fileURLToPath(import.meta.url)), 'pricing.json');
71
-
72
- // Carrega a tabela de preços do JSON editável; cai no fallback embutido se o
73
- // arquivo sumir, não for JSON válido ou não tiver `models` com entradas.
74
- export function loadPriceReference(file = PRICING_FILE) {
75
- try {
76
- const models = JSON.parse(readFileSync(file, 'utf-8'))?.models;
77
- if (models && typeof models === 'object' && Object.keys(models).length) {
78
- return models;
79
- }
80
- } catch {
81
- // arquivo ausente/corrompido — usa fallback.
82
- }
83
- return DEFAULT_PRICE_REFERENCE;
84
- }
85
-
86
- const PRICE_REFERENCE = loadPriceReference();
87
-
19
+ 'gpt-5.5': {
20
+ label: 'GPT-5.5 API',
21
+ provider: 'openai',
22
+ input: 5,
23
+ cachedInput: 0.5,
24
+ output: 30,
25
+ },
26
+ 'claude-opus-4.7': {
27
+ label: 'Claude Opus 4.7 API',
28
+ provider: 'anthropic',
29
+ input: 5,
30
+ cachedInput: 0.5,
31
+ output: 25,
32
+ },
33
+ 'claude-opus-4.8': {
34
+ label: 'Claude Opus 4.8 API',
35
+ provider: 'anthropic',
36
+ input: 5,
37
+ cachedInput: 0.5,
38
+ output: 25,
39
+ },
40
+ 'claude-sonnet-4.6': {
41
+ label: 'Claude Sonnet 4.6 API',
42
+ provider: 'anthropic',
43
+ input: 3,
44
+ cachedInput: 0.3,
45
+ output: 15,
46
+ },
47
+ 'claude-sonnet-5': {
48
+ label: 'Claude Sonnet 5 API',
49
+ provider: 'anthropic',
50
+ input: 3,
51
+ cachedInput: 0.3,
52
+ output: 15,
53
+ },
54
+ 'claude-haiku-4.5': {
55
+ label: 'Claude Haiku 4.5 API',
56
+ provider: 'anthropic',
57
+ input: 1,
58
+ cachedInput: 0.1,
59
+ output: 5,
60
+ },
61
+ 'claude-fable-5': {
62
+ label: 'Claude Fable 5 API',
63
+ provider: 'anthropic',
64
+ input: 10,
65
+ cachedInput: 1,
66
+ output: 50,
67
+ },
68
+ };
69
+
70
+ const PRICING_FILE = join(dirname(fileURLToPath(import.meta.url)), 'pricing.json');
71
+
72
+ // Carrega a tabela de preços do JSON editável; cai no fallback embutido se o
73
+ // arquivo sumir, não for JSON válido ou não tiver `models` com entradas.
74
+ export function loadPriceReference(file = PRICING_FILE) {
75
+ try {
76
+ const models = JSON.parse(readFileSync(file, 'utf-8'))?.models;
77
+ if (models && typeof models === 'object' && Object.keys(models).length) {
78
+ return models;
79
+ }
80
+ } catch {
81
+ // arquivo ausente/corrompido — usa fallback.
82
+ }
83
+ return DEFAULT_PRICE_REFERENCE;
84
+ }
85
+
86
+ const PRICE_REFERENCE = loadPriceReference();
87
+
88
88
  const MODEL_ALIASES = {
89
89
  'gpt-5.6': 'gpt-5.6-sol',
90
90
  'gpt-5.6-sol': 'gpt-5.6-sol',
@@ -97,62 +97,62 @@ const MODEL_ALIASES = {
97
97
  'gpt-5.6-luna': 'gpt-5.6-luna',
98
98
  'gpt-5-6-luna': 'gpt-5.6-luna',
99
99
  'openai/gpt-5.6-luna': 'gpt-5.6-luna',
100
- 'gpt-5.5': 'gpt-5.5',
101
- 'gpt-5_5': 'gpt-5.5',
102
- 'openai/gpt-5.5': 'gpt-5.5',
103
- // Older/adjacent Codex model ids: priced approximately at the gpt-5.5 tier until confirmed
104
- // (better a close estimate than a silent $0). Codex sessions surface these via session_meta.
105
- 'gpt-5.4': 'gpt-5.5',
106
- 'gpt-5-4': 'gpt-5.5',
107
- 'gpt-5.4-mini': 'gpt-5.5',
108
- 'gpt-5.3-codex': 'gpt-5.5',
109
- 'gpt-5.3': 'gpt-5.5',
110
- 'openai/gpt-5.4': 'gpt-5.5',
111
- 'claude-opus-4.7': 'claude-opus-4.7',
112
- 'claude-opus-4-7': 'claude-opus-4.7',
113
- 'anthropic/claude-opus-4.7': 'claude-opus-4.7',
114
- 'anthropic/claude-opus-4-7': 'claude-opus-4.7',
115
- 'claude-opus-4.8': 'claude-opus-4.8',
116
- 'claude-opus-4-8': 'claude-opus-4.8',
117
- 'anthropic/claude-opus-4.8': 'claude-opus-4.8',
118
- 'anthropic/claude-opus-4-8': 'claude-opus-4.8',
119
- 'claude-sonnet-4.6': 'claude-sonnet-4.6',
120
- 'claude-sonnet-4-6': 'claude-sonnet-4.6',
121
- 'anthropic/claude-sonnet-4.6': 'claude-sonnet-4.6',
122
- 'anthropic/claude-sonnet-4-6': 'claude-sonnet-4.6',
123
- 'claude-sonnet-5': 'claude-sonnet-5',
124
- 'claude-sonnet-5-0': 'claude-sonnet-5',
125
- 'anthropic/claude-sonnet-5': 'claude-sonnet-5',
126
- 'claude-haiku-4.5': 'claude-haiku-4.5',
127
- 'claude-haiku-4-5': 'claude-haiku-4.5',
128
- 'claude-haiku-4-5-20251001': 'claude-haiku-4.5',
129
- 'anthropic/claude-haiku-4.5': 'claude-haiku-4.5',
130
- 'anthropic/claude-haiku-4-5': 'claude-haiku-4.5',
131
- 'claude-fable-5': 'claude-fable-5',
132
- 'claude-fable-5[1m]': 'claude-fable-5',
133
- 'anthropic/claude-fable-5': 'claude-fable-5',
134
- };
135
-
136
- const MANAGED_FRONTMATTER_KEYS = new Set([
137
- 'modelo',
138
- 'modelos',
139
- 'provedor_modelo',
140
- 'provedores_modelo',
141
- 'nivel_pensamento',
142
- 'prompts',
143
- 'tool_calls',
144
- 'tools_distinct',
145
- 'tools',
146
- 'chamadas_llm',
147
- 'tokens_input',
148
- 'tokens_cache_write',
149
- 'tokens_cached_input',
150
- 'tokens_output',
151
- 'tokens_reasoning',
152
- 'tokens_total',
153
- 'custo_modelo_label',
154
- 'custo_modelo_usd',
155
- 'custo_por_modelo',
100
+ 'gpt-5.5': 'gpt-5.5',
101
+ 'gpt-5_5': 'gpt-5.5',
102
+ 'openai/gpt-5.5': 'gpt-5.5',
103
+ // Older/adjacent Codex model ids: priced approximately at the gpt-5.5 tier until confirmed
104
+ // (better a close estimate than a silent $0). Codex sessions surface these via session_meta.
105
+ 'gpt-5.4': 'gpt-5.5',
106
+ 'gpt-5-4': 'gpt-5.5',
107
+ 'gpt-5.4-mini': 'gpt-5.5',
108
+ 'gpt-5.3-codex': 'gpt-5.5',
109
+ 'gpt-5.3': 'gpt-5.5',
110
+ 'openai/gpt-5.4': 'gpt-5.5',
111
+ 'claude-opus-4.7': 'claude-opus-4.7',
112
+ 'claude-opus-4-7': 'claude-opus-4.7',
113
+ 'anthropic/claude-opus-4.7': 'claude-opus-4.7',
114
+ 'anthropic/claude-opus-4-7': 'claude-opus-4.7',
115
+ 'claude-opus-4.8': 'claude-opus-4.8',
116
+ 'claude-opus-4-8': 'claude-opus-4.8',
117
+ 'anthropic/claude-opus-4.8': 'claude-opus-4.8',
118
+ 'anthropic/claude-opus-4-8': 'claude-opus-4.8',
119
+ 'claude-sonnet-4.6': 'claude-sonnet-4.6',
120
+ 'claude-sonnet-4-6': 'claude-sonnet-4.6',
121
+ 'anthropic/claude-sonnet-4.6': 'claude-sonnet-4.6',
122
+ 'anthropic/claude-sonnet-4-6': 'claude-sonnet-4.6',
123
+ 'claude-sonnet-5': 'claude-sonnet-5',
124
+ 'claude-sonnet-5-0': 'claude-sonnet-5',
125
+ 'anthropic/claude-sonnet-5': 'claude-sonnet-5',
126
+ 'claude-haiku-4.5': 'claude-haiku-4.5',
127
+ 'claude-haiku-4-5': 'claude-haiku-4.5',
128
+ 'claude-haiku-4-5-20251001': 'claude-haiku-4.5',
129
+ 'anthropic/claude-haiku-4.5': 'claude-haiku-4.5',
130
+ 'anthropic/claude-haiku-4-5': 'claude-haiku-4.5',
131
+ 'claude-fable-5': 'claude-fable-5',
132
+ 'claude-fable-5[1m]': 'claude-fable-5',
133
+ 'anthropic/claude-fable-5': 'claude-fable-5',
134
+ };
135
+
136
+ const MANAGED_FRONTMATTER_KEYS = new Set([
137
+ 'modelo',
138
+ 'modelos',
139
+ 'provedor_modelo',
140
+ 'provedores_modelo',
141
+ 'nivel_pensamento',
142
+ 'prompts',
143
+ 'tool_calls',
144
+ 'tools_distinct',
145
+ 'tools',
146
+ 'chamadas_llm',
147
+ 'tokens_input',
148
+ 'tokens_cache_write',
149
+ 'tokens_cached_input',
150
+ 'tokens_output',
151
+ 'tokens_reasoning',
152
+ 'tokens_total',
153
+ 'custo_modelo_label',
154
+ 'custo_modelo_usd',
155
+ 'custo_por_modelo',
156
156
  'usage_por_transcript',
157
157
  'subagents_count',
158
158
  'subagents_tokens_total',
@@ -163,356 +163,356 @@ const MANAGED_FRONTMATTER_KEYS = new Set([
163
163
  'custo_total_incl_subagents_usd',
164
164
  'observability_schema',
165
165
  'custo_por_modelo_json',
166
- // Legado: chaves antigas removidas ao reprocessar a sessão.
167
- 'custo_estimado_gpt55_usd',
168
- 'custo_estimado_opus47_usd',
169
- 'custo_delta_opus47_usd',
170
- 'usage_report',
171
- ]);
172
-
173
- // Convenção interna: campos disjuntos.
174
- // input = tokens de entrada NÃO cacheados; cached = cache read; cacheWrite = cache write
175
- // (cacheWrite1h = subparcela 1h, para custo 2x); thinking = tokens de raciocínio
176
- // (Claude: estimado de chars/3.5; Codex: reasoning_output_tokens, já contidos em output).
177
- export function emptyTokenUsage() {
178
- return {
179
- input: 0,
180
- cached: 0,
181
- cacheWrite: 0,
182
- cacheWrite1h: 0,
183
- output: 0,
184
- reasoning: 0,
185
- total: 0,
186
- };
187
- }
188
-
189
- // Formato Codex: cached_input_tokens é SUBCONJUNTO de input_tokens — separa aqui.
190
- export function normalizeCodexUsage(raw = {}) {
191
- const inputAll = Number(raw.input_tokens || 0);
192
- const cached = Math.min(Number(raw.cached_input_tokens || 0), inputAll);
193
- const output = Number(raw.output_tokens || 0);
194
- return {
195
- input: inputAll - cached,
196
- cached,
197
- cacheWrite: 0,
198
- cacheWrite1h: 0,
199
- output,
200
- reasoning: Number(raw.reasoning_output_tokens || 0),
201
- total: Number(raw.total_tokens || 0) || inputAll + output,
202
- };
203
- }
204
-
205
- // Formato Claude Code: campos já disjuntos.
206
- export function normalizeClaudeUsage(raw = {}) {
207
- const input = Number(raw.input_tokens || 0);
208
- const cached = Number(raw.cache_read_input_tokens || 0);
209
- const cacheWrite = Number(raw.cache_creation_input_tokens || 0);
210
- const cacheWrite1h = Number(raw.cache_creation?.ephemeral_1h_input_tokens || 0);
211
- const output = Number(raw.output_tokens || 0);
212
- return {
213
- input,
214
- cached,
215
- cacheWrite,
216
- cacheWrite1h: Math.min(cacheWrite1h, cacheWrite),
217
- output,
218
- reasoning: 0,
219
- total: input + cached + cacheWrite + output,
220
- };
221
- }
222
-
223
- export function addUsage(target, usage) {
224
- target.input += usage.input;
225
- target.cached += usage.cached;
226
- target.cacheWrite += usage.cacheWrite;
227
- target.cacheWrite1h += usage.cacheWrite1h;
228
- target.output += usage.output;
229
- target.reasoning += usage.reasoning;
230
- target.total += usage.total;
231
- }
232
-
233
- function normalizeModelName(model) {
234
- const clean = String(model || 'unknown').trim() || 'unknown';
235
- // Strip a trailing context-window tag (e.g. `claude-opus-4-8[1m]`, `claude-fable-5[1m]`) so the
236
- // 1M variant of ANY model maps to its base price instead of falling through to $0.
237
- const lower = clean.toLowerCase().replace(/\[[^\]]*\]$/, '');
238
- if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
239
- // Fallback: remove sufixo de data (ex.: claude-opus-4-8-20260528) e tenta de novo.
240
- const noDate = lower.replace(/-\d{8}$/, '');
241
- return MODEL_ALIASES[noDate] || clean;
242
- }
243
-
244
- function normalizeProvider(provider) {
245
- return String(provider || 'unknown').trim() || 'unknown';
246
- }
247
-
248
- function calculateCost(usage, price) {
249
- const inputCost = (usage.input / 1_000_000) * price.input;
250
- const cachedCost = (usage.cached / 1_000_000) * price.cachedInput;
251
- const write1h = usage.cacheWrite1h;
252
- const write5m = Math.max(usage.cacheWrite - write1h, 0);
253
- const writeCost = ((write5m * 1.25 + write1h * 2) / 1_000_000) * price.input;
254
- const outputCost = (usage.output / 1_000_000) * price.output;
255
- return roundUsd(inputCost + cachedCost + writeCost + outputCost);
256
- }
257
-
258
- function roundUsd(value) {
259
- return Math.round(Number(value || 0) * 10000) / 10000;
260
- }
261
-
262
- // Resolve o nome do modelo (com aliases/sufixo de data) para a tabela de preços.
263
- // Devolve null quando o modelo não está tabelado.
264
- export function priceForModel(model) {
265
- return PRICE_REFERENCE[normalizeModelName(model)] || null;
266
- }
267
-
268
- // Custo USD por tipo de uso (mesmos multiplicadores de cache do calculateCost).
269
- // null quando o preço do modelo é desconhecido.
270
- export function costBreakdown(usage, price) {
271
- if (!price) return null;
272
- const u = usage || {};
273
- const write1h = u.cacheWrite1h || 0;
274
- const write5m = Math.max((u.cacheWrite || 0) - write1h, 0);
275
- const input = (u.input / 1_000_000) * price.input;
276
- const cached = (u.cached / 1_000_000) * price.cachedInput;
277
- const cacheWrite = ((write5m * 1.25 + write1h * 2) / 1_000_000) * price.input;
278
- const output = (u.output / 1_000_000) * price.output;
279
- return { input, cached, cacheWrite, output, total: input + cached + cacheWrite + output };
280
- }
281
-
282
- function escapeTableCell(value) {
283
- return String(value ?? '')
284
- .replace(/\r?\n/g, ' ')
285
- .replace(/\|/g, '\\|')
286
- .trim();
287
- }
288
-
289
- // Formata inteiros com separador de milhar pt-BR (3656657 -> 3.656.657).
290
- function fmtNum(value) {
291
- const n = Math.trunc(Number(value) || 0);
292
- return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
293
- }
294
-
295
- function addUnique(list, value) {
296
- const clean = String(value || '').trim();
297
- if (clean && !list.includes(clean)) list.push(clean);
298
- }
299
-
300
- function parseJsonLine(line) {
301
- try {
302
- return JSON.parse(line);
303
- } catch {
304
- return null;
305
- }
306
- }
307
-
308
- function extractTextContent(content) {
309
- if (typeof content === 'string') return content;
310
- if (!Array.isArray(content)) return '';
311
- return content
312
- .map((item) => item?.text || item?.input_text || item?.output_text || '')
313
- .filter(Boolean)
314
- .join('\n');
315
- }
316
-
317
- function shouldIgnoreUserText(text) {
318
- return /^# AGENTS\.md instructions/.test(text)
319
- || text.startsWith('<environment_context>')
320
- || text.startsWith('<permissions instructions>')
321
- || text.startsWith('<system-reminder>')
322
- || text.startsWith('<local-command-caveat>')
323
- || text.startsWith('<command-name>')
324
- || text.startsWith('<ide_')
325
- || text.startsWith('## Memory')
326
- || text.includes('You are Codex, a coding agent')
327
- || /^Generate a concise( UI)? title/i.test(text)
328
- || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(text);
329
- }
330
-
331
- function emptyParseResult(transcriptPath) {
332
- return {
333
- transcriptPath,
334
- sessionId: '',
335
- provider: 'unknown',
336
- model: 'unknown',
337
- pensamento: '',
338
- userPrompts: [],
339
- tools: [],
340
- toolCalls: 0,
341
- calls: [],
342
- byModel: new Map(),
343
- totals: emptyTokenUsage(),
344
- };
345
- }
346
-
347
- function trackByModel(result, provider, model, usage) {
348
- const key = `${provider}:${model}`;
349
- if (!result.byModel.has(key)) {
350
- result.byModel.set(key, { model, provider, calls: 0, usage: emptyTokenUsage() });
351
- }
352
- const entry = result.byModel.get(key);
353
- entry.calls += 1;
354
- addUsage(entry.usage, usage);
355
- }
356
-
357
- function parseCodexLines(lines, result) {
358
- let currentProvider = 'unknown';
359
- let currentModel = 'unknown';
360
- let latestPrompt = '';
361
-
362
- for (const line of lines) {
363
- const event = parseJsonLine(line);
364
- if (!event) continue;
365
-
366
- const payload = event.payload || {};
367
-
368
- if (event.type === 'session_meta') {
369
- result.sessionId = payload.id || result.sessionId;
370
- currentProvider = normalizeProvider(payload.model_provider || currentProvider);
371
- currentModel = normalizeModelName(payload.model || currentModel);
372
- result.provider = currentProvider;
373
- result.model = currentModel;
374
- continue;
375
- }
376
-
377
- if (event.type === 'turn_context') {
378
- currentProvider = normalizeProvider(payload.model_provider || currentProvider);
379
- currentModel = normalizeModelName(payload.model || currentModel);
380
- result.provider = currentProvider;
381
- result.model = currentModel;
382
- const effort = payload.effort || payload.reasoning_effort
383
- || payload.collaboration_mode?.settings?.reasoning_effort || '';
384
- if (effort) result.pensamento = String(effort);
385
- continue;
386
- }
387
-
388
- if (event.type === 'event_msg' && payload.type === 'user_message') {
389
- const text = String(payload.message || '').trim();
390
- if (text && !shouldIgnoreUserText(text)) {
391
- latestPrompt = text;
392
- addUnique(result.userPrompts, text);
393
- }
394
- continue;
395
- }
396
-
397
- if (event.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
398
- const text = extractTextContent(payload.content).trim();
399
- if (text && !shouldIgnoreUserText(text)) {
400
- latestPrompt = text;
401
- addUnique(result.userPrompts, text);
402
- }
403
- continue;
404
- }
405
-
406
- if (event.type === 'response_item' && payload.type === 'function_call') {
407
- result.toolCalls += 1;
408
- addUnique(result.tools, payload.name || 'function_call');
409
- continue;
410
- }
411
-
412
- if (event.type === 'response_item' && payload.type === 'tool_search_call') {
413
- result.toolCalls += 1;
414
- addUnique(result.tools, 'tool_search');
415
- continue;
416
- }
417
-
418
- if (event.type === 'response_item' && payload.type === 'web_search_call') {
419
- result.toolCalls += 1;
420
- addUnique(result.tools, 'web_search');
421
- continue;
422
- }
423
-
424
- if (event.type !== 'event_msg' || payload.type !== 'token_count') continue;
425
-
426
- const info = payload.info || {};
427
- const rawUsage = info.last_token_usage;
428
- if (!rawUsage) continue;
429
-
430
- const usage = normalizeCodexUsage(rawUsage);
431
- const model = normalizeModelName(info.model || payload.model || currentModel);
432
- const provider = normalizeProvider(info.model_provider || payload.model_provider || currentProvider);
433
-
434
- result.calls.push({
435
- index: result.calls.length + 1,
436
- model,
437
- provider,
438
- usage,
439
- prompt: truncate(latestPrompt, 110),
440
- });
441
- addUsage(result.totals, usage);
442
- trackByModel(result, provider, model, usage);
443
- }
444
-
445
- return result;
446
- }
447
-
448
- // Transcript Claude Code: uma linha "assistant" por content block, repetindo o MESMO
449
- // requestId/message.id e a MESMA usage — dedupe obrigatório para não multiplicar tokens.
450
- function parseClaudeLines(lines, result) {
451
- result.provider = 'anthropic';
452
- const seenUsage = new Set();
453
- const seenTools = new Set();
166
+ // Legado: chaves antigas removidas ao reprocessar a sessão.
167
+ 'custo_estimado_gpt55_usd',
168
+ 'custo_estimado_opus47_usd',
169
+ 'custo_delta_opus47_usd',
170
+ 'usage_report',
171
+ ]);
172
+
173
+ // Convenção interna: campos disjuntos.
174
+ // input = tokens de entrada NÃO cacheados; cached = cache read; cacheWrite = cache write
175
+ // (cacheWrite1h = subparcela 1h, para custo 2x); thinking = tokens de raciocínio
176
+ // (Claude: estimado de chars/3.5; Codex: reasoning_output_tokens, já contidos em output).
177
+ export function emptyTokenUsage() {
178
+ return {
179
+ input: 0,
180
+ cached: 0,
181
+ cacheWrite: 0,
182
+ cacheWrite1h: 0,
183
+ output: 0,
184
+ reasoning: 0,
185
+ total: 0,
186
+ };
187
+ }
188
+
189
+ // Formato Codex: cached_input_tokens é SUBCONJUNTO de input_tokens — separa aqui.
190
+ export function normalizeCodexUsage(raw = {}) {
191
+ const inputAll = Number(raw.input_tokens || 0);
192
+ const cached = Math.min(Number(raw.cached_input_tokens || 0), inputAll);
193
+ const output = Number(raw.output_tokens || 0);
194
+ return {
195
+ input: inputAll - cached,
196
+ cached,
197
+ cacheWrite: 0,
198
+ cacheWrite1h: 0,
199
+ output,
200
+ reasoning: Number(raw.reasoning_output_tokens || 0),
201
+ total: Number(raw.total_tokens || 0) || inputAll + output,
202
+ };
203
+ }
204
+
205
+ // Formato Claude Code: campos já disjuntos.
206
+ export function normalizeClaudeUsage(raw = {}) {
207
+ const input = Number(raw.input_tokens || 0);
208
+ const cached = Number(raw.cache_read_input_tokens || 0);
209
+ const cacheWrite = Number(raw.cache_creation_input_tokens || 0);
210
+ const cacheWrite1h = Number(raw.cache_creation?.ephemeral_1h_input_tokens || 0);
211
+ const output = Number(raw.output_tokens || 0);
212
+ return {
213
+ input,
214
+ cached,
215
+ cacheWrite,
216
+ cacheWrite1h: Math.min(cacheWrite1h, cacheWrite),
217
+ output,
218
+ reasoning: 0,
219
+ total: input + cached + cacheWrite + output,
220
+ };
221
+ }
222
+
223
+ export function addUsage(target, usage) {
224
+ target.input += usage.input;
225
+ target.cached += usage.cached;
226
+ target.cacheWrite += usage.cacheWrite;
227
+ target.cacheWrite1h += usage.cacheWrite1h;
228
+ target.output += usage.output;
229
+ target.reasoning += usage.reasoning;
230
+ target.total += usage.total;
231
+ }
232
+
233
+ function normalizeModelName(model) {
234
+ const clean = String(model || 'unknown').trim() || 'unknown';
235
+ // Strip a trailing context-window tag (e.g. `claude-opus-4-8[1m]`, `claude-fable-5[1m]`) so the
236
+ // 1M variant of ANY model maps to its base price instead of falling through to $0.
237
+ const lower = clean.toLowerCase().replace(/\[[^\]]*\]$/, '');
238
+ if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
239
+ // Fallback: remove sufixo de data (ex.: claude-opus-4-8-20260528) e tenta de novo.
240
+ const noDate = lower.replace(/-\d{8}$/, '');
241
+ return MODEL_ALIASES[noDate] || clean;
242
+ }
243
+
244
+ function normalizeProvider(provider) {
245
+ return String(provider || 'unknown').trim() || 'unknown';
246
+ }
247
+
248
+ function calculateCost(usage, price) {
249
+ const inputCost = (usage.input / 1_000_000) * price.input;
250
+ const cachedCost = (usage.cached / 1_000_000) * price.cachedInput;
251
+ const write1h = usage.cacheWrite1h;
252
+ const write5m = Math.max(usage.cacheWrite - write1h, 0);
253
+ const writeCost = ((write5m * 1.25 + write1h * 2) / 1_000_000) * price.input;
254
+ const outputCost = (usage.output / 1_000_000) * price.output;
255
+ return roundUsd(inputCost + cachedCost + writeCost + outputCost);
256
+ }
257
+
258
+ function roundUsd(value) {
259
+ return Math.round(Number(value || 0) * 10000) / 10000;
260
+ }
261
+
262
+ // Resolve o nome do modelo (com aliases/sufixo de data) para a tabela de preços.
263
+ // Devolve null quando o modelo não está tabelado.
264
+ export function priceForModel(model) {
265
+ return PRICE_REFERENCE[normalizeModelName(model)] || null;
266
+ }
267
+
268
+ // Custo USD por tipo de uso (mesmos multiplicadores de cache do calculateCost).
269
+ // null quando o preço do modelo é desconhecido.
270
+ export function costBreakdown(usage, price) {
271
+ if (!price) return null;
272
+ const u = usage || {};
273
+ const write1h = u.cacheWrite1h || 0;
274
+ const write5m = Math.max((u.cacheWrite || 0) - write1h, 0);
275
+ const input = (u.input / 1_000_000) * price.input;
276
+ const cached = (u.cached / 1_000_000) * price.cachedInput;
277
+ const cacheWrite = ((write5m * 1.25 + write1h * 2) / 1_000_000) * price.input;
278
+ const output = (u.output / 1_000_000) * price.output;
279
+ return { input, cached, cacheWrite, output, total: input + cached + cacheWrite + output };
280
+ }
281
+
282
+ function escapeTableCell(value) {
283
+ return String(value ?? '')
284
+ .replace(/\r?\n/g, ' ')
285
+ .replace(/\|/g, '\\|')
286
+ .trim();
287
+ }
288
+
289
+ // Formata inteiros com separador de milhar pt-BR (3656657 -> 3.656.657).
290
+ function fmtNum(value) {
291
+ const n = Math.trunc(Number(value) || 0);
292
+ return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
293
+ }
294
+
295
+ function addUnique(list, value) {
296
+ const clean = String(value || '').trim();
297
+ if (clean && !list.includes(clean)) list.push(clean);
298
+ }
299
+
300
+ function parseJsonLine(line) {
301
+ try {
302
+ return JSON.parse(line);
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ function extractTextContent(content) {
309
+ if (typeof content === 'string') return content;
310
+ if (!Array.isArray(content)) return '';
311
+ return content
312
+ .map((item) => item?.text || item?.input_text || item?.output_text || '')
313
+ .filter(Boolean)
314
+ .join('\n');
315
+ }
316
+
317
+ function shouldIgnoreUserText(text) {
318
+ return /^# AGENTS\.md instructions/.test(text)
319
+ || text.startsWith('<environment_context>')
320
+ || text.startsWith('<permissions instructions>')
321
+ || text.startsWith('<system-reminder>')
322
+ || text.startsWith('<local-command-caveat>')
323
+ || text.startsWith('<command-name>')
324
+ || text.startsWith('<ide_')
325
+ || text.startsWith('## Memory')
326
+ || text.includes('You are Codex, a coding agent')
327
+ || /^Generate a concise( UI)? title/i.test(text)
328
+ || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(text);
329
+ }
330
+
331
+ function emptyParseResult(transcriptPath) {
332
+ return {
333
+ transcriptPath,
334
+ sessionId: '',
335
+ provider: 'unknown',
336
+ model: 'unknown',
337
+ pensamento: '',
338
+ userPrompts: [],
339
+ tools: [],
340
+ toolCalls: 0,
341
+ calls: [],
342
+ byModel: new Map(),
343
+ totals: emptyTokenUsage(),
344
+ };
345
+ }
346
+
347
+ function trackByModel(result, provider, model, usage) {
348
+ const key = `${provider}:${model}`;
349
+ if (!result.byModel.has(key)) {
350
+ result.byModel.set(key, { model, provider, calls: 0, usage: emptyTokenUsage() });
351
+ }
352
+ const entry = result.byModel.get(key);
353
+ entry.calls += 1;
354
+ addUsage(entry.usage, usage);
355
+ }
356
+
357
+ function parseCodexLines(lines, result) {
358
+ let currentProvider = 'unknown';
359
+ let currentModel = 'unknown';
360
+ let latestPrompt = '';
361
+
362
+ for (const line of lines) {
363
+ const event = parseJsonLine(line);
364
+ if (!event) continue;
365
+
366
+ const payload = event.payload || {};
367
+
368
+ if (event.type === 'session_meta') {
369
+ result.sessionId = payload.session_id || payload.id || result.sessionId;
370
+ currentProvider = normalizeProvider(payload.model_provider || currentProvider);
371
+ currentModel = normalizeModelName(payload.model || currentModel);
372
+ result.provider = currentProvider;
373
+ result.model = currentModel;
374
+ continue;
375
+ }
376
+
377
+ if (event.type === 'turn_context') {
378
+ currentProvider = normalizeProvider(payload.model_provider || currentProvider);
379
+ currentModel = normalizeModelName(payload.model || currentModel);
380
+ result.provider = currentProvider;
381
+ result.model = currentModel;
382
+ const effort = payload.effort || payload.reasoning_effort
383
+ || payload.collaboration_mode?.settings?.reasoning_effort || '';
384
+ if (effort) result.pensamento = String(effort);
385
+ continue;
386
+ }
387
+
388
+ if (event.type === 'event_msg' && payload.type === 'user_message') {
389
+ const text = String(payload.message || '').trim();
390
+ if (text && !shouldIgnoreUserText(text)) {
391
+ latestPrompt = text;
392
+ addUnique(result.userPrompts, text);
393
+ }
394
+ continue;
395
+ }
396
+
397
+ if (event.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
398
+ const text = extractTextContent(payload.content).trim();
399
+ if (text && !shouldIgnoreUserText(text)) {
400
+ latestPrompt = text;
401
+ addUnique(result.userPrompts, text);
402
+ }
403
+ continue;
404
+ }
405
+
406
+ if (event.type === 'response_item' && payload.type === 'function_call') {
407
+ result.toolCalls += 1;
408
+ addUnique(result.tools, payload.name || 'function_call');
409
+ continue;
410
+ }
411
+
412
+ if (event.type === 'response_item' && payload.type === 'tool_search_call') {
413
+ result.toolCalls += 1;
414
+ addUnique(result.tools, 'tool_search');
415
+ continue;
416
+ }
417
+
418
+ if (event.type === 'response_item' && payload.type === 'web_search_call') {
419
+ result.toolCalls += 1;
420
+ addUnique(result.tools, 'web_search');
421
+ continue;
422
+ }
423
+
424
+ if (event.type !== 'event_msg' || payload.type !== 'token_count') continue;
425
+
426
+ const info = payload.info || {};
427
+ const rawUsage = info.last_token_usage;
428
+ if (!rawUsage) continue;
429
+
430
+ const usage = normalizeCodexUsage(rawUsage);
431
+ const model = normalizeModelName(info.model || payload.model || currentModel);
432
+ const provider = normalizeProvider(info.model_provider || payload.model_provider || currentProvider);
433
+
434
+ result.calls.push({
435
+ index: result.calls.length + 1,
436
+ model,
437
+ provider,
438
+ usage,
439
+ prompt: truncate(latestPrompt, 110),
440
+ });
441
+ addUsage(result.totals, usage);
442
+ trackByModel(result, provider, model, usage);
443
+ }
444
+
445
+ return result;
446
+ }
447
+
448
+ // Transcript Claude Code: uma linha "assistant" por content block, repetindo o MESMO
449
+ // requestId/message.id e a MESMA usage — dedupe obrigatório para não multiplicar tokens.
450
+ function parseClaudeLines(lines, result) {
451
+ result.provider = 'anthropic';
452
+ const seenUsage = new Set();
453
+ const seenTools = new Set();
454
454
  const seenThinking = new Set();
455
455
  let thinkingChars = 0;
456
456
  const thinkingCharsByModel = new Map();
457
- let latestPrompt = '';
458
-
459
- for (const line of lines) {
460
- const event = parseJsonLine(line);
461
- if (!event) continue;
462
-
463
- if (event.sessionId && !result.sessionId) result.sessionId = event.sessionId;
464
-
465
- if (event.type === 'user' && !event.toolUseResult && event.message) {
466
- const text = extractTextContent(event.message.content).trim();
467
- if (text && !shouldIgnoreUserText(text)) {
468
- latestPrompt = text;
469
- addUnique(result.userPrompts, text);
470
- }
471
- continue;
472
- }
473
-
474
- if (event.type !== 'assistant' || !event.message) continue;
475
-
476
- const msg = event.message;
477
- const model = normalizeModelName(msg.model);
478
- if (model === '<synthetic>' || msg.model === '<synthetic>') continue;
479
-
480
- for (const block of msg.content || []) {
481
- if (block?.type === 'tool_use' && block.id && !seenTools.has(block.id)) {
482
- seenTools.add(block.id);
483
- result.toolCalls += 1;
484
- addUnique(result.tools, block.name || 'tool_use');
485
- }
486
- if (block?.type === 'thinking' && block.thinking) {
487
- const thinkKey = `${msg.id || ''}:${block.thinking.slice(0, 60)}`;
488
- if (!seenThinking.has(thinkKey)) {
457
+ let latestPrompt = '';
458
+
459
+ for (const line of lines) {
460
+ const event = parseJsonLine(line);
461
+ if (!event) continue;
462
+
463
+ if (event.sessionId && !result.sessionId) result.sessionId = event.sessionId;
464
+
465
+ if (event.type === 'user' && !event.toolUseResult && event.message) {
466
+ const text = extractTextContent(event.message.content).trim();
467
+ if (text && !shouldIgnoreUserText(text)) {
468
+ latestPrompt = text;
469
+ addUnique(result.userPrompts, text);
470
+ }
471
+ continue;
472
+ }
473
+
474
+ if (event.type !== 'assistant' || !event.message) continue;
475
+
476
+ const msg = event.message;
477
+ const model = normalizeModelName(msg.model);
478
+ if (model === '<synthetic>' || msg.model === '<synthetic>') continue;
479
+
480
+ for (const block of msg.content || []) {
481
+ if (block?.type === 'tool_use' && block.id && !seenTools.has(block.id)) {
482
+ seenTools.add(block.id);
483
+ result.toolCalls += 1;
484
+ addUnique(result.tools, block.name || 'tool_use');
485
+ }
486
+ if (block?.type === 'thinking' && block.thinking) {
487
+ const thinkKey = `${msg.id || ''}:${block.thinking.slice(0, 60)}`;
488
+ if (!seenThinking.has(thinkKey)) {
489
489
  seenThinking.add(thinkKey);
490
490
  thinkingChars += block.thinking.length;
491
491
  thinkingCharsByModel.set(model, (thinkingCharsByModel.get(model) || 0) + block.thinking.length);
492
- }
493
- }
494
- }
495
-
496
- const usageKey = event.requestId || msg.id || '';
497
- if (!msg.usage || !usageKey || seenUsage.has(usageKey)) continue;
498
- seenUsage.add(usageKey);
499
-
500
- const usage = normalizeClaudeUsage(msg.usage);
501
- result.calls.push({
502
- index: result.calls.length + 1,
503
- model,
504
- provider: 'anthropic',
505
- usage,
506
- prompt: truncate(latestPrompt, 110),
507
- });
508
- addUsage(result.totals, usage);
509
- trackByModel(result, 'anthropic', model, usage);
510
- result.model = model;
511
- }
512
-
513
- // Thinking estimado: ~3,5 chars por token. Distribuído no total como informação à parte
514
- // (já contido em output_tokens — não somar de novo).
515
- const thinkingTokens = Math.round(thinkingChars / 3.5);
492
+ }
493
+ }
494
+ }
495
+
496
+ const usageKey = event.requestId || msg.id || '';
497
+ if (!msg.usage || !usageKey || seenUsage.has(usageKey)) continue;
498
+ seenUsage.add(usageKey);
499
+
500
+ const usage = normalizeClaudeUsage(msg.usage);
501
+ result.calls.push({
502
+ index: result.calls.length + 1,
503
+ model,
504
+ provider: 'anthropic',
505
+ usage,
506
+ prompt: truncate(latestPrompt, 110),
507
+ });
508
+ addUsage(result.totals, usage);
509
+ trackByModel(result, 'anthropic', model, usage);
510
+ result.model = model;
511
+ }
512
+
513
+ // Thinking estimado: ~3,5 chars por token. Distribuído no total como informação à parte
514
+ // (já contido em output_tokens — não somar de novo).
515
+ const thinkingTokens = Math.round(thinkingChars / 3.5);
516
516
  if (thinkingTokens > 0) {
517
517
  result.totals.reasoning = thinkingTokens;
518
518
  result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
@@ -521,408 +521,408 @@ function parseClaudeLines(lines, result) {
521
521
  if (entry) entry.usage.reasoning = Math.round(chars / 3.5);
522
522
  }
523
523
  }
524
-
525
- return result;
526
- }
527
-
528
- function formatTokensShort(n) {
529
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
530
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
531
- return String(n);
532
- }
533
-
534
- function detectTranscriptFormat(lines) {
535
- for (const line of lines.slice(0, 20)) {
536
- const event = parseJsonLine(line);
537
- if (!event) continue;
538
- if (event.type === 'session_meta' || event.type === 'turn_context'
539
- || event.type === 'event_msg' || event.type === 'response_item') return 'codex';
540
- if (event.type === 'assistant' || event.type === 'queue-operation'
541
- || event.type === 'file-history-snapshot' || event.type === 'last-prompt') return 'claude';
542
- }
543
- return 'codex';
544
- }
545
-
546
- export function parseTokenUsageFromTranscript(transcriptPath) {
547
- const result = emptyParseResult(transcriptPath);
548
- if (!transcriptPath || !existsSync(transcriptPath)) return result;
549
-
550
- const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
551
- return detectTranscriptFormat(lines) === 'claude'
552
- ? parseClaudeLines(lines, result)
553
- : parseCodexLines(lines, result);
554
- }
555
-
556
- function modelCost(usage, model) {
557
- const normalized = normalizeModelName(model);
558
- const price = PRICE_REFERENCE[normalized];
559
- return price ? calculateCost(usage, price) : 0;
560
- }
561
-
562
- export function summarizeTokenUsage(parsed) {
563
- const totals = parsed.totals;
564
- const modelRows = [...parsed.byModel.values()].map((entry) => ({
565
- ...entry,
566
- costs: {
567
- model: modelCost(entry.usage, entry.model),
568
- },
569
- }));
570
- const modelCostTotal = roundUsd(modelRows.reduce((sum, row) => sum + row.costs.model, 0));
571
- const modelLabel = modelRows.length === 1 ? modelRows[0].model : parsed.model;
572
-
573
- return {
574
- sessionId: parsed.sessionId,
575
- transcriptPath: parsed.transcriptPath,
576
- pensamento: parsed.pensamento || '',
577
- prompts: parsed.userPrompts.length,
578
- toolCalls: parsed.toolCalls,
579
- tools: parsed.tools,
580
- calls: parsed.calls.length,
581
- models: [...new Set(modelRows.map((row) => row.model))],
582
- providers: [...new Set(modelRows.map((row) => row.provider))],
583
- totals,
584
- costs: {
585
- model: modelCostTotal,
586
- modelLabel,
587
- },
588
- modelRows,
589
- callsTable: parsed.calls,
590
- };
591
- }
592
-
593
- // ---------------------------------------------------------------------------
594
- // Histórico por transcript (reaberturas): cada conversa tem transcript próprio.
595
- // O frontmatter guarda uma entrada por transcript; os campos planos são a soma.
596
- // ---------------------------------------------------------------------------
597
-
598
- function transcriptIdFromPath(transcriptPath) {
599
- return basename(String(transcriptPath || '')).replace(/\.jsonl?$/i, '') || 'desconhecido';
600
- }
601
-
602
- function entryFromSummary(summary, transcriptId) {
603
- return {
604
- transcript_id: transcriptId,
605
- provider: summary.providers.join(' + ') || 'unknown',
606
- modelos: summary.models,
607
- pensamento: summary.pensamento || '',
608
- input: summary.totals.input,
609
- cache_write: summary.totals.cacheWrite,
610
- cache_read: summary.totals.cached,
611
- output: summary.totals.output,
612
- reasoning: summary.totals.reasoning,
613
- total: summary.totals.total,
614
- custo_usd: summary.costs.model,
615
- prompts: summary.prompts,
616
- tool_calls: summary.toolCalls,
617
- chamadas_llm: summary.calls,
618
- tools: summary.tools,
619
- atualizado_em: new Date().toISOString().slice(0, 19),
620
- };
621
- }
622
-
623
- // Parser do bloco YAML `usage_por_transcript` gerado por este próprio script (formato fixo).
624
- function parseUsageHistory(frontmatter) {
625
- const match = frontmatter.match(/^usage_por_transcript:\n((?:[ ]{2,}.*\n?)*)/m);
626
- if (!match) return [];
627
-
628
- const entries = [];
629
- let current = null;
630
- for (const rawLine of match[1].split('\n')) {
631
- const line = rawLine.trimEnd();
632
- if (!line.trim()) continue;
633
- const itemStart = line.match(/^[ ]{2}- transcript_id:\s*(.*)$/);
634
- if (itemStart) {
635
- current = { transcript_id: stripYamlScalar(itemStart[1]), modelos: [], tools: [] };
636
- entries.push(current);
637
- continue;
638
- }
639
- if (!current) continue;
640
- const listItem = line.match(/^[ ]{6}- (.*)$/);
641
- if (listItem && current._listKey) {
642
- current[current._listKey].push(stripYamlScalar(listItem[1]));
643
- continue;
644
- }
645
- const kv = line.match(/^[ ]{4}([a-z_]+):\s*(.*)$/);
646
- if (!kv) continue;
647
- const [, key, value] = kv;
648
- if (key === 'modelos' || key === 'tools') {
649
- current._listKey = key;
650
- current[key] = [];
651
- continue;
652
- }
653
- current._listKey = null;
654
- current[key] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : stripYamlScalar(value);
655
- }
656
- return entries.map(({ _listKey, ...entry }) => entry);
657
- }
658
-
659
- function stripYamlScalar(value) {
660
- return String(value || '').trim().replace(/^["']|["']$/g, '');
661
- }
662
-
663
- function aggregateEntries(entries) {
664
- const agg = {
665
- input: 0, cacheWrite: 0, cached: 0, output: 0, reasoning: 0, total: 0,
666
- custo: 0, prompts: 0, toolCalls: 0, calls: 0,
667
- models: [], providers: [], pensamentos: [], tools: [],
668
- };
669
- for (const e of entries) {
670
- agg.input += Number(e.input || 0);
671
- agg.cacheWrite += Number(e.cache_write || 0);
672
- agg.cached += Number(e.cache_read || 0);
673
- agg.output += Number(e.output || 0);
674
- agg.reasoning += Number(e.reasoning || 0);
675
- agg.total += Number(e.total || 0);
676
- agg.custo = roundUsd(agg.custo + Number(e.custo_usd || 0));
677
- agg.prompts += Number(e.prompts || 0);
678
- agg.toolCalls += Number(e.tool_calls || 0);
679
- agg.calls += Number(e.chamadas_llm || 0);
680
- for (const m of e.modelos || []) addUnique(agg.models, m);
681
- addUnique(agg.providers, e.provider);
682
- if (e.pensamento) addUnique(agg.pensamentos, e.pensamento);
683
- for (const t of e.tools || []) addUnique(agg.tools, t);
684
- }
685
- return agg;
686
- }
687
-
688
- function extractFrontmatterValue(content, key) {
689
- const match = content.match(/^---\n([\s\S]*?)\n---/);
690
- if (!match) return '';
691
- const line = match[1].match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
692
- return line ? line[1].trim().replace(/^["']|["']$/g, '') : '';
693
- }
694
-
695
- function stripManagedFrontmatter(frontmatter) {
696
- const lines = frontmatter.split('\n');
697
- const kept = [];
698
- let skipping = false;
699
-
700
- for (const line of lines) {
701
- const root = line.match(/^([A-Za-z0-9_]+):/);
702
- if (root) {
703
- skipping = MANAGED_FRONTMATTER_KEYS.has(root[1]);
704
- if (!skipping) kept.push(line);
705
- continue;
706
- }
707
-
708
- if (!skipping) kept.push(line);
709
- }
710
-
711
- return kept.join('\n').trimEnd();
712
- }
713
-
714
- function yamlList(key, values, indent = '') {
715
- if (!values.length) return `${indent}${key}: []`;
716
- return `${indent}${key}:\n${values.map((value) => `${indent} - "${String(value).replace(/"/g, '\\"')}"`).join('\n')}`;
717
- }
718
-
719
- function buildUsageFrontmatter(agg, entries) {
720
- const model = agg.models.length === 1 ? agg.models[0] : agg.models.join(' + ');
721
- const provider = agg.providers.length === 1 ? agg.providers[0] : agg.providers.join(' + ');
722
-
723
- const historyYaml = entries.length
724
- ? [
725
- 'usage_por_transcript:',
726
- ...entries.flatMap((e) => [
727
- ` - transcript_id: "${e.transcript_id}"`,
728
- ` provider: "${e.provider || 'unknown'}"`,
729
- yamlList('modelos', e.modelos || [], ' '),
730
- ` pensamento: "${e.pensamento || ''}"`,
731
- ` input: ${e.input || 0}`,
732
- ` cache_write: ${e.cache_write || 0}`,
733
- ` cache_read: ${e.cache_read || 0}`,
734
- ` output: ${e.output || 0}`,
735
- ` reasoning: ${e.reasoning || 0}`,
736
- ` total: ${e.total || 0}`,
737
- ` custo_usd: ${e.custo_usd || 0}`,
738
- ` prompts: ${e.prompts || 0}`,
739
- ` tool_calls: ${e.tool_calls || 0}`,
740
- ` chamadas_llm: ${e.chamadas_llm || 0}`,
741
- yamlList('tools', e.tools || [], ' '),
742
- ` atualizado_em: "${e.atualizado_em || ''}"`,
743
- ]),
744
- ].join('\n')
745
- : 'usage_por_transcript: []';
746
-
747
- return [
748
- `modelo: "${model || 'unknown'}"`,
749
- yamlList('modelos', agg.models),
750
- `provedor_modelo: "${provider || 'unknown'}"`,
751
- yamlList('provedores_modelo', agg.providers),
752
- `nivel_pensamento: "${agg.pensamentos.join(' + ')}"`,
753
- `prompts: ${agg.prompts}`,
754
- `tool_calls: ${agg.toolCalls}`,
755
- `tools_distinct: ${agg.tools.length}`,
756
- yamlList('tools', agg.tools),
757
- `chamadas_llm: ${agg.calls}`,
758
- `tokens_input: ${agg.input}`,
759
- `tokens_cache_write: ${agg.cacheWrite}`,
760
- `tokens_cached_input: ${agg.cached}`,
761
- `tokens_output: ${agg.output}`,
762
- `tokens_reasoning: ${agg.reasoning}`,
763
- `tokens_total: ${agg.total}`,
764
- `custo_modelo_label: "${model || 'unknown'}"`,
765
- `custo_modelo_usd: ${agg.custo}`,
766
- historyYaml,
767
- ].join('\n');
768
- }
769
-
770
- function upsertSessionFrontmatter(content, agg, entries) {
771
- const managedYaml = buildUsageFrontmatter(agg, entries);
772
- const match = content.match(/^---\n([\s\S]*?)\n---/);
773
- if (!match) return `---\n${managedYaml}\n---\n\n${content}`;
774
-
775
- const clean = stripManagedFrontmatter(match[1]);
776
- const nextFrontmatter = [clean, managedYaml].filter(Boolean).join('\n');
777
- return `---\n${nextFrontmatter}\n---${content.slice(match[0].length)}`;
778
- }
779
-
780
- function buildModelTable(summary) {
781
- if (!summary.modelRows.length) return 'Nenhum modelo registrado.';
782
-
783
- return [
784
- '| Modelo | Provider | Chamadas | Input | Cache W | Cache R | Output | Reasoning | Total | Custo |',
785
- '|---|---|---:|---:|---:|---:|---:|---:|---:|---:|',
786
- ...summary.modelRows.map((row) => [
787
- `| ${escapeTableCell(row.model)}`,
788
- escapeTableCell(row.provider),
789
- fmtNum(row.calls),
790
- fmtNum(row.usage.input),
791
- fmtNum(row.usage.cacheWrite),
792
- fmtNum(row.usage.cached),
793
- fmtNum(row.usage.output),
794
- fmtNum(row.usage.reasoning),
795
- fmtNum(row.usage.total),
796
- `$${row.costs.model.toFixed(4)} |`,
797
- ].join(' | ')),
798
- ].join('\n');
799
- }
800
-
801
- function buildHistoryTable(entries) {
802
- if (!entries.length) return 'Nenhuma reabertura registrada.';
803
-
804
- return [
805
- '| Transcript | Modelo(s) | Pensamento | Input | Cache W | Cache R | Output | Total | Custo | Atualizado |',
806
- '|---|---|---|---:|---:|---:|---:|---:|---:|---|',
807
- ...entries.map((e) => [
808
- `| ${escapeTableCell(String(e.transcript_id).slice(0, 12))}…`,
809
- escapeTableCell((e.modelos || []).join(' + ')),
810
- escapeTableCell(e.pensamento || '-'),
811
- fmtNum(e.input),
812
- fmtNum(e.cache_write),
813
- fmtNum(e.cache_read),
814
- fmtNum(e.output),
815
- fmtNum(e.total),
816
- `$${Number(e.custo_usd || 0).toFixed(4)}`,
817
- `${escapeTableCell(e.atualizado_em || '')} |`,
818
- ].join(' | ')),
819
- ].join('\n');
820
- }
821
-
822
- function buildUsageSection(agg, entries, summary) {
823
- return `## Uso de tokens e custos
824
-
825
- > Estimativa API-equivalente baseada nos transcripts locais (Codex/Claude Code). Não representa cobrança real do plano.
826
-
827
- | Métrica | Valor |
828
- |---|---:|
829
- | Prompts | ${agg.prompts} |
830
- | Ferramentas | ${agg.tools.length} tools / ${agg.toolCalls} calls |
831
- | Chamadas com uso | ${fmtNum(agg.calls)} |
832
- | Input tokens (não cacheados) | ${fmtNum(agg.input)} |
833
- | Cache write tokens | ${fmtNum(agg.cacheWrite)} |
834
- | Cache read tokens | ${fmtNum(agg.cached)} |
835
- | Output tokens | ${fmtNum(agg.output)} |
836
- | Thinking/reasoning tokens | ${fmtNum(agg.reasoning)} |
837
- | Total tokens | ${fmtNum(agg.total)} |
838
- | Modelo(s) | ${agg.models.join(' + ') || 'unknown'} |
839
- | Nível de pensamento | ${agg.pensamentos.join(' + ') || '-'} |
840
- | Custo estimado | $${agg.custo.toFixed(4)} |
841
-
842
- ### Por reabertura
843
-
844
- ${buildHistoryTable(entries)}
845
-
846
- ### Por modelo (transcript atual)
847
-
848
- ${buildModelTable(summary)}
849
- `;
850
- }
851
-
852
- export function upsertUsageSection(content, section) {
853
- // Normaliza espaçamento nos dois caminhos (inserção e substituição) para manter idempotência.
854
- const assemble = (head, rest) => (rest
855
- ? `${head.trimEnd()}\n\n${section.trimEnd()}\n\n${rest.trimStart()}`
856
- : `${head.trimEnd()}\n\n${section.trimEnd()}\n`);
857
-
858
- const marker = '\n## Uso de tokens e custos';
859
- const existing = content.indexOf(marker);
860
- let base = content;
861
-
862
- if (existing !== -1) {
863
- const next = content.indexOf('\n## ', existing + marker.length);
864
- const usageTail = next === -1 ? content.slice(existing) : content.slice(existing, next);
865
- const orphanIteration = usageTail.search(/\n### \d{2}:\d{2} - /);
866
- const preservedTail = orphanIteration === -1 ? '' : usageTail.slice(orphanIteration).trim();
867
- const rest = next === -1 ? '' : content.slice(next).trimStart();
868
- base = [
869
- content.slice(0, existing).trimEnd(),
870
- preservedTail,
871
- rest,
872
- ].filter(Boolean).join('\n\n');
873
- }
874
-
875
- const anchor = base.includes('\n## Pendências')
876
- ? '\n## Pendências'
877
- : '\n## Encerramento';
878
- const anchorIndex = base.indexOf(anchor);
879
- if (anchorIndex === -1) return assemble(base, '');
880
- return assemble(base.slice(0, anchorIndex), base.slice(anchorIndex));
881
- }
882
-
883
- // Migração: nota antiga com totais planos mas sem usage_por_transcript.
884
- // Preserva como entrada "legado", exceto quando o transcript atual parece ser a
885
- // mesma conversa que gerou os totais (mesmos modelos) — aí descarta para não duplicar.
886
- function legacyEntryFromNote(content, summary) {
887
- const total = Number(extractFrontmatterValue(content, 'tokens_total') || 0);
888
- if (!total) return null;
889
-
890
- const legacyModel = extractFrontmatterValue(content, 'modelo');
891
- const currentModels = summary.models.join(' + ');
892
- if (legacyModel && legacyModel === currentModels) return null;
893
-
894
- return {
895
- transcript_id: 'legado',
896
- provider: extractFrontmatterValue(content, 'provedor_modelo') || 'unknown',
897
- modelos: legacyModel ? [legacyModel] : [],
898
- pensamento: '',
899
- input: Number(extractFrontmatterValue(content, 'tokens_input') || 0),
900
- cache_write: 0,
901
- cache_read: Number(extractFrontmatterValue(content, 'tokens_cached_input') || 0),
902
- output: Number(extractFrontmatterValue(content, 'tokens_output') || 0),
903
- reasoning: Number(extractFrontmatterValue(content, 'tokens_reasoning') || 0),
904
- total,
905
- custo_usd: Number(extractFrontmatterValue(content, 'custo_modelo_usd') || 0),
906
- prompts: Number(extractFrontmatterValue(content, 'prompts') || 0),
907
- tool_calls: Number(extractFrontmatterValue(content, 'tool_calls') || 0),
908
- chamadas_llm: Number(extractFrontmatterValue(content, 'chamadas_llm') || 0),
909
- tools: [],
910
- atualizado_em: '',
911
- };
912
- }
913
-
524
+
525
+ return result;
526
+ }
527
+
528
+ function formatTokensShort(n) {
529
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
530
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
531
+ return String(n);
532
+ }
533
+
534
+ function detectTranscriptFormat(lines) {
535
+ for (const line of lines.slice(0, 20)) {
536
+ const event = parseJsonLine(line);
537
+ if (!event) continue;
538
+ if (event.type === 'session_meta' || event.type === 'turn_context'
539
+ || event.type === 'event_msg' || event.type === 'response_item') return 'codex';
540
+ if (event.type === 'assistant' || event.type === 'queue-operation'
541
+ || event.type === 'file-history-snapshot' || event.type === 'last-prompt') return 'claude';
542
+ }
543
+ return 'codex';
544
+ }
545
+
546
+ export function parseTokenUsageFromTranscript(transcriptPath) {
547
+ const result = emptyParseResult(transcriptPath);
548
+ if (!transcriptPath || !existsSync(transcriptPath)) return result;
549
+
550
+ const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
551
+ return detectTranscriptFormat(lines) === 'claude'
552
+ ? parseClaudeLines(lines, result)
553
+ : parseCodexLines(lines, result);
554
+ }
555
+
556
+ function modelCost(usage, model) {
557
+ const normalized = normalizeModelName(model);
558
+ const price = PRICE_REFERENCE[normalized];
559
+ return price ? calculateCost(usage, price) : 0;
560
+ }
561
+
562
+ export function summarizeTokenUsage(parsed) {
563
+ const totals = parsed.totals;
564
+ const modelRows = [...parsed.byModel.values()].map((entry) => ({
565
+ ...entry,
566
+ costs: {
567
+ model: modelCost(entry.usage, entry.model),
568
+ },
569
+ }));
570
+ const modelCostTotal = roundUsd(modelRows.reduce((sum, row) => sum + row.costs.model, 0));
571
+ const modelLabel = modelRows.length === 1 ? modelRows[0].model : parsed.model;
572
+
573
+ return {
574
+ sessionId: parsed.sessionId,
575
+ transcriptPath: parsed.transcriptPath,
576
+ pensamento: parsed.pensamento || '',
577
+ prompts: parsed.userPrompts.length,
578
+ toolCalls: parsed.toolCalls,
579
+ tools: parsed.tools,
580
+ calls: parsed.calls.length,
581
+ models: [...new Set(modelRows.map((row) => row.model))],
582
+ providers: [...new Set(modelRows.map((row) => row.provider))],
583
+ totals,
584
+ costs: {
585
+ model: modelCostTotal,
586
+ modelLabel,
587
+ },
588
+ modelRows,
589
+ callsTable: parsed.calls,
590
+ };
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // Histórico por transcript (reaberturas): cada conversa tem transcript próprio.
595
+ // O frontmatter guarda uma entrada por transcript; os campos planos são a soma.
596
+ // ---------------------------------------------------------------------------
597
+
598
+ function transcriptIdFromPath(transcriptPath) {
599
+ return basename(String(transcriptPath || '')).replace(/\.jsonl?$/i, '') || 'desconhecido';
600
+ }
601
+
602
+ function entryFromSummary(summary, transcriptId) {
603
+ return {
604
+ transcript_id: transcriptId,
605
+ provider: summary.providers.join(' + ') || 'unknown',
606
+ modelos: summary.models,
607
+ pensamento: summary.pensamento || '',
608
+ input: summary.totals.input,
609
+ cache_write: summary.totals.cacheWrite,
610
+ cache_read: summary.totals.cached,
611
+ output: summary.totals.output,
612
+ reasoning: summary.totals.reasoning,
613
+ total: summary.totals.total,
614
+ custo_usd: summary.costs.model,
615
+ prompts: summary.prompts,
616
+ tool_calls: summary.toolCalls,
617
+ chamadas_llm: summary.calls,
618
+ tools: summary.tools,
619
+ atualizado_em: new Date().toISOString().slice(0, 19),
620
+ };
621
+ }
622
+
623
+ // Parser do bloco YAML `usage_por_transcript` gerado por este próprio script (formato fixo).
624
+ function parseUsageHistory(frontmatter) {
625
+ const match = frontmatter.match(/^usage_por_transcript:\n((?:[ ]{2,}.*\n?)*)/m);
626
+ if (!match) return [];
627
+
628
+ const entries = [];
629
+ let current = null;
630
+ for (const rawLine of match[1].split('\n')) {
631
+ const line = rawLine.trimEnd();
632
+ if (!line.trim()) continue;
633
+ const itemStart = line.match(/^[ ]{2}- transcript_id:\s*(.*)$/);
634
+ if (itemStart) {
635
+ current = { transcript_id: stripYamlScalar(itemStart[1]), modelos: [], tools: [] };
636
+ entries.push(current);
637
+ continue;
638
+ }
639
+ if (!current) continue;
640
+ const listItem = line.match(/^[ ]{6}- (.*)$/);
641
+ if (listItem && current._listKey) {
642
+ current[current._listKey].push(stripYamlScalar(listItem[1]));
643
+ continue;
644
+ }
645
+ const kv = line.match(/^[ ]{4}([a-z_]+):\s*(.*)$/);
646
+ if (!kv) continue;
647
+ const [, key, value] = kv;
648
+ if (key === 'modelos' || key === 'tools') {
649
+ current._listKey = key;
650
+ current[key] = [];
651
+ continue;
652
+ }
653
+ current._listKey = null;
654
+ current[key] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : stripYamlScalar(value);
655
+ }
656
+ return entries.map(({ _listKey, ...entry }) => entry);
657
+ }
658
+
659
+ function stripYamlScalar(value) {
660
+ return String(value || '').trim().replace(/^["']|["']$/g, '');
661
+ }
662
+
663
+ function aggregateEntries(entries) {
664
+ const agg = {
665
+ input: 0, cacheWrite: 0, cached: 0, output: 0, reasoning: 0, total: 0,
666
+ custo: 0, prompts: 0, toolCalls: 0, calls: 0,
667
+ models: [], providers: [], pensamentos: [], tools: [],
668
+ };
669
+ for (const e of entries) {
670
+ agg.input += Number(e.input || 0);
671
+ agg.cacheWrite += Number(e.cache_write || 0);
672
+ agg.cached += Number(e.cache_read || 0);
673
+ agg.output += Number(e.output || 0);
674
+ agg.reasoning += Number(e.reasoning || 0);
675
+ agg.total += Number(e.total || 0);
676
+ agg.custo = roundUsd(agg.custo + Number(e.custo_usd || 0));
677
+ agg.prompts += Number(e.prompts || 0);
678
+ agg.toolCalls += Number(e.tool_calls || 0);
679
+ agg.calls += Number(e.chamadas_llm || 0);
680
+ for (const m of e.modelos || []) addUnique(agg.models, m);
681
+ addUnique(agg.providers, e.provider);
682
+ if (e.pensamento) addUnique(agg.pensamentos, e.pensamento);
683
+ for (const t of e.tools || []) addUnique(agg.tools, t);
684
+ }
685
+ return agg;
686
+ }
687
+
688
+ function extractFrontmatterValue(content, key) {
689
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
690
+ if (!match) return '';
691
+ const line = match[1].match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
692
+ return line ? line[1].trim().replace(/^["']|["']$/g, '') : '';
693
+ }
694
+
695
+ function stripManagedFrontmatter(frontmatter) {
696
+ const lines = frontmatter.split('\n');
697
+ const kept = [];
698
+ let skipping = false;
699
+
700
+ for (const line of lines) {
701
+ const root = line.match(/^([A-Za-z0-9_]+):/);
702
+ if (root) {
703
+ skipping = MANAGED_FRONTMATTER_KEYS.has(root[1]);
704
+ if (!skipping) kept.push(line);
705
+ continue;
706
+ }
707
+
708
+ if (!skipping) kept.push(line);
709
+ }
710
+
711
+ return kept.join('\n').trimEnd();
712
+ }
713
+
714
+ function yamlList(key, values, indent = '') {
715
+ if (!values.length) return `${indent}${key}: []`;
716
+ return `${indent}${key}:\n${values.map((value) => `${indent} - "${String(value).replace(/"/g, '\\"')}"`).join('\n')}`;
717
+ }
718
+
719
+ function buildUsageFrontmatter(agg, entries) {
720
+ const model = agg.models.length === 1 ? agg.models[0] : agg.models.join(' + ');
721
+ const provider = agg.providers.length === 1 ? agg.providers[0] : agg.providers.join(' + ');
722
+
723
+ const historyYaml = entries.length
724
+ ? [
725
+ 'usage_por_transcript:',
726
+ ...entries.flatMap((e) => [
727
+ ` - transcript_id: "${e.transcript_id}"`,
728
+ ` provider: "${e.provider || 'unknown'}"`,
729
+ yamlList('modelos', e.modelos || [], ' '),
730
+ ` pensamento: "${e.pensamento || ''}"`,
731
+ ` input: ${e.input || 0}`,
732
+ ` cache_write: ${e.cache_write || 0}`,
733
+ ` cache_read: ${e.cache_read || 0}`,
734
+ ` output: ${e.output || 0}`,
735
+ ` reasoning: ${e.reasoning || 0}`,
736
+ ` total: ${e.total || 0}`,
737
+ ` custo_usd: ${e.custo_usd || 0}`,
738
+ ` prompts: ${e.prompts || 0}`,
739
+ ` tool_calls: ${e.tool_calls || 0}`,
740
+ ` chamadas_llm: ${e.chamadas_llm || 0}`,
741
+ yamlList('tools', e.tools || [], ' '),
742
+ ` atualizado_em: "${e.atualizado_em || ''}"`,
743
+ ]),
744
+ ].join('\n')
745
+ : 'usage_por_transcript: []';
746
+
747
+ return [
748
+ `modelo: "${model || 'unknown'}"`,
749
+ yamlList('modelos', agg.models),
750
+ `provedor_modelo: "${provider || 'unknown'}"`,
751
+ yamlList('provedores_modelo', agg.providers),
752
+ `nivel_pensamento: "${agg.pensamentos.join(' + ')}"`,
753
+ `prompts: ${agg.prompts}`,
754
+ `tool_calls: ${agg.toolCalls}`,
755
+ `tools_distinct: ${agg.tools.length}`,
756
+ yamlList('tools', agg.tools),
757
+ `chamadas_llm: ${agg.calls}`,
758
+ `tokens_input: ${agg.input}`,
759
+ `tokens_cache_write: ${agg.cacheWrite}`,
760
+ `tokens_cached_input: ${agg.cached}`,
761
+ `tokens_output: ${agg.output}`,
762
+ `tokens_reasoning: ${agg.reasoning}`,
763
+ `tokens_total: ${agg.total}`,
764
+ `custo_modelo_label: "${model || 'unknown'}"`,
765
+ `custo_modelo_usd: ${agg.custo}`,
766
+ historyYaml,
767
+ ].join('\n');
768
+ }
769
+
770
+ function upsertSessionFrontmatter(content, agg, entries) {
771
+ const managedYaml = buildUsageFrontmatter(agg, entries);
772
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
773
+ if (!match) return `---\n${managedYaml}\n---\n\n${content}`;
774
+
775
+ const clean = stripManagedFrontmatter(match[1]);
776
+ const nextFrontmatter = [clean, managedYaml].filter(Boolean).join('\n');
777
+ return `---\n${nextFrontmatter}\n---${content.slice(match[0].length)}`;
778
+ }
779
+
780
+ function buildModelTable(summary) {
781
+ if (!summary.modelRows.length) return 'Nenhum modelo registrado.';
782
+
783
+ return [
784
+ '| Modelo | Provider | Chamadas | Input | Cache W | Cache R | Output | Reasoning | Total | Custo |',
785
+ '|---|---|---:|---:|---:|---:|---:|---:|---:|---:|',
786
+ ...summary.modelRows.map((row) => [
787
+ `| ${escapeTableCell(row.model)}`,
788
+ escapeTableCell(row.provider),
789
+ fmtNum(row.calls),
790
+ fmtNum(row.usage.input),
791
+ fmtNum(row.usage.cacheWrite),
792
+ fmtNum(row.usage.cached),
793
+ fmtNum(row.usage.output),
794
+ fmtNum(row.usage.reasoning),
795
+ fmtNum(row.usage.total),
796
+ `$${row.costs.model.toFixed(4)} |`,
797
+ ].join(' | ')),
798
+ ].join('\n');
799
+ }
800
+
801
+ function buildHistoryTable(entries) {
802
+ if (!entries.length) return 'Nenhuma reabertura registrada.';
803
+
804
+ return [
805
+ '| Transcript | Modelo(s) | Pensamento | Input | Cache W | Cache R | Output | Total | Custo | Atualizado |',
806
+ '|---|---|---|---:|---:|---:|---:|---:|---:|---|',
807
+ ...entries.map((e) => [
808
+ `| ${escapeTableCell(String(e.transcript_id).slice(0, 12))}…`,
809
+ escapeTableCell((e.modelos || []).join(' + ')),
810
+ escapeTableCell(e.pensamento || '-'),
811
+ fmtNum(e.input),
812
+ fmtNum(e.cache_write),
813
+ fmtNum(e.cache_read),
814
+ fmtNum(e.output),
815
+ fmtNum(e.total),
816
+ `$${Number(e.custo_usd || 0).toFixed(4)}`,
817
+ `${escapeTableCell(e.atualizado_em || '')} |`,
818
+ ].join(' | ')),
819
+ ].join('\n');
820
+ }
821
+
822
+ function buildUsageSection(agg, entries, summary) {
823
+ return `## Uso de tokens e custos
824
+
825
+ > Estimativa API-equivalente baseada nos transcripts locais (Codex/Claude Code). Não representa cobrança real do plano.
826
+
827
+ | Métrica | Valor |
828
+ |---|---:|
829
+ | Prompts | ${agg.prompts} |
830
+ | Ferramentas | ${agg.tools.length} tools / ${agg.toolCalls} calls |
831
+ | Chamadas com uso | ${fmtNum(agg.calls)} |
832
+ | Input tokens (não cacheados) | ${fmtNum(agg.input)} |
833
+ | Cache write tokens | ${fmtNum(agg.cacheWrite)} |
834
+ | Cache read tokens | ${fmtNum(agg.cached)} |
835
+ | Output tokens | ${fmtNum(agg.output)} |
836
+ | Thinking/reasoning tokens | ${fmtNum(agg.reasoning)} |
837
+ | Total tokens | ${fmtNum(agg.total)} |
838
+ | Modelo(s) | ${agg.models.join(' + ') || 'unknown'} |
839
+ | Nível de pensamento | ${agg.pensamentos.join(' + ') || '-'} |
840
+ | Custo estimado | $${agg.custo.toFixed(4)} |
841
+
842
+ ### Por reabertura
843
+
844
+ ${buildHistoryTable(entries)}
845
+
846
+ ### Por modelo (transcript atual)
847
+
848
+ ${buildModelTable(summary)}
849
+ `;
850
+ }
851
+
852
+ export function upsertUsageSection(content, section) {
853
+ // Normaliza espaçamento nos dois caminhos (inserção e substituição) para manter idempotência.
854
+ const assemble = (head, rest) => (rest
855
+ ? `${head.trimEnd()}\n\n${section.trimEnd()}\n\n${rest.trimStart()}`
856
+ : `${head.trimEnd()}\n\n${section.trimEnd()}\n`);
857
+
858
+ const marker = '\n## Uso de tokens e custos';
859
+ const existing = content.indexOf(marker);
860
+ let base = content;
861
+
862
+ if (existing !== -1) {
863
+ const next = content.indexOf('\n## ', existing + marker.length);
864
+ const usageTail = next === -1 ? content.slice(existing) : content.slice(existing, next);
865
+ const orphanIteration = usageTail.search(/\n### \d{2}:\d{2} - /);
866
+ const preservedTail = orphanIteration === -1 ? '' : usageTail.slice(orphanIteration).trim();
867
+ const rest = next === -1 ? '' : content.slice(next).trimStart();
868
+ base = [
869
+ content.slice(0, existing).trimEnd(),
870
+ preservedTail,
871
+ rest,
872
+ ].filter(Boolean).join('\n\n');
873
+ }
874
+
875
+ const anchor = base.includes('\n## Pendências')
876
+ ? '\n## Pendências'
877
+ : '\n## Encerramento';
878
+ const anchorIndex = base.indexOf(anchor);
879
+ if (anchorIndex === -1) return assemble(base, '');
880
+ return assemble(base.slice(0, anchorIndex), base.slice(anchorIndex));
881
+ }
882
+
883
+ // Migração: nota antiga com totais planos mas sem usage_por_transcript.
884
+ // Preserva como entrada "legado", exceto quando o transcript atual parece ser a
885
+ // mesma conversa que gerou os totais (mesmos modelos) — aí descarta para não duplicar.
886
+ function legacyEntryFromNote(content, summary) {
887
+ const total = Number(extractFrontmatterValue(content, 'tokens_total') || 0);
888
+ if (!total) return null;
889
+
890
+ const legacyModel = extractFrontmatterValue(content, 'modelo');
891
+ const currentModels = summary.models.join(' + ');
892
+ if (legacyModel && legacyModel === currentModels) return null;
893
+
894
+ return {
895
+ transcript_id: 'legado',
896
+ provider: extractFrontmatterValue(content, 'provedor_modelo') || 'unknown',
897
+ modelos: legacyModel ? [legacyModel] : [],
898
+ pensamento: '',
899
+ input: Number(extractFrontmatterValue(content, 'tokens_input') || 0),
900
+ cache_write: 0,
901
+ cache_read: Number(extractFrontmatterValue(content, 'tokens_cached_input') || 0),
902
+ output: Number(extractFrontmatterValue(content, 'tokens_output') || 0),
903
+ reasoning: Number(extractFrontmatterValue(content, 'tokens_reasoning') || 0),
904
+ total,
905
+ custo_usd: Number(extractFrontmatterValue(content, 'custo_modelo_usd') || 0),
906
+ prompts: Number(extractFrontmatterValue(content, 'prompts') || 0),
907
+ tool_calls: Number(extractFrontmatterValue(content, 'tool_calls') || 0),
908
+ chamadas_llm: Number(extractFrontmatterValue(content, 'chamadas_llm') || 0),
909
+ tools: [],
910
+ atualizado_em: '',
911
+ };
912
+ }
913
+
914
914
  export function collectSessionUsage({ sessionContent, transcriptPath }) {
915
915
  if (!transcriptPath || !existsSync(transcriptPath)) {
916
916
  return null;
917
917
  }
918
-
919
- const parsed = parseTokenUsageFromTranscript(transcriptPath);
920
- const summary = summarizeTokenUsage(parsed);
921
- if (!summary.calls) return null;
922
-
918
+
919
+ const parsed = parseTokenUsageFromTranscript(transcriptPath);
920
+ const summary = summarizeTokenUsage(parsed);
921
+ if (!summary.calls) return null;
922
+
923
923
  const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
924
- const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
925
-
924
+ const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
925
+
926
926
  const transcriptId = transcriptIdFromPath(transcriptPath);
927
927
  const previous = existingEntries.find((entry) => entry.transcript_id === transcriptId);
928
928
  const current = entryFromSummary(summary, transcriptId);
@@ -931,14 +931,14 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
931
931
  if (comparable(previous) === comparable(current)) current.atualizado_em = previous.atualizado_em;
932
932
  }
933
933
  let entries = existingEntries.filter((e) => e.transcript_id !== transcriptId);
934
-
935
- if (!existingEntries.length) {
936
- const legacy = legacyEntryFromNote(sessionContent, summary);
937
- if (legacy) entries.push(legacy);
938
- }
939
-
934
+
935
+ if (!existingEntries.length) {
936
+ const legacy = legacyEntryFromNote(sessionContent, summary);
937
+ if (legacy) entries.push(legacy);
938
+ }
939
+
940
940
  entries.push(current);
941
-
941
+
942
942
  const agg = aggregateEntries(entries);
943
943
  const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
944
944
  return {
@@ -957,57 +957,57 @@ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcr
957
957
  writeFileSync(sessionPath, withSection, 'utf-8');
958
958
  return result;
959
959
  }
960
-
961
- function parseCliArgs(argv) {
962
- const args = {};
963
- for (let i = 0; i < argv.length; i += 1) {
964
- const item = argv[i];
965
- if (!item.startsWith('--')) continue;
966
- const key = item.slice(2);
967
- const next = argv[i + 1];
968
- if (!next || next.startsWith('--')) {
969
- args[key] = true;
970
- } else {
971
- args[key] = next;
972
- i += 1;
973
- }
974
- }
975
- return args;
976
- }
977
-
978
- function runCli() {
979
- const args = parseCliArgs(process.argv.slice(2));
980
- const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
981
- const control = readControl(vaultBase);
982
- const sessionRel = args.session || control.session_file || control.last_session_file || '';
983
- const sessionPath = sessionRel ? join(vaultBase, sessionRel) : '';
984
- const transcriptPath = args.transcript || '';
985
-
986
- const result = updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath });
987
- if (!result) {
988
- console.log(JSON.stringify({ ok: false, reason: 'usage-not-available' }, null, 2));
989
- return;
990
- }
991
-
992
- console.log(JSON.stringify({
993
- ok: true,
994
- session: sessionRel,
995
- calls: result.summary.calls,
996
- prompts: result.summary.prompts,
997
- toolCalls: result.summary.toolCalls,
998
- models: result.summary.models,
999
- pensamento: result.summary.pensamento,
1000
- totals: result.summary.totals,
1001
- costs: result.summary.costs,
1002
- reaberturas: result.entries.length,
1003
- }, null, 2));
1004
- }
1005
-
1006
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1007
- try {
1008
- runCli();
1009
- } catch (error) {
1010
- process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1011
- process.exitCode = 1;
1012
- }
1013
- }
960
+
961
+ function parseCliArgs(argv) {
962
+ const args = {};
963
+ for (let i = 0; i < argv.length; i += 1) {
964
+ const item = argv[i];
965
+ if (!item.startsWith('--')) continue;
966
+ const key = item.slice(2);
967
+ const next = argv[i + 1];
968
+ if (!next || next.startsWith('--')) {
969
+ args[key] = true;
970
+ } else {
971
+ args[key] = next;
972
+ i += 1;
973
+ }
974
+ }
975
+ return args;
976
+ }
977
+
978
+ function runCli() {
979
+ const args = parseCliArgs(process.argv.slice(2));
980
+ const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
981
+ const control = readControl(vaultBase);
982
+ const sessionRel = args.session || control.session_file || control.last_session_file || '';
983
+ const sessionPath = sessionRel ? join(vaultBase, sessionRel) : '';
984
+ const transcriptPath = args.transcript || '';
985
+
986
+ const result = updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath });
987
+ if (!result) {
988
+ console.log(JSON.stringify({ ok: false, reason: 'usage-not-available' }, null, 2));
989
+ return;
990
+ }
991
+
992
+ console.log(JSON.stringify({
993
+ ok: true,
994
+ session: sessionRel,
995
+ calls: result.summary.calls,
996
+ prompts: result.summary.prompts,
997
+ toolCalls: result.summary.toolCalls,
998
+ models: result.summary.models,
999
+ pensamento: result.summary.pensamento,
1000
+ totals: result.summary.totals,
1001
+ costs: result.summary.costs,
1002
+ reaberturas: result.entries.length,
1003
+ }, null, 2));
1004
+ }
1005
+
1006
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1007
+ try {
1008
+ runCli();
1009
+ } catch (error) {
1010
+ process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1011
+ process.exitCode = 1;
1012
+ }
1013
+ }