token-trace-manager 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ttm.mjs ADDED
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ // CLI do token-trace-manager. Uso: ttm <comando> [opções]
3
+
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { execFile } from 'node:child_process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { readConfig, writeConfig, setTarget, removeTarget, validateConfig, KNOWN_PROTOCOLS } from '../lib/config.mjs'
9
+ import { ensureDaemon, stopDaemon, daemonHealth, fetchDaemon } from '../lib/daemon-client.mjs'
10
+ import { linkTui, unlinkTui } from '../lib/link.mjs'
11
+ import { PRICING_LAST_UPDATED } from '../lib/pricing.mjs'
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
+
15
+ const HELP = `ttm — rastreio universal de tokens/custo para TUIs de código
16
+
17
+ Comandos:
18
+ ttm start Sobe o daemon do proxy (idempotente)
19
+ ttm stop Para o daemon
20
+ ttm status Estado do daemon + resumo da configuração
21
+ ttm label <TICKET-123|none> Define (ou limpa) o label padrão para tráfego sem label na URL
22
+ ttm tokens <TICKET-123> [--since 30d]
23
+ Imprime SÓ a quantidade de tokens da tarefa (pra colar no Jira)
24
+ ttm audit [--since 7d] Audita: re-multiplica tokens x taxas gravadas e reconcilia,
25
+ id a id, com o log independente do Claude Code
26
+ ttm summary [--since 7d] Resumo de uso/custo no terminal
27
+ ttm dashboard [--since 7d] [--out arquivo.html]
28
+ Abre o dashboard ao vivo (ou exporta HTML estático)
29
+ ttm targets Lista os targets configurados (provedores upstream)
30
+ ttm config [porta <n>] Mostra config; 'porta <n>' troca a porta do proxy
31
+ ttm config target <name> <url> [--protocol P] [--env VAR]
32
+ Adiciona/remove/lista targets custom (fallback manual ao link)
33
+ ttm link <tui> [--label T] [--repo] [--force]
34
+ Descobre a base URL que o <tui> já usa, vira upstream,
35
+ e reescreve a config do <tui> pra apontar pro ttm.
36
+ Preserva redirect prévio (z.ai/GLM/...) — não re-routa
37
+ pro vendor real. TUIs: claude-code, codex, opencode, gemini-cli
38
+ ttm unlink <tui> Desfaz o link: restaura a config original do <tui> e remove o target
39
+ ttm setup <tui> Mostra o snippet de configuração para um TUI
40
+ (claude-code, codex, opencode, gemini-cli, kilo, ...)
41
+
42
+ O proxy escuta só em 127.0.0.1. Conteúdo de prompts/respostas fica em payloads/
43
+ locais se 'storePayloads' estiver ligado (default: sim) — chaves de API nunca
44
+ são gravadas. Preços atualizados em ${PRICING_LAST_UPDATED}.`
45
+
46
+ function flag(args, name, dflt) {
47
+ const i = args.indexOf(name)
48
+ if (i === -1) return dflt
49
+ return args[i + 1] ?? dflt
50
+ }
51
+
52
+ function fmtTok(n) {
53
+ n = n || 0
54
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
55
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
56
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
57
+ return String(n)
58
+ }
59
+
60
+ const fmtUsd = n => '$' + (n || 0).toFixed(2)
61
+
62
+ async function main() {
63
+ const [cmd, ...args] = process.argv.slice(2)
64
+ const cfg = readConfig()
65
+
66
+ switch (cmd) {
67
+ case 'start': {
68
+ const { started, health, restartedFrom } = await ensureDaemon()
69
+ console.log(
70
+ restartedFrom
71
+ ? `daemon reiniciado (v${restartedFrom} -> v${health.version}) em http://127.0.0.1:${cfg.port} (pid ${health.pid})`
72
+ : started
73
+ ? `daemon iniciado em http://127.0.0.1:${cfg.port} (pid ${health.pid})`
74
+ : `daemon já estava rodando em http://127.0.0.1:${cfg.port} (pid ${health.pid}, uptime ${health.uptime_s}s)`,
75
+ )
76
+ return
77
+ }
78
+
79
+ case 'stop': {
80
+ const r = await stopDaemon()
81
+ console.log(r.stopped ? `daemon parado (pid ${r.pid})` : `nada a parar: ${r.reason}`)
82
+ return
83
+ }
84
+
85
+ case 'status': {
86
+ const h = await daemonHealth()
87
+ if (!h) {
88
+ console.log(`daemon: PARADO (rode \`ttm start\`) — porta configurada: ${cfg.port}`)
89
+ return
90
+ }
91
+ const label = (await fetchDaemon('/api/label')).defaultLabel
92
+ console.log(`daemon: RODANDO em http://127.0.0.1:${cfg.port} (pid ${h.pid}, v${h.version}, uptime ${h.uptime_s}s)`)
93
+ console.log(`label padrão: ${label ?? '(nenhum — use labels na URL ou \`ttm label X\`)'}`)
94
+ console.log(`dashboard: http://127.0.0.1:${cfg.port}/dashboard`)
95
+ return
96
+ }
97
+
98
+ case 'label': {
99
+ const value = args[0]
100
+ if (!value) {
101
+ await ensureDaemon()
102
+ console.log(`label padrão atual: ${(await fetchDaemon('/api/label')).defaultLabel ?? '(nenhum)'}`)
103
+ return
104
+ }
105
+ await ensureDaemon()
106
+ const label = value === 'none' || value === 'nenhum' ? null : value
107
+ const r = await fetchDaemon('/api/label', { method: 'POST', body: { label } })
108
+ console.log(label ? `label padrão definido: ${r.defaultLabel}` : 'label padrão removido')
109
+ return
110
+ }
111
+
112
+ case 'tokens': {
113
+ const key = args[0]
114
+ if (!key) {
115
+ console.error('uso: ttm tokens <TICKET-123> [--since 30d] [--raw] (métrica lidos/escritos, estilo Langfuse)')
116
+ process.exit(1)
117
+ }
118
+ await ensureDaemon()
119
+ const since = flag(args, '--since', '30d')
120
+ const r = await fetchDaemon(
121
+ `/api/summary?since=${encodeURIComponent(since)}&label=${encodeURIComponent(key.toUpperCase())}`,
122
+ )
123
+ if (args.includes('--raw')) {
124
+ console.log(r.totalTokens) // só o total, para scripts
125
+ return
126
+ }
127
+ console.log(`lidos=${r.tokensIn ?? 0} escritos=${r.tokensOut ?? 0} total=${r.totalTokens}`)
128
+ return
129
+ }
130
+
131
+ case 'audit': {
132
+ const { runAudit } = await import('../lib/audit.mjs')
133
+ const since = flag(args, '--since', null)
134
+ const sinceIso = since
135
+ ? new Date(Date.now() - parseInt(since, 10) * (since.endsWith('h') ? 3600000 : 86400000)).toISOString()
136
+ : null
137
+ const r = await runAudit({ sinceIso })
138
+ console.log(`auditoria (${since ?? 'tudo'}) — ${r.eventCount} eventos\n`)
139
+ console.log(`[1] matemática interna (custo = tokens x taxas gravadas na hora da chamada)`)
140
+ console.log(` verificados: ${r.math.checked} | exatos: ${r.math.ok} | divergentes: ${r.math.drift.length} | sem recibo de taxas (eventos antigos): ${r.math.noRates}`)
141
+ for (const d of r.math.drift.slice(0, 5)) {
142
+ console.log(` ! evento #${d.id} (${d.label ?? 'sem label'}): gravado $${d.stored?.toFixed(6)} vs recalculado $${d.recomputed.toFixed(6)}`)
143
+ }
144
+ console.log(`\n[2] reconciliação com fontes independentes (agnóstico ao harness)`)
145
+ let mismatchTotal = 0
146
+ for (const s of r.sources) {
147
+ if (!s.available) {
148
+ console.log(` - ${s.title}: indisponível nesta máquina`)
149
+ continue
150
+ }
151
+ mismatchTotal += s.mismatches.length
152
+ console.log(
153
+ ` - ${s.title} [${s.strategy === 'provider_id' ? 'casa por id do provedor' : 'casa por impressão digital'}]: ` +
154
+ `casados: ${s.matched} | idênticos token a token: ${s.identical} | divergentes: ${s.mismatches.length}`,
155
+ )
156
+ for (const m of s.mismatches.slice(0, 5)) {
157
+ console.log(` ! evento #${m.event_id} (${m.label ?? 'sem label'}): ${JSON.stringify(m.fields)}`)
158
+ }
159
+ }
160
+ if (r.uncovered.length) {
161
+ console.log(` - sem fonte local que cubra: ${r.uncovered.length} evento(s) — normal para TUIs sem log próprio de usage`)
162
+ for (const u of r.uncovered.slice(0, 3)) {
163
+ console.log(` · #${u.id} ${u.label ?? 'sem label'} via ${(u.client || '?').split(' ')[0]} (${u.target})`)
164
+ }
165
+ }
166
+ const clean = r.math.drift.length === 0 && mismatchTotal === 0
167
+ console.log(clean ? '\n✓ nenhuma divergência encontrada' : '\n✗ divergências acima — investigue')
168
+ process.exit(clean ? 0 : 1)
169
+ }
170
+
171
+ case 'summary': {
172
+ await ensureDaemon()
173
+ const since = flag(args, '--since', '30d')
174
+ const r = await fetchDaemon(`/api/summary?since=${encodeURIComponent(since)}`)
175
+ console.log(`período: ${since} — ${r.eventCount} chamadas, ${fmtTok(r.totalTokens)} tokens, ${fmtUsd(r.totalCost)}${r.costKnown ? '' : ' (custo parcial: há modelos sem preço na tabela)'}`)
176
+ for (const l of r.byLabel.slice(0, 10)) {
177
+ console.log(` ${l.key.padEnd(20)} ${fmtTok(l.tokens).padStart(9)} tok ${fmtUsd(l.cost).padStart(9)} (${l.events} chamadas)`)
178
+ }
179
+ return
180
+ }
181
+
182
+ case 'dashboard': {
183
+ await ensureDaemon()
184
+ const since = flag(args, '--since', '30d')
185
+ const out = flag(args, '--out', null)
186
+ if (out) {
187
+ const report = await fetchDaemon(`/api/summary?since=${encodeURIComponent(since)}`)
188
+ const template = fs.readFileSync(path.join(__dirname, '..', 'templates', 'dashboard.html'), 'utf8')
189
+ const json = JSON.stringify(report).replace(/</g, '\\u003c')
190
+ fs.writeFileSync(out, template.replace('"__REPORT_DATA__"', () => json))
191
+ console.log(`dashboard exportado: ${out}`)
192
+ return
193
+ }
194
+ const url = `http://127.0.0.1:${cfg.port}/dashboard?since=${encodeURIComponent(since)}`
195
+ console.log(url)
196
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
197
+ execFile(opener, [url], () => {})
198
+ return
199
+ }
200
+
201
+ case 'targets': {
202
+ console.log(`targets registrados (primeiro segmento do path escolhe o upstream):`)
203
+ for (const [name, t] of Object.entries(cfg.targets)) {
204
+ console.log(` /${name.padEnd(12)} -> ${t.targetUrl} [protocolo: ${t.protocol}]`)
205
+ }
206
+ console.log(`\nproxyUrl: ${cfg.proxyUrl}`)
207
+ return
208
+ }
209
+
210
+ case 'config': {
211
+ if (args[0] === 'porta' || args[0] === 'port') {
212
+ const n = parseInt(args[1], 10)
213
+ if (isNaN(n) || n < 1024 || n > 65535) {
214
+ console.error('porta deve ser um número entre 1024 e 65535')
215
+ process.exit(1)
216
+ }
217
+ writeConfig({ port: n, proxyUrl: `http://127.0.0.1:${n}` })
218
+ console.log(`porta alterada para ${n} — rode \`ttm stop && ttm start\` para aplicar`)
219
+ return
220
+ }
221
+ if (args[0] === 'target') {
222
+ // ttm config target -> lista
223
+ // ttm config target <name> <url> [opts] -> set
224
+ // ttm config target <name> --remove -> remove (ordem qualquer)
225
+ const sub = args.slice(1)
226
+ const isList = sub.length === 0 || sub.includes('--list') || sub.includes('list')
227
+ const isRemove = sub.includes('--remove') || sub.includes('remove')
228
+ const positional = sub.filter(x => x && !x.startsWith('--') && x !== 'list' && x !== 'remove')
229
+ if (isList) {
230
+ const c = readConfig()
231
+ for (const [name, t] of Object.entries(c.targets)) {
232
+ const inv = t._invalid ? ' [PROTOCOLO INVÁLIDO]' : ''
233
+ const env = t.targetUrlEnv ? ` via env ${t.targetUrlEnv}` : ''
234
+ console.log(` /${name.padEnd(14)} -> ${t.targetUrl}${env} [protocolo: ${t.protocol}]${inv}`)
235
+ }
236
+ const { warnings } = validateConfig(c)
237
+ for (const w of warnings) console.error(` ! ${w}`)
238
+ return
239
+ }
240
+ if (isRemove) {
241
+ const name = positional[0]
242
+ if (!name) { console.error('uso: ttm config target <name> --remove'); process.exit(1) }
243
+ removeTarget(name)
244
+ console.log(`target "${name}" removido`)
245
+ return
246
+ }
247
+ const name = positional[0]
248
+ const url = positional[1]
249
+ if (!name || !url) {
250
+ console.error('uso: ttm config target <name> <url> [--protocol P] [--env VAR]')
251
+ process.exit(1)
252
+ }
253
+ const protocol = flag(sub, '--protocol', 'anthropic')
254
+ const envVar = flag(sub, '--env', null)
255
+ if (!KNOWN_PROTOCOLS.includes(protocol)) {
256
+ console.error(`protocolo inválido "${protocol}" — válidos: ${KNOWN_PROTOCOLS.join(', ')}`)
257
+ process.exit(1)
258
+ }
259
+ const spec = { targetUrl: url, protocol }
260
+ if (envVar) spec.targetUrlEnv = envVar
261
+ setTarget(name, spec)
262
+ console.log(`target "${name}" -> ${url} [protocolo: ${protocol}${envVar ? `, env: ${envVar}` : ''}]`)
263
+ return
264
+ }
265
+ console.log(JSON.stringify({ ...cfg, pricingLastUpdated: PRICING_LAST_UPDATED }, null, 2))
266
+ return
267
+ }
268
+
269
+ case 'link': {
270
+ const tui = args[0]
271
+ if (!tui) { console.error('uso: ttm link <tui> [--label TICKET] [--repo] [--force]'); process.exit(1) }
272
+ const label = flag(args, '--label', null)
273
+ const repo = args.includes('--repo')
274
+ const force = args.includes('--force')
275
+ const r = await linkTui(tui, { label, repo, force })
276
+ if (r.supported === false) {
277
+ console.error(`TUI "${tui}" não tem auto-link. TUIs suportados: claude-code, codex, opencode, gemini-cli. Use \`ttm setup ${tui}\` para snippet manual.`)
278
+ process.exit(1)
279
+ }
280
+ if (r.needsForce) {
281
+ console.error(r.hint)
282
+ process.exit(1)
283
+ }
284
+ if (r.error) {
285
+ console.error(r.error)
286
+ process.exit(1)
287
+ }
288
+ if (r.alreadyLinked) {
289
+ console.log(`já linkado: ${r.tui} -> ${r.upstream} (proxy: ${r.proxyBaseUrl})`)
290
+ if (r.label) console.log(`label padrão: ${r.label}`)
291
+ return
292
+ }
293
+ console.log(`linkado: ${r.tui} -> upstream ${r.upstream} [protocolo: ${r.protocol}]`)
294
+ console.log(` base URL do ${r.tui} agora aponta pra: ${r.proxyBaseUrl}`)
295
+ if (r.rewrite.done) {
296
+ console.log(` config do TUI reescrita em: ${r.rewrite.writeTarget} (fonte: ${r.rewrite.source})`)
297
+ } else {
298
+ console.log(` ⚠ ${r.rewrite.note}`)
299
+ console.log(` rode no seu shell: ${r.rewrite.exportLine}`)
300
+ }
301
+ if (r.label) console.log(`label padrão: ${r.label}`)
302
+ console.log(` reinicie o ${r.tui} para que ele pegue a nova base URL`)
303
+ return
304
+ }
305
+
306
+ case 'unlink': {
307
+ const tui = args[0]
308
+ if (!tui) { console.error('uso: ttm unlink <tui>'); process.exit(1) }
309
+ const r = await unlinkTui(tui)
310
+ if (r.notLinked) {
311
+ console.log(`nada a desfazer: ${r.tui} não estava linkado`)
312
+ return
313
+ }
314
+ console.log(`deslinkado: ${r.tui}`)
315
+ if (r.restore.done) {
316
+ console.log(` config original restaurada em: ${r.restore.writeTarget}`)
317
+ } else {
318
+ console.log(` ⚠ ${r.restore.note}`)
319
+ console.log(` rode no seu shell: ${r.restore.exportLine}`)
320
+ }
321
+ console.log(` reinicie o ${r.tui} para aplicar`)
322
+ return
323
+ }
324
+
325
+ case 'setup': {
326
+ const { setupSnippet } = await import('../lib/setup-snippets.mjs')
327
+ console.log(setupSnippet(args[0], cfg))
328
+ return
329
+ }
330
+
331
+ default:
332
+ console.log(HELP)
333
+ if (cmd && cmd !== 'help' && cmd !== '--help' && cmd !== '-h') {
334
+ console.error(`\ncomando desconhecido: ${cmd}`)
335
+ process.exit(1)
336
+ }
337
+ }
338
+ }
339
+
340
+ main().catch(err => {
341
+ console.error(`erro: ${err.message}`)
342
+ process.exit(1)
343
+ })
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Mostra a configuração do proxy (porta, targets, label) e como ajustá-la
3
+ ---
4
+
5
+ Chame a ferramenta MCP `ttm_status` e apresente em português:
6
+ - proxyUrl e porta
7
+ - targets registrados (nome → targetUrl do provedor)
8
+ - label padrão atual
9
+
10
+ Se o usuário quiser MUDAR algo, explique os caminhos:
11
+ - Porta: `node <plugin>/bin/ttm.mjs config porta <n>` no terminal (ou editar `~/.claude/plugins/data/token-trace-manager/config.json`), seguido de `ttm stop && ttm start`.
12
+ - Targets extras (ex: um gateway interno): adicionar em `targets` no mesmo `config.json`, com `targetUrl` e `protocol` (`anthropic` | `openai` | `openai-responses` | `gemini`).
13
+ - Preços de modelos não listados: criar `pricing.override.json` no mesmo diretório com `{ "<model-id>": { "input": X, "output": Y } }` (USD por 1M tokens).
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Abre o dashboard local de uso/custo de tokens
3
+ ---
4
+
5
+ Chame a ferramenta MCP `ttm_dashboard_url` (ela garante que o daemon está de pé) e devolva a URL ao usuário em português, explicando que é um dashboard ao vivo servido localmente (só em 127.0.0.1) com custo por label/ticket, provedor, modelo, cliente (TUI) e dia.
6
+
7
+ Se o usuário pediu uma janela específica (ex: "últimos 7 dias"), acrescente `?since=7d` à URL retornada.
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Define o label padrão (ticket Jira) atribuído ao tráfego do proxy
3
+ argument-hint: <TICKET-123 | none>
4
+ ---
5
+
6
+ Chame a ferramenta MCP `ttm_activate` com `label` = `$ARGUMENTS` (ou `ttm_set_label` com `null` se o usuário passou `none`/`nenhum`). Sem argumento nenhum, chame `ttm_activate` sem label — ela detecta o ticket pela branch git. Se o usuário disser que já estava trabalhando antes de criar o card, use `retroactive_minutes` com a janela que ele indicar.
7
+
8
+ Contexto para explicar ao usuário (em português, uma ou duas frases):
9
+ - O label padrão vale para TODO tráfego que passar pelo proxy sem label embutido na URL, de qualquer TUI, até ser trocado.
10
+ - Para atribuição por repositório/branch sem depender do label padrão, o caminho é embutir o ticket na própria base URL (ex: `http://127.0.0.1:25519/anthropic/PROJ-123`) — mencione isso se o usuário parecer trabalhar em vários tickets ao mesmo tempo.
11
+
12
+ Se o usuário não passou argumento, chame `ttm_status` e informe apenas o label padrão atual.
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Configura o Claude Code (ou mostra o snippet de outro TUI) para passar pelo proxy de tokens
3
+ argument-hint: "[claude-code | codex | opencode | gemini-cli | kilo | ...]"
4
+ ---
5
+
6
+ O objetivo é apontar a base URL do TUI para o proxy local (`http://127.0.0.1:<porta>/<target>[/<TICKET-123>]`). Chame `ttm_status` para obter a porta e os targets atuais.
7
+
8
+ **Se `$ARGUMENTS` for vazio ou `claude-code`** — o TUI é o próprio Claude Code:
9
+ 1. Explique que a variável `ANTHROPIC_BASE_URL` precisa estar definida ANTES do Claude Code iniciar, então a mudança só vale para sessões novas.
10
+ 2. Proponha (e, com aprovação do usuário, aplique) adicionar ao `~/.claude/settings.json` — preservando o conteúdo existente — o bloco:
11
+ ```json
12
+ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:<porta>/anthropic" }
13
+ ```
14
+ Se o usuário quiser atribuição automática por ticket num repo específico, sugira colocar o mesmo bloco no `.claude/settings.json` DO REPO com o ticket no path (ex: `/anthropic/PROJ-123`).
15
+ 3. Avise: para desfazer, basta remover a env; e se o daemon estiver parado, as chamadas falham — o Claude Code não inicia sem conseguir falar com a URL. Sugira validar com `ttm_status` que o daemon está de pé antes de reiniciar.
16
+
17
+ **Se `$ARGUMENTS` nomear outro TUI** (codex, opencode, gemini-cli, kilo, etc.): rode `node ${CLAUDE_PLUGIN_ROOT}/bin/ttm.mjs setup $ARGUMENTS` via Bash e apresente o snippet impresso, explicando em português onde colar.
18
+
19
+ Sempre termine confirmando que o daemon está rodando (`ttm_status`) — sem ele de pé, o TUI configurado fica sem resposta.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Mostra o estado do proxy de tokens e o resumo de uso/custo
3
+ ---
4
+
5
+ Chame a ferramenta MCP `ttm_status` e depois `ttm_usage_summary` (sem argumentos, janela padrão de 30 dias).
6
+
7
+ Responda em português, compacto:
8
+ 1. Daemon rodando ou parado (se parado, diga que `ttm_dashboard_url` ou `ttm start` no terminal sobe ele).
9
+ 2. Label padrão atual (ou que o tráfego está caindo em "Sem label").
10
+ 3. Total de chamadas, tokens e custo da janela, e os 3 maiores labels por custo.
11
+ 4. A URL do dashboard.
12
+
13
+ Se `costKnown` vier `false`, avise que o custo é parcial (há modelos sem preço na tabela — tokens registrados mesmo assim).