wendkeep 0.38.0 → 0.38.2

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,766 +163,771 @@ 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') {
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
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();
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)) {
489
- seenThinking.add(thinkKey);
490
- thinkingChars += block.thinking.length;
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);
457
+ let sawThinking = false;
458
+ let latestPrompt = '';
459
+
460
+ for (const line of lines) {
461
+ const event = parseJsonLine(line);
462
+ if (!event) continue;
463
+
464
+ if (event.sessionId && !result.sessionId) result.sessionId = event.sessionId;
465
+
466
+ if (event.type === 'user' && !event.toolUseResult && event.message) {
467
+ const text = extractTextContent(event.message.content).trim();
468
+ if (text && !shouldIgnoreUserText(text)) {
469
+ latestPrompt = text;
470
+ addUnique(result.userPrompts, text);
471
+ }
472
+ continue;
473
+ }
474
+
475
+ if (event.type !== 'assistant' || !event.message) continue;
476
+
477
+ const msg = event.message;
478
+ const model = normalizeModelName(msg.model);
479
+ if (model === '<synthetic>' || msg.model === '<synthetic>') continue;
480
+
481
+ for (const block of msg.content || []) {
482
+ if (block?.type === 'tool_use' && block.id && !seenTools.has(block.id)) {
483
+ seenTools.add(block.id);
484
+ result.toolCalls += 1;
485
+ addUnique(result.tools, block.name || 'tool_use');
486
+ }
487
+ if (block?.type === 'thinking') {
488
+ // Presença = extended thinking ATIVO. A `signature` persiste mesmo quando o Claude
489
+ // Code redige o texto (`thinking: ''`) — é o único sinal confiável do effort.
490
+ sawThinking = true;
491
+ // O texto sobrevive à redação às vezes; quando sobrevive, estima reasoning tokens.
492
+ if (block.thinking) {
493
+ const thinkKey = `${msg.id || ''}:${block.thinking.slice(0, 60)}`;
494
+ if (!seenThinking.has(thinkKey)) {
495
+ seenThinking.add(thinkKey);
496
+ thinkingChars += block.thinking.length;
497
+ thinkingCharsByModel.set(model, (thinkingCharsByModel.get(model) || 0) + block.thinking.length);
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ const usageKey = event.requestId || msg.id || '';
504
+ if (!msg.usage || !usageKey || seenUsage.has(usageKey)) continue;
505
+ seenUsage.add(usageKey);
506
+
507
+ const usage = normalizeClaudeUsage(msg.usage);
508
+ result.calls.push({
509
+ index: result.calls.length + 1,
510
+ model,
511
+ provider: 'anthropic',
512
+ usage,
513
+ prompt: truncate(latestPrompt, 110),
514
+ });
515
+ addUsage(result.totals, usage);
516
+ trackByModel(result, 'anthropic', model, usage);
517
+ result.model = model;
518
+ }
519
+
520
+ // Effort observável no Claude: presença de blocos thinking (signature), não o texto — o
521
+ // nível low/medium/high não é gravado no transcript. Rótulo binário: thinking/none.
522
+ result.pensamento = sawThinking ? 'thinking' : 'none';
523
+
524
+ // Reasoning: estimativa-piso ~3,5 chars/token dos textos que escaparam da redação (quase
525
+ // sempre 0 no thread principal). Já contido em output_tokens — nunca somado de novo. NÃO
526
+ // determina o effort (desacoplado do pensamento acima).
527
+ const thinkingTokens = Math.round(thinkingChars / 3.5);
516
528
  if (thinkingTokens > 0) {
517
529
  result.totals.reasoning = thinkingTokens;
518
- result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
519
530
  for (const [model, chars] of thinkingCharsByModel) {
520
531
  const entry = result.byModel.get(`anthropic:${model}`);
521
532
  if (entry) entry.usage.reasoning = Math.round(chars / 3.5);
522
533
  }
523
534
  }
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
-
535
+
536
+ return result;
537
+ }
538
+
539
+ function detectTranscriptFormat(lines) {
540
+ for (const line of lines.slice(0, 20)) {
541
+ const event = parseJsonLine(line);
542
+ if (!event) continue;
543
+ if (event.type === 'session_meta' || event.type === 'turn_context'
544
+ || event.type === 'event_msg' || event.type === 'response_item') return 'codex';
545
+ if (event.type === 'assistant' || event.type === 'queue-operation'
546
+ || event.type === 'file-history-snapshot' || event.type === 'last-prompt') return 'claude';
547
+ }
548
+ return 'codex';
549
+ }
550
+
551
+ export function parseTokenUsageFromTranscript(transcriptPath) {
552
+ const result = emptyParseResult(transcriptPath);
553
+ if (!transcriptPath || !existsSync(transcriptPath)) return result;
554
+
555
+ const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
556
+ return detectTranscriptFormat(lines) === 'claude'
557
+ ? parseClaudeLines(lines, result)
558
+ : parseCodexLines(lines, result);
559
+ }
560
+
561
+ function modelCost(usage, model) {
562
+ const normalized = normalizeModelName(model);
563
+ const price = PRICE_REFERENCE[normalized];
564
+ return price ? calculateCost(usage, price) : 0;
565
+ }
566
+
567
+ export function summarizeTokenUsage(parsed) {
568
+ const totals = parsed.totals;
569
+ const modelRows = [...parsed.byModel.values()].map((entry) => ({
570
+ ...entry,
571
+ costs: {
572
+ model: modelCost(entry.usage, entry.model),
573
+ },
574
+ }));
575
+ const modelCostTotal = roundUsd(modelRows.reduce((sum, row) => sum + row.costs.model, 0));
576
+ const modelLabel = modelRows.length === 1 ? modelRows[0].model : parsed.model;
577
+
578
+ return {
579
+ sessionId: parsed.sessionId,
580
+ transcriptPath: parsed.transcriptPath,
581
+ pensamento: parsed.pensamento || '',
582
+ prompts: parsed.userPrompts.length,
583
+ toolCalls: parsed.toolCalls,
584
+ tools: parsed.tools,
585
+ calls: parsed.calls.length,
586
+ models: [...new Set(modelRows.map((row) => row.model))],
587
+ providers: [...new Set(modelRows.map((row) => row.provider))],
588
+ totals,
589
+ costs: {
590
+ model: modelCostTotal,
591
+ modelLabel,
592
+ },
593
+ modelRows,
594
+ callsTable: parsed.calls,
595
+ };
596
+ }
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // Histórico por transcript (reaberturas): cada conversa tem transcript próprio.
600
+ // O frontmatter guarda uma entrada por transcript; os campos planos são a soma.
601
+ // ---------------------------------------------------------------------------
602
+
603
+ function transcriptIdFromPath(transcriptPath) {
604
+ return basename(String(transcriptPath || '')).replace(/\.jsonl?$/i, '') || 'desconhecido';
605
+ }
606
+
607
+ function entryFromSummary(summary, transcriptId) {
608
+ return {
609
+ transcript_id: transcriptId,
610
+ provider: summary.providers.join(' + ') || 'unknown',
611
+ modelos: summary.models,
612
+ pensamento: summary.pensamento || '',
613
+ input: summary.totals.input,
614
+ cache_write: summary.totals.cacheWrite,
615
+ cache_read: summary.totals.cached,
616
+ output: summary.totals.output,
617
+ reasoning: summary.totals.reasoning,
618
+ total: summary.totals.total,
619
+ custo_usd: summary.costs.model,
620
+ prompts: summary.prompts,
621
+ tool_calls: summary.toolCalls,
622
+ chamadas_llm: summary.calls,
623
+ tools: summary.tools,
624
+ atualizado_em: new Date().toISOString().slice(0, 19),
625
+ };
626
+ }
627
+
628
+ // Parser do bloco YAML `usage_por_transcript` gerado por este próprio script (formato fixo).
629
+ function parseUsageHistory(frontmatter) {
630
+ const match = frontmatter.match(/^usage_por_transcript:\n((?:[ ]{2,}.*\n?)*)/m);
631
+ if (!match) return [];
632
+
633
+ const entries = [];
634
+ let current = null;
635
+ for (const rawLine of match[1].split('\n')) {
636
+ const line = rawLine.trimEnd();
637
+ if (!line.trim()) continue;
638
+ const itemStart = line.match(/^[ ]{2}- transcript_id:\s*(.*)$/);
639
+ if (itemStart) {
640
+ current = { transcript_id: stripYamlScalar(itemStart[1]), modelos: [], tools: [] };
641
+ entries.push(current);
642
+ continue;
643
+ }
644
+ if (!current) continue;
645
+ const listItem = line.match(/^[ ]{6}- (.*)$/);
646
+ if (listItem && current._listKey) {
647
+ current[current._listKey].push(stripYamlScalar(listItem[1]));
648
+ continue;
649
+ }
650
+ const kv = line.match(/^[ ]{4}([a-z_]+):\s*(.*)$/);
651
+ if (!kv) continue;
652
+ const [, key, value] = kv;
653
+ if (key === 'modelos' || key === 'tools') {
654
+ current._listKey = key;
655
+ current[key] = [];
656
+ continue;
657
+ }
658
+ current._listKey = null;
659
+ current[key] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : stripYamlScalar(value);
660
+ }
661
+ return entries.map(({ _listKey, ...entry }) => entry);
662
+ }
663
+
664
+ function stripYamlScalar(value) {
665
+ return String(value || '').trim().replace(/^["']|["']$/g, '');
666
+ }
667
+
668
+ function aggregateEntries(entries) {
669
+ const agg = {
670
+ input: 0, cacheWrite: 0, cached: 0, output: 0, reasoning: 0, total: 0,
671
+ custo: 0, prompts: 0, toolCalls: 0, calls: 0,
672
+ models: [], providers: [], pensamentos: [], tools: [],
673
+ };
674
+ for (const e of entries) {
675
+ agg.input += Number(e.input || 0);
676
+ agg.cacheWrite += Number(e.cache_write || 0);
677
+ agg.cached += Number(e.cache_read || 0);
678
+ agg.output += Number(e.output || 0);
679
+ agg.reasoning += Number(e.reasoning || 0);
680
+ agg.total += Number(e.total || 0);
681
+ agg.custo = roundUsd(agg.custo + Number(e.custo_usd || 0));
682
+ agg.prompts += Number(e.prompts || 0);
683
+ agg.toolCalls += Number(e.tool_calls || 0);
684
+ agg.calls += Number(e.chamadas_llm || 0);
685
+ for (const m of e.modelos || []) addUnique(agg.models, m);
686
+ addUnique(agg.providers, e.provider);
687
+ if (e.pensamento) addUnique(agg.pensamentos, e.pensamento);
688
+ for (const t of e.tools || []) addUnique(agg.tools, t);
689
+ }
690
+ return agg;
691
+ }
692
+
693
+ function extractFrontmatterValue(content, key) {
694
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
695
+ if (!match) return '';
696
+ const line = match[1].match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
697
+ return line ? line[1].trim().replace(/^["']|["']$/g, '') : '';
698
+ }
699
+
700
+ function stripManagedFrontmatter(frontmatter) {
701
+ const lines = frontmatter.split('\n');
702
+ const kept = [];
703
+ let skipping = false;
704
+
705
+ for (const line of lines) {
706
+ const root = line.match(/^([A-Za-z0-9_]+):/);
707
+ if (root) {
708
+ skipping = MANAGED_FRONTMATTER_KEYS.has(root[1]);
709
+ if (!skipping) kept.push(line);
710
+ continue;
711
+ }
712
+
713
+ if (!skipping) kept.push(line);
714
+ }
715
+
716
+ return kept.join('\n').trimEnd();
717
+ }
718
+
719
+ function yamlList(key, values, indent = '') {
720
+ if (!values.length) return `${indent}${key}: []`;
721
+ return `${indent}${key}:\n${values.map((value) => `${indent} - "${String(value).replace(/"/g, '\\"')}"`).join('\n')}`;
722
+ }
723
+
724
+ function buildUsageFrontmatter(agg, entries) {
725
+ const model = agg.models.length === 1 ? agg.models[0] : agg.models.join(' + ');
726
+ const provider = agg.providers.length === 1 ? agg.providers[0] : agg.providers.join(' + ');
727
+
728
+ const historyYaml = entries.length
729
+ ? [
730
+ 'usage_por_transcript:',
731
+ ...entries.flatMap((e) => [
732
+ ` - transcript_id: "${e.transcript_id}"`,
733
+ ` provider: "${e.provider || 'unknown'}"`,
734
+ yamlList('modelos', e.modelos || [], ' '),
735
+ ` pensamento: "${e.pensamento || ''}"`,
736
+ ` input: ${e.input || 0}`,
737
+ ` cache_write: ${e.cache_write || 0}`,
738
+ ` cache_read: ${e.cache_read || 0}`,
739
+ ` output: ${e.output || 0}`,
740
+ ` reasoning: ${e.reasoning || 0}`,
741
+ ` total: ${e.total || 0}`,
742
+ ` custo_usd: ${e.custo_usd || 0}`,
743
+ ` prompts: ${e.prompts || 0}`,
744
+ ` tool_calls: ${e.tool_calls || 0}`,
745
+ ` chamadas_llm: ${e.chamadas_llm || 0}`,
746
+ yamlList('tools', e.tools || [], ' '),
747
+ ` atualizado_em: "${e.atualizado_em || ''}"`,
748
+ ]),
749
+ ].join('\n')
750
+ : 'usage_por_transcript: []';
751
+
752
+ return [
753
+ `modelo: "${model || 'unknown'}"`,
754
+ yamlList('modelos', agg.models),
755
+ `provedor_modelo: "${provider || 'unknown'}"`,
756
+ yamlList('provedores_modelo', agg.providers),
757
+ `nivel_pensamento: "${agg.pensamentos.join(' + ')}"`,
758
+ `prompts: ${agg.prompts}`,
759
+ `tool_calls: ${agg.toolCalls}`,
760
+ `tools_distinct: ${agg.tools.length}`,
761
+ yamlList('tools', agg.tools),
762
+ `chamadas_llm: ${agg.calls}`,
763
+ `tokens_input: ${agg.input}`,
764
+ `tokens_cache_write: ${agg.cacheWrite}`,
765
+ `tokens_cached_input: ${agg.cached}`,
766
+ `tokens_output: ${agg.output}`,
767
+ `tokens_reasoning: ${agg.reasoning}`,
768
+ `tokens_total: ${agg.total}`,
769
+ `custo_modelo_label: "${model || 'unknown'}"`,
770
+ `custo_modelo_usd: ${agg.custo}`,
771
+ historyYaml,
772
+ ].join('\n');
773
+ }
774
+
775
+ function upsertSessionFrontmatter(content, agg, entries) {
776
+ const managedYaml = buildUsageFrontmatter(agg, entries);
777
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
778
+ if (!match) return `---\n${managedYaml}\n---\n\n${content}`;
779
+
780
+ const clean = stripManagedFrontmatter(match[1]);
781
+ const nextFrontmatter = [clean, managedYaml].filter(Boolean).join('\n');
782
+ return `---\n${nextFrontmatter}\n---${content.slice(match[0].length)}`;
783
+ }
784
+
785
+ function buildModelTable(summary) {
786
+ if (!summary.modelRows.length) return 'Nenhum modelo registrado.';
787
+
788
+ return [
789
+ '| Modelo | Provider | Chamadas | Input | Cache W | Cache R | Output | Reasoning | Total | Custo |',
790
+ '|---|---|---:|---:|---:|---:|---:|---:|---:|---:|',
791
+ ...summary.modelRows.map((row) => [
792
+ `| ${escapeTableCell(row.model)}`,
793
+ escapeTableCell(row.provider),
794
+ fmtNum(row.calls),
795
+ fmtNum(row.usage.input),
796
+ fmtNum(row.usage.cacheWrite),
797
+ fmtNum(row.usage.cached),
798
+ fmtNum(row.usage.output),
799
+ fmtNum(row.usage.reasoning),
800
+ fmtNum(row.usage.total),
801
+ `$${row.costs.model.toFixed(4)} |`,
802
+ ].join(' | ')),
803
+ ].join('\n');
804
+ }
805
+
806
+ function buildHistoryTable(entries) {
807
+ if (!entries.length) return 'Nenhuma reabertura registrada.';
808
+
809
+ return [
810
+ '| Transcript | Modelo(s) | Pensamento | Input | Cache W | Cache R | Output | Total | Custo | Atualizado |',
811
+ '|---|---|---|---:|---:|---:|---:|---:|---:|---|',
812
+ ...entries.map((e) => [
813
+ `| ${escapeTableCell(String(e.transcript_id).slice(0, 12))}…`,
814
+ escapeTableCell((e.modelos || []).join(' + ')),
815
+ escapeTableCell(e.pensamento || '-'),
816
+ fmtNum(e.input),
817
+ fmtNum(e.cache_write),
818
+ fmtNum(e.cache_read),
819
+ fmtNum(e.output),
820
+ fmtNum(e.total),
821
+ `$${Number(e.custo_usd || 0).toFixed(4)}`,
822
+ `${escapeTableCell(e.atualizado_em || '')} |`,
823
+ ].join(' | ')),
824
+ ].join('\n');
825
+ }
826
+
827
+ function buildUsageSection(agg, entries, summary) {
828
+ return `## Uso de tokens e custos
829
+
830
+ > Estimativa API-equivalente baseada nos transcripts locais (Codex/Claude Code). Não representa cobrança real do plano.
831
+
832
+ | Métrica | Valor |
833
+ |---|---:|
834
+ | Prompts | ${agg.prompts} |
835
+ | Ferramentas | ${agg.tools.length} tools / ${agg.toolCalls} calls |
836
+ | Chamadas com uso | ${fmtNum(agg.calls)} |
837
+ | Input tokens (não cacheados) | ${fmtNum(agg.input)} |
838
+ | Cache write tokens | ${fmtNum(agg.cacheWrite)} |
839
+ | Cache read tokens | ${fmtNum(agg.cached)} |
840
+ | Output tokens | ${fmtNum(agg.output)} |
841
+ | Thinking/reasoning tokens | ${fmtNum(agg.reasoning)} |
842
+ | Total tokens | ${fmtNum(agg.total)} |
843
+ | Modelo(s) | ${agg.models.join(' + ') || 'unknown'} |
844
+ | Nível de pensamento | ${agg.pensamentos.join(' + ') || '-'} |
845
+ | Custo estimado | $${agg.custo.toFixed(4)} |
846
+
847
+ ### Por reabertura
848
+
849
+ ${buildHistoryTable(entries)}
850
+
851
+ ### Por modelo (transcript atual)
852
+
853
+ ${buildModelTable(summary)}
854
+ `;
855
+ }
856
+
857
+ export function upsertUsageSection(content, section) {
858
+ // Normaliza espaçamento nos dois caminhos (inserção e substituição) para manter idempotência.
859
+ const assemble = (head, rest) => (rest
860
+ ? `${head.trimEnd()}\n\n${section.trimEnd()}\n\n${rest.trimStart()}`
861
+ : `${head.trimEnd()}\n\n${section.trimEnd()}\n`);
862
+
863
+ const marker = '\n## Uso de tokens e custos';
864
+ const existing = content.indexOf(marker);
865
+ let base = content;
866
+
867
+ if (existing !== -1) {
868
+ const next = content.indexOf('\n## ', existing + marker.length);
869
+ const usageTail = next === -1 ? content.slice(existing) : content.slice(existing, next);
870
+ const orphanIteration = usageTail.search(/\n### \d{2}:\d{2} - /);
871
+ const preservedTail = orphanIteration === -1 ? '' : usageTail.slice(orphanIteration).trim();
872
+ const rest = next === -1 ? '' : content.slice(next).trimStart();
873
+ base = [
874
+ content.slice(0, existing).trimEnd(),
875
+ preservedTail,
876
+ rest,
877
+ ].filter(Boolean).join('\n\n');
878
+ }
879
+
880
+ const anchor = base.includes('\n## Pendências')
881
+ ? '\n## Pendências'
882
+ : '\n## Encerramento';
883
+ const anchorIndex = base.indexOf(anchor);
884
+ if (anchorIndex === -1) return assemble(base, '');
885
+ return assemble(base.slice(0, anchorIndex), base.slice(anchorIndex));
886
+ }
887
+
888
+ // Migração: nota antiga com totais planos mas sem usage_por_transcript.
889
+ // Preserva como entrada "legado", exceto quando o transcript atual parece ser a
890
+ // mesma conversa que gerou os totais (mesmos modelos) — aí descarta para não duplicar.
891
+ function legacyEntryFromNote(content, summary) {
892
+ const total = Number(extractFrontmatterValue(content, 'tokens_total') || 0);
893
+ if (!total) return null;
894
+
895
+ const legacyModel = extractFrontmatterValue(content, 'modelo');
896
+ const currentModels = summary.models.join(' + ');
897
+ if (legacyModel && legacyModel === currentModels) return null;
898
+
899
+ return {
900
+ transcript_id: 'legado',
901
+ provider: extractFrontmatterValue(content, 'provedor_modelo') || 'unknown',
902
+ modelos: legacyModel ? [legacyModel] : [],
903
+ pensamento: '',
904
+ input: Number(extractFrontmatterValue(content, 'tokens_input') || 0),
905
+ cache_write: 0,
906
+ cache_read: Number(extractFrontmatterValue(content, 'tokens_cached_input') || 0),
907
+ output: Number(extractFrontmatterValue(content, 'tokens_output') || 0),
908
+ reasoning: Number(extractFrontmatterValue(content, 'tokens_reasoning') || 0),
909
+ total,
910
+ custo_usd: Number(extractFrontmatterValue(content, 'custo_modelo_usd') || 0),
911
+ prompts: Number(extractFrontmatterValue(content, 'prompts') || 0),
912
+ tool_calls: Number(extractFrontmatterValue(content, 'tool_calls') || 0),
913
+ chamadas_llm: Number(extractFrontmatterValue(content, 'chamadas_llm') || 0),
914
+ tools: [],
915
+ atualizado_em: '',
916
+ };
917
+ }
918
+
914
919
  export function collectSessionUsage({ sessionContent, transcriptPath }) {
915
920
  if (!transcriptPath || !existsSync(transcriptPath)) {
916
921
  return null;
917
922
  }
918
-
919
- const parsed = parseTokenUsageFromTranscript(transcriptPath);
920
- const summary = summarizeTokenUsage(parsed);
921
- if (!summary.calls) return null;
922
-
923
+
924
+ const parsed = parseTokenUsageFromTranscript(transcriptPath);
925
+ const summary = summarizeTokenUsage(parsed);
926
+ if (!summary.calls) return null;
927
+
923
928
  const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
924
- const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
925
-
929
+ const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
930
+
926
931
  const transcriptId = transcriptIdFromPath(transcriptPath);
927
932
  const previous = existingEntries.find((entry) => entry.transcript_id === transcriptId);
928
933
  const current = entryFromSummary(summary, transcriptId);
@@ -931,14 +936,14 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
931
936
  if (comparable(previous) === comparable(current)) current.atualizado_em = previous.atualizado_em;
932
937
  }
933
938
  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
-
939
+
940
+ if (!existingEntries.length) {
941
+ const legacy = legacyEntryFromNote(sessionContent, summary);
942
+ if (legacy) entries.push(legacy);
943
+ }
944
+
940
945
  entries.push(current);
941
-
946
+
942
947
  const agg = aggregateEntries(entries);
943
948
  const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
944
949
  return {
@@ -957,57 +962,57 @@ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcr
957
962
  writeFileSync(sessionPath, withSection, 'utf-8');
958
963
  return result;
959
964
  }
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
- }
965
+
966
+ function parseCliArgs(argv) {
967
+ const args = {};
968
+ for (let i = 0; i < argv.length; i += 1) {
969
+ const item = argv[i];
970
+ if (!item.startsWith('--')) continue;
971
+ const key = item.slice(2);
972
+ const next = argv[i + 1];
973
+ if (!next || next.startsWith('--')) {
974
+ args[key] = true;
975
+ } else {
976
+ args[key] = next;
977
+ i += 1;
978
+ }
979
+ }
980
+ return args;
981
+ }
982
+
983
+ function runCli() {
984
+ const args = parseCliArgs(process.argv.slice(2));
985
+ const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
986
+ const control = readControl(vaultBase);
987
+ const sessionRel = args.session || control.session_file || control.last_session_file || '';
988
+ const sessionPath = sessionRel ? join(vaultBase, sessionRel) : '';
989
+ const transcriptPath = args.transcript || '';
990
+
991
+ const result = updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath });
992
+ if (!result) {
993
+ console.log(JSON.stringify({ ok: false, reason: 'usage-not-available' }, null, 2));
994
+ return;
995
+ }
996
+
997
+ console.log(JSON.stringify({
998
+ ok: true,
999
+ session: sessionRel,
1000
+ calls: result.summary.calls,
1001
+ prompts: result.summary.prompts,
1002
+ toolCalls: result.summary.toolCalls,
1003
+ models: result.summary.models,
1004
+ pensamento: result.summary.pensamento,
1005
+ totals: result.summary.totals,
1006
+ costs: result.summary.costs,
1007
+ reaberturas: result.entries.length,
1008
+ }, null, 2));
1009
+ }
1010
+
1011
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1012
+ try {
1013
+ runCli();
1014
+ } catch (error) {
1015
+ process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1016
+ process.exitCode = 1;
1017
+ }
1018
+ }