token-trace-manager 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Proxy local universal de rastreio de tokens/custo para TUIs de código, com labels por ticket e dashboard local",
8
- "version": "0.7.0"
8
+ "version": "0.7.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "ttm",
13
13
  "source": "./",
14
14
  "description": "Proxy local universal de rastreio de tokens/custo para TUIs de código (Claude Code, Codex, OpenCode, Gemini CLI, Kilo...), com labels por ticket e dashboard local.",
15
- "version": "0.7.0",
15
+ "version": "0.7.1",
16
16
  "category": "productivity"
17
17
  }
18
18
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ttm",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Proxy local universal de rastreio de tokens/custo para TUIs de código (Claude Code, Codex, OpenCode, Gemini CLI, Kilo...), com labels por ticket e dashboard local.",
5
5
  "author": {
6
6
  "name": "Mpaape"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Mudanças notáveis deste projeto. Formato: [Keep a Changelog](https://keepachangelog.com/), [semver](https://semver.org/).
4
4
 
5
+ ## [0.7.1] - 2026-07-09
6
+
7
+ ### Fixed
8
+ - Roteamento de labels por URL agora é inequívoco: tickets continuam em `/{target}/PROJ-123/...`, e slugs descritivos usam `/{target}/-/{slug}/...`, sem roubar paths hifenizados reais do upstream.
9
+ - Payloads redigem recursivamente campos secretos em bodies JSON (`api_key`, `access_token`, `authorization`, `client_secret`, etc.) antes de gravar `payloads/*.json`.
10
+ - `/api/summary` sem `since` volta a usar o default documentado de `30d`.
11
+ - `ttm config` redige `otel.headers` antes de imprimir a configuração.
12
+ - `ttm audit --since all` aceita `all` sem gerar data inválida.
13
+ - MCP reporta a versão real do pacote e `ttm_set_label` sem `label` não limpa mais o estado por acidente.
14
+ - `ttm link codex` cria um provider `ttm` completo quando `~/.codex/config.toml` existe mas ainda não tem provider ativo.
15
+
16
+ ### Changed
17
+ - SQLite inicializa com WAL, `busy_timeout` e índices adicionais para reduzir colisões entre daemon, auditoria e consultas.
18
+ - Erros reais de schema/migração SQLite deixam de cair silenciosamente para o backend JSON; fallback JSON fica restrito a runtimes sem `node:sqlite` ou ao hook de teste.
19
+ - E2E ampliado para 154 asserções cobrindo as regressões acima, backend JSON, redaction de body e smoke de CLI.
20
+
5
21
  ## [0.7.0] - 2026-07-08
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -16,7 +16,8 @@ Um único daemon local intercepta as chamadas de API de todas as suas ferramenta
16
16
  Claude Code ──┐ ┌──> api.anthropic.com
17
17
  Codex ────────┤ proxyURL ├──> api.openai.com
18
18
  OpenCode ─────┼─> http://127.0.0.1:25519 ┼──> generativelanguage.googleapis.com
19
- Gemini CLI ───┤ /{target}/{TICKET?}/… ├──> openrouter.ai/api
19
+ Gemini CLI ───┤ /{target}/{TICKET?}/… ├──> openrouter.ai/api
20
+ │ /{target}/-/{slug}/… │
20
21
  Kilo, etc ────┘ │ └──> (targets customizados)
21
22
  │ tee (só metadados de usage)
22
23
  v
@@ -26,48 +27,38 @@ Um único daemon local intercepta as chamadas de API de todas as suas ferramenta
26
27
  ```
27
28
 
28
29
  - **targetUrl** — a URL real do provedor que o TUI chamaria sem proxy (já vem preenchida para `anthropic`, `openai`, `gemini` e `openrouter`; adicione outras no config).
29
- - **proxyURL** — `http://127.0.0.1:25519`. O primeiro segmento do path escolhe o target; um segundo segmento opcional no formato `TICKET-123` vira o label daquela chamada.
30
+ - **proxyURL** — `http://127.0.0.1:25519`. O primeiro segmento do path escolhe o target; um segundo segmento opcional no formato `TICKET-123` vira o label daquela chamada. Para labels descritivos que não são ticket, use a forma explícita `/{target}/-/{slug}/...`, sem ambiguidade com o path real do provedor.
30
31
 
31
32
  ## Instalação
32
33
 
33
- Escolha o caminho pelo seu caso cada um é completo e autossuficiente.
34
+ > Pré-requisito: **Node 22.5**. Sem dependências, sem build é Node puro.
34
35
 
35
- ### Caminho A — uso o Claude Code (plugin, recomendado)
36
-
37
- ```
38
- 1. /plugin marketplace add cloud104/token-trace-manager
39
- 2. /plugin install ttm@token-trace-manager
40
- 3. Reinicie o Claude Code (plugins carregam na inicialização)
41
- 4. /ttm:setup (sobe o daemon e, com sua aprovação,
42
- aponta o ANTHROPIC_BASE_URL pro proxy)
43
- 5. Reinicie o Claude Code de novo (a env var só vale pra sessão nova)
36
+ ```bash
37
+ npm install -g token-trace-manager # 1. instala o bin `ttm`
38
+ ttm start # 2. sobe o daemon (porta 25519, só 127.0.0.1)
39
+ ttm link claude-code # 3. insere o ttm no meio do seu TUI
40
+ ttm status # pronto — daemon rodando
44
41
  ```
45
42
 
46
- São **dois reinícios** mesmo um para o plugin carregar, outro para a env var valer. na primeira vez; depois é usar. Valide com `/ttm:status`: se aparecer o daemon rodando e as chamadas contando, está pronto. Os comandos disponíveis: `/ttm:setup`, `/ttm:status`, `/ttm:link`, `/ttm:dashboard`, `/ttm:config`.
47
-
48
- Quer rastrear outros TUIs além do Claude Code? O daemon já é o mesmo — só falta apontar cada ferramenta: `/ttm:setup codex` (ou `opencode`, `gemini-cli`...) mostra o snippet.
43
+ `ttm link` suporta `claude-code`, `codex`, `opencode`, `gemini-cli`. Ele descobre a base URL que o TUI usa, vira upstream dela e reescreve a config do TUI pra passar pelo ttm. Depois, é usar o TUI normalmente cada chamada conta tokens.
49
44
 
50
- ### Caminho B qualquer outro TUI (standalone, sem Claude Code)
45
+ ![ttm instala e roda em 4 comandos](.github/assets/ttm-demo.gif)
51
46
 
52
- ```bash
53
- npm install -g token-trace-manager # instala o bin `ttm` (Node ≥ 22.5)
54
-
55
- ttm start # 1. sobe o daemon (idempotente)
56
- ttm link claude-code # 2. descobre a base URL do TUI e se insere no meio
57
- # (use: claude-code, codex, opencode, gemini-cli)
58
- ttm status # 3. confirma que está de pé
59
- ```
47
+ > **Já redireciona pra um compatível-anthropic (z.ai/GLM/...)?** O `ttm link` **preserva** seu redirect — ele descobre pra onde você aponta, vira upstream disso, e reescreve a config do TUI. Continua batendo no mesmo lugar, só que com contagem de tokens.
60
48
 
61
- > **Já redireciona pra um compatível-anthropic (z.ai/GLM/...)?** O `ttm link` **preserva** o seu redirect — ele descobre pra onde você aponta, vira upstream disso, e reescreve a config do TUI pra passar pelo ttm. Continua batendo no mesmo lugar, só que com contagem de tokens.
49
+ <details>
50
+ <summary><strong>Outros caminhos</strong> (sem npm, plugin do Claude Code, etc.)</summary>
62
51
 
63
- Sem npm? Funciona pelo git também:
52
+ **Sem npm via git:**
64
53
  ```bash
65
54
  git clone https://github.com/cloud104/token-trace-manager
66
55
  cd token-trace-manager
67
- npm link # deixa `ttm` no PATH (sem isso, use `node bin/ttm.mjs`)
56
+ npm link # deixa `ttm` no PATH (sem isso, use `node bin/ttm.mjs`)
68
57
  ```
69
58
 
70
- Requer Node 22.5 (SQLite nativo via `node:sqlite`). Sem dependênciasé Node puro.
59
+ **Plugin do Claude Code (`/plugin install`):** exige que o repo seja **público** (`/plugin marketplace add cloud104/token-trace-manager` clona o repo). Enquanto o repo estiver privado, use o caminho do `npm` acima funciona pro Claude Code também, e o `ttm link claude-code` cuida de tudo (inclusive do redirect). Não há perda de função: as tools MCP (`ttm_status`, `ttm_set_label`, `ttm_usage_summary`) ficam disponíveis em qualquer TUI compatível com MCP via `.mcp.json`.
60
+
61
+ </details>
71
62
 
72
63
  ## No dia a dia
73
64
 
@@ -143,11 +134,11 @@ período: 24h — 1 chamadas, 42.9k tokens, $0.52
143
134
  TESTE-1 42.9k tok $0.52 (1 chamadas)
144
135
  ```
145
136
 
146
- Se apareceu, está tudo validado de uma vez: daemon de pé, passthrough com sua autenticação intacta, extração de usage, atribuição por label e custo calculado. Abra `http://127.0.0.1:25519/dashboard` para ver no navegador. (Para testar os internos sem rede: `npm test` — 40 asserções contra um upstream fake.)
137
+ Se apareceu, está tudo validado de uma vez: daemon de pé, passthrough com sua autenticação intacta, extração de usage, atribuição por label e custo calculado. Abra `http://127.0.0.1:25519/dashboard` para ver no navegador. (Para testar os internos sem rede: `npm test` — 154 asserções contra um upstream fake.)
147
138
 
148
139
  ## Configurando cada TUI
149
140
 
150
- O padrão é sempre o mesmo: trocar a base URL da ferramenta de `targetUrl` (a URL padrão do provedor) para `proxyURL/{target}`. Com um ticket no path (`proxyURL/{target}/PROJ-123`), todo o tráfego daquela configuração é atribuído ao ticket. `ttm setup <tui>` imprime o snippet da sua porta atual.
141
+ O padrão é sempre o mesmo: trocar a base URL da ferramenta de `targetUrl` (a URL padrão do provedor) para `proxyURL/{target}`. Com um ticket no path (`proxyURL/{target}/PROJ-123`), todo o tráfego daquela configuração é atribuído ao ticket. Para slug descritivo, use `proxyURL/{target}/-/meu-slug`. `ttm setup <tui>` imprime o snippet da sua porta atual.
151
142
 
152
143
  ### Claude Code
153
144
  `targetUrl` padrão: `https://api.anthropic.com` — override por env var `ANTHROPIC_BASE_URL` (precisa existir **antes** do processo iniciar):
@@ -267,7 +258,7 @@ O modo padrão (backend proprietário do Copilot) não é rastreável.
267
258
 
268
259
  Duas formas, combináveis:
269
260
 
270
- 1. **Na URL** (recomendado, por repo/branch): `http://127.0.0.1:25519/anthropic/PROJ-123` — tudo que passar por essa configuração cai no `PROJ-123`. Ideal via `.claude/settings.json` por repositório, ou um `opencode.json` por projeto.
261
+ 1. **Na URL** (recomendado, por repo/branch): `http://127.0.0.1:25519/anthropic/PROJ-123` — tudo que passar por essa configuração cai no `PROJ-123`. Para um label que não é ticket, use `http://127.0.0.1:25519/anthropic/-/meu-slug`. Ideal via `.claude/settings.json` por repositório, ou um `opencode.json` por projeto.
271
262
  2. **Label padrão global**: `ttm label PROJ-123` (ou tool MCP `ttm_set_label`, ou `/ttm:link PROJ-123` no Claude Code) — vale para todo tráfego **sem** label na URL, de qualquer TUI, até trocar. `ttm label none` limpa.
272
263
 
273
264
  Chamadas sem nenhum label aparecem no dashboard como "Sem label" — nada se perde.
@@ -359,7 +350,7 @@ Arquivo: `~/.claude/plugins/data/token-trace-manager/config.json` (criado sob de
359
350
  node test/e2e.mjs
360
351
  ```
361
352
 
362
- Sobe um upstream fake + o daemon real e valida: passthrough de streaming e não-streaming nos 4 protocolos, labels por URL e por padrão, normalização de tokens (cache/reasoning), cálculo de custo, injeção+filtragem de `include_usage`, endpoints locais, dashboard, privacidade (nenhuma chave ou conteúdo persistido) e as regressões da revisão adversarial (XSS via User-Agent, corrupção por `$'` no template, rotas herdadas de protótipo, erros de protocolo MCP). 81+ asserções, cobrindo também retenção/rollups, contexto repo/branch, relabel retroativo, sessionização de traces e o exportador OTel.
353
+ Sobe um upstream fake + o daemon real e valida: passthrough de streaming e não-streaming nos 4 protocolos, labels por URL e por padrão, normalização de tokens (cache/reasoning), cálculo de custo, injeção+filtragem de `include_usage`, endpoints locais, dashboard, privacidade (headers e segredos no body não persistem) e regressões adversariais (XSS via User-Agent, corrupção por `$'` no template, rotas herdadas de protótipo, paths hifenizados, erros de protocolo MCP). 150+ asserções, cobrindo também retenção/rollups, contexto repo/branch, relabel retroativo, sessionização de traces, backend JSON e o exportador OTel.
363
354
 
364
355
  ## Upgrade
365
356
 
package/bin/ttm.mjs CHANGED
@@ -9,6 +9,7 @@ import { readConfig, writeConfig, setTarget, removeTarget, validateConfig, KNOWN
9
9
  import { ensureDaemon, stopDaemon, daemonHealth, fetchDaemon } from '../lib/daemon-client.mjs'
10
10
  import { linkTui, unlinkTui } from '../lib/link.mjs'
11
11
  import { PRICING_LAST_UPDATED } from '../lib/pricing.mjs'
12
+ import { sinceToIso } from '../lib/report-data.mjs'
12
13
 
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
14
15
 
@@ -59,6 +60,14 @@ function fmtTok(n) {
59
60
 
60
61
  const fmtUsd = n => '$' + (n || 0).toFixed(2)
61
62
 
63
+ function printableConfig(cfg) {
64
+ const out = JSON.parse(JSON.stringify(cfg))
65
+ if (out.otel?.headers) {
66
+ out.otel.headers = Object.fromEntries(Object.keys(out.otel.headers).map(k => [k, '[REDACTED]']))
67
+ }
68
+ return out
69
+ }
70
+
62
71
  async function main() {
63
72
  const [cmd, ...args] = process.argv.slice(2)
64
73
  const cfg = readConfig()
@@ -131,9 +140,7 @@ async function main() {
131
140
  case 'audit': {
132
141
  const { runAudit } = await import('../lib/audit.mjs')
133
142
  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
143
+ const sinceIso = sinceToIso(since)
137
144
  const r = await runAudit({ sinceIso })
138
145
  console.log(`auditoria (${since ?? 'tudo'}) — ${r.eventCount} eventos\n`)
139
146
  console.log(`[1] matemática interna (custo = tokens x taxas gravadas na hora da chamada)`)
@@ -262,7 +269,7 @@ async function main() {
262
269
  console.log(`target "${name}" -> ${url} [protocolo: ${protocol}${envVar ? `, env: ${envVar}` : ''}]`)
263
270
  return
264
271
  }
265
- console.log(JSON.stringify({ ...cfg, pricingLastUpdated: PRICING_LAST_UPDATED }, null, 2))
272
+ console.log(JSON.stringify(printableConfig({ ...cfg, pricingLastUpdated: PRICING_LAST_UPDATED }), null, 2))
266
273
  return
267
274
  }
268
275
 
package/commands/setup.md CHANGED
@@ -3,7 +3,7 @@ description: Configura o Claude Code (ou mostra o snippet de outro TUI) para pas
3
3
  argument-hint: "[claude-code | codex | opencode | gemini-cli | kilo | ...]"
4
4
  ---
5
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.
6
+ O objetivo é apontar a base URL do TUI para o proxy local (`http://127.0.0.1:<porta>/<target>[/<TICKET-123>]`; para slug descritivo use `/<target>/-/<slug>`). Chame `ttm_status` para obter a porta e os targets atuais.
7
7
 
8
8
  **Se `$ARGUMENTS` for vazio ou `claude-code`** — o TUI é o próprio Claude Code:
9
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.
@@ -50,7 +50,10 @@ export async function ensureDaemon() {
50
50
  // Daemon de uma versão antiga ainda de pé após upgrade: template/dados
51
51
  // ficariam inconsistentes — reinicia com o código novo automaticamente.
52
52
  if (health) {
53
- await stopDaemon()
53
+ const stopped = await stopDaemon()
54
+ if (!stopped.stopped || stopped.graceful === false) {
55
+ throw new Error(`daemon antigo (pid ${health.pid}) não parou com segurança: ${stopped.reason ?? 'timeout aguardando saída'}`)
56
+ }
54
57
  const h = await spawnDaemon()
55
58
  return { started: true, restartedFrom: health.version, health: h }
56
59
  }
package/lib/daemon.mjs CHANGED
@@ -8,10 +8,11 @@
8
8
  // storePayloads estiver ligado (default: ligado) — chaves de API nunca,
9
9
  // em nenhuma hipótese, vão parar lá.
10
10
  //
11
- // Roteamento: http://127.0.0.1:PORT/{target}/{label?}/{caminho-upstream}
12
- // target : chave em config.targets (anthropic, openai, gemini, ...)
13
- // label : segmento opcional estilo PROJ-123 para atribuir o custo
14
- // resto : caminho repassado ao targetUrl do provedor
11
+ // Roteamento:
12
+ // http://127.0.0.1:PORT/{target}/{TICKET-123}/{caminho-upstream}
13
+ // http://127.0.0.1:PORT/{target}/-/{label-slug}/{caminho-upstream}
14
+ // O ticket curto continua conveniente; labels descritivos usam "/-/" para não
15
+ // roubar segmentos reais do path do provedor (ex: /api-v2/chat/completions).
15
16
 
16
17
  import fs from 'node:fs'
17
18
  import http from 'node:http'
@@ -32,12 +33,10 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.j
32
33
 
33
34
  // Label = qualquer identificador de tarefa: ticket real (PROJ-123) OU slug
34
35
  // descritivo (TCW-poc-proxy-contabilizacao-custo-ia) — não exige card já criado.
35
- // Exige AO MENOS UM hífen: é a assinatura que distingue um label de um
36
- // segmento normal de path do provedor (v1, v1beta, messages, models, ...),
37
- // nenhum dos quais tem hífen — sem isso o roteamento interpretaria "v1" como
38
- // label e quebraria toda chamada sem label explícito na URL.
39
36
  const LABEL_RE = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/
37
+ const TICKET_LABEL_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/
40
38
  const LABEL_MAX_LEN = 120
39
+ const SECRET_KEY_RE = /(?:api[_-]?key|access[_-]?token|auth(?:orization)?|auth[_-]?token|bearer[_-]?token|client[_-]?secret|refresh[_-]?token|id[_-]?token|password|secret|credential)/i
41
40
  const HOP_BY_HOP = new Set([
42
41
  'connection',
43
42
  'keep-alive',
@@ -53,6 +52,67 @@ const MAX_JSON_CAPTURE = 32 * 1024 * 1024
53
52
 
54
53
  const startedAt = Date.now()
55
54
 
55
+ function isValidLabel(label) {
56
+ return typeof label === 'string' && label.length > 0 && label.length <= LABEL_MAX_LEN && LABEL_RE.test(label)
57
+ }
58
+
59
+ function invalidLabelMessage(label, action = 'inválido') {
60
+ return `label "${label ?? ''}" ${action} (ex: PROJ-123 ou /-/um-slug-descritivo, até ${LABEL_MAX_LEN} caracteres)`
61
+ }
62
+
63
+ function parseProxyRoute(segments, search) {
64
+ let label = null
65
+ let restIdx = 1
66
+ const second = segments[1]
67
+
68
+ if (second === '-') {
69
+ const explicit = segments[2]
70
+ if (!isValidLabel(explicit)) return { error: invalidLabelMessage(explicit) }
71
+ label = explicit.toUpperCase()
72
+ restIdx = 3
73
+ } else if (second && TICKET_LABEL_RE.test(second)) {
74
+ if (second.length > LABEL_MAX_LEN) return { error: invalidLabelMessage(second) }
75
+ label = second.toUpperCase()
76
+ restIdx = 2
77
+ }
78
+
79
+ return {
80
+ label,
81
+ upstreamPath: '/' + segments.slice(restIdx).join('/') + search,
82
+ }
83
+ }
84
+
85
+ function redactRawSecrets(text) {
86
+ return text
87
+ .replace(/("(?:api[_-]?key|access[_-]?token|auth(?:orization)?|auth[_-]?token|client[_-]?secret|password|secret)"\s*:\s*")([^"]*)(")/gi, '$1[REDACTED]$3')
88
+ .replace(/\b(api[_-]?key|access[_-]?token|authorization|auth[_-]?token|client[_-]?secret|password|secret)\s*[:=]\s*([^\s&;,]+)/gi, '$1=[REDACTED]')
89
+ }
90
+
91
+ function redactSecrets(value, seen = new WeakSet()) {
92
+ if (!value || typeof value !== 'object') return value
93
+ if (seen.has(value)) return '[Circular]'
94
+ seen.add(value)
95
+ if (Array.isArray(value)) return value.map(v => redactSecrets(v, seen))
96
+
97
+ const out = {}
98
+ for (const [k, v] of Object.entries(value)) {
99
+ out[k] = SECRET_KEY_RE.test(k) ? '[REDACTED]' : redactSecrets(v, seen)
100
+ }
101
+ return out
102
+ }
103
+
104
+ function payloadRequestFromBody(requestBody) {
105
+ if (requestBody.length > 4 * 1024 * 1024) {
106
+ return { _truncado: true, _bytes: requestBody.length }
107
+ }
108
+ const text = requestBody.toString('utf8')
109
+ try {
110
+ return redactSecrets(JSON.parse(text))
111
+ } catch {
112
+ return { _raw: redactRawSecrets(text).slice(0, 1024 * 1024) }
113
+ }
114
+ }
115
+
56
116
  function sendJson(res, status, obj) {
57
117
  const body = JSON.stringify(obj)
58
118
  res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) })
@@ -101,8 +161,8 @@ async function handleLocal(req, res, pathname, searchParams) {
101
161
  })
102
162
  }
103
163
  const label = payload.label ?? null
104
- if (label !== null && (label.length > LABEL_MAX_LEN || !LABEL_RE.test(label))) {
105
- return sendJson(res, 400, { error: `label "${label}" inválido (ex: PROJ-123 ou um-slug-descritivo, até ${LABEL_MAX_LEN} caracteres)` })
164
+ if (label !== null && !isValidLabel(label)) {
165
+ return sendJson(res, 400, { error: invalidLabelMessage(label) })
106
166
  }
107
167
  const normalized = label ? label.toUpperCase() : null
108
168
  await setKv('default_label', normalized)
@@ -227,7 +287,7 @@ async function handleLocal(req, res, pathname, searchParams) {
227
287
  let label = payload.label ?? null
228
288
  if (label !== null) {
229
289
  if (label.length > LABEL_MAX_LEN || !LABEL_RE.test(label)) {
230
- return sendJson(res, 400, { error: `label "${label}" inválido para deleção (ex: PROJ-123 ou um-slug-descritivo)` })
290
+ return sendJson(res, 400, { error: invalidLabelMessage(label, 'inválido para deleção') })
231
291
  }
232
292
  label = label.toUpperCase()
233
293
  }
@@ -469,16 +529,6 @@ async function proxyRequest(req, res, cfg, targetName, label, upstreamPath) {
469
529
  if (!storePayloads || !id) return
470
530
  // Conteúdo da chamada para inspeção (segue a retenção; SEM
471
531
  // headers — chaves de API nunca tocam o disco)
472
- let request
473
- if (requestBody.length > 4 * 1024 * 1024) {
474
- request = { _truncado: true, _bytes: requestBody.length }
475
- } else {
476
- try {
477
- request = JSON.parse(requestBody.toString('utf8'))
478
- } catch {
479
- request = { _raw: requestBody.toString('utf8').slice(0, 1024 * 1024) }
480
- }
481
- }
482
532
  fs.writeFileSync(
483
533
  payloadPath(id),
484
534
  JSON.stringify({
@@ -486,7 +536,7 @@ async function proxyRequest(req, res, cfg, targetName, label, upstreamPath) {
486
536
  ts: evRecord.ts ?? new Date().toISOString(),
487
537
  label: evRecord.label,
488
538
  model: evRecord.model,
489
- request,
539
+ request: payloadRequestFromBody(requestBody),
490
540
  response: { text: u.text || null },
491
541
  }),
492
542
  )
@@ -535,14 +585,9 @@ export function createDaemonServer() {
535
585
  return await handleLocal(req, res, parsed.pathname, parsed.searchParams)
536
586
  }
537
587
 
538
- let label = null
539
- let restIdx = 1
540
- if (segments.length > 1 && LABEL_RE.test(segments[1])) {
541
- label = segments[1].toUpperCase()
542
- restIdx = 2
543
- }
544
- const upstreamPath = '/' + segments.slice(restIdx).join('/') + parsed.search
545
- await proxyRequest(req, res, cfg, first, label, upstreamPath)
588
+ const route = parseProxyRoute(segments, parsed.search)
589
+ if (route.error) return sendJson(res, 400, { error: route.error })
590
+ await proxyRequest(req, res, cfg, first, route.label, route.upstreamPath)
546
591
  } catch (err) {
547
592
  if (!res.headersSent) sendJson(res, 500, { error: err.message })
548
593
  else res.destroy()
package/lib/db.mjs CHANGED
@@ -58,6 +58,8 @@ CREATE TABLE IF NOT EXISTS usage_events (
58
58
  );
59
59
  CREATE INDEX IF NOT EXISTS idx_usage_events_ts ON usage_events(ts);
60
60
  CREATE INDEX IF NOT EXISTS idx_usage_events_label ON usage_events(label);
61
+ CREATE INDEX IF NOT EXISTS idx_usage_events_target ON usage_events(target);
62
+ CREATE INDEX IF NOT EXISTS idx_usage_events_label_ts ON usage_events(label, ts);
61
63
 
62
64
  CREATE TABLE IF NOT EXISTS kv (
63
65
  key TEXT PRIMARY KEY,
@@ -83,6 +85,8 @@ CREATE TABLE IF NOT EXISTS daily_rollups (
83
85
  events INTEGER DEFAULT 0,
84
86
  PRIMARY KEY (day, label, target, model, client)
85
87
  );
88
+ CREATE INDEX IF NOT EXISTS idx_daily_rollups_day ON daily_rollups(day);
89
+ CREATE INDEX IF NOT EXISTS idx_daily_rollups_label_day ON daily_rollups(label, day);
86
90
  `
87
91
 
88
92
  // Migração aditiva: bases criadas por versões antigas ganham as colunas novas.
@@ -110,26 +114,38 @@ function migrate(db) {
110
114
 
111
115
  let backend = null // { kind: 'sqlite', db } | { kind: 'json', file }
112
116
 
117
+ function jsonBackend() {
118
+ const file = path.join(dataDir(), 'ttm-events.json')
119
+ if (!fs.existsSync(file)) {
120
+ fs.writeFileSync(file, JSON.stringify({ nextId: 1, events: [], kv: {} }, null, 2))
121
+ }
122
+ backend = { kind: 'json', file }
123
+ return backend
124
+ }
125
+
113
126
  async function getBackend() {
114
127
  if (backend) return backend
128
+ if (process.env.TTM_FORCE_JSON_BACKEND === '1') return jsonBackend()
129
+
130
+ let DatabaseSync
131
+ try {
132
+ ;({ DatabaseSync } = await import('node:sqlite'))
133
+ } catch (err) {
134
+ // Fallback JSON existe só quando o runtime não oferece node:sqlite.
135
+ if (err?.code === 'ERR_UNKNOWN_BUILTIN_MODULE' || /node:sqlite/.test(err?.message || '')) return jsonBackend()
136
+ throw err
137
+ }
138
+
139
+ const db = new DatabaseSync(path.join(dataDir(), 'ttm.sqlite'))
115
140
  try {
116
- const { DatabaseSync } = await import('node:sqlite')
117
- const db = new DatabaseSync(path.join(dataDir(), 'ttm.sqlite'))
141
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000')
118
142
  db.exec(SCHEMA)
119
143
  migrate(db)
120
- backend = { kind: 'sqlite', db }
121
144
  } catch (err) {
122
- // Fallback JSON existe para Node < 22.5 (sem node:sqlite). Qualquer outro
123
- // erro (schema/migração) precisa aparecer nunca trocar de backend mudo.
124
- if (err?.code !== 'ERR_UNKNOWN_BUILTIN_MODULE' && !/node:sqlite/.test(err?.message || '')) {
125
- process.stderr.write(`ttm: sqlite indisponível (${err.message}) — usando fallback JSON\n`)
126
- }
127
- const file = path.join(dataDir(), 'ttm-events.json')
128
- if (!fs.existsSync(file)) {
129
- fs.writeFileSync(file, JSON.stringify({ nextId: 1, events: [], kv: {} }, null, 2))
130
- }
131
- backend = { kind: 'json', file }
145
+ try { db.close() } catch {}
146
+ throw new Error(`sqlite falhou ao inicializar/migrar: ${err.message}`)
132
147
  }
148
+ backend = { kind: 'sqlite', db }
133
149
  return backend
134
150
  }
135
151
 
@@ -354,12 +370,22 @@ export async function listRollups({ sinceDay, label } = {}) {
354
370
  const b = await getBackend()
355
371
  let rows
356
372
  if (b.kind === 'sqlite') {
357
- rows = b.db.prepare('SELECT * FROM daily_rollups').all()
373
+ const where = []
374
+ const params = []
375
+ if (sinceDay) {
376
+ where.push('day >= ?')
377
+ params.push(sinceDay)
378
+ }
379
+ if (label) {
380
+ where.push('label = ?')
381
+ params.push(label)
382
+ }
383
+ rows = b.db.prepare('SELECT * FROM daily_rollups' + (where.length ? ' WHERE ' + where.join(' AND ') : '')).all(...params)
358
384
  } else {
359
385
  rows = Object.values(readJson(b.file).rollups || {})
360
386
  }
361
- if (sinceDay) rows = rows.filter(r => r.day >= sinceDay)
362
- if (label) rows = rows.filter(r => (r.label || '') === label) // '' interno = sem label; filtro '' não chega aqui (guard truthy)
387
+ if (b.kind !== 'sqlite' && sinceDay) rows = rows.filter(r => r.day >= sinceDay)
388
+ if (b.kind !== 'sqlite' && label) rows = rows.filter(r => (r.label || '') === label) // '' interno = sem label; filtro '' não chega aqui (guard truthy)
363
389
  return rows
364
390
  }
365
391
 
@@ -5,11 +5,16 @@
5
5
  // OpenCode (opencode.json "mcp"), etc. Garante o daemon do proxy de pé.
6
6
 
7
7
  import { execFileSync } from 'node:child_process'
8
+ import fs from 'node:fs'
8
9
  import path from 'node:path'
9
10
  import readline from 'node:readline'
11
+ import { fileURLToPath } from 'node:url'
10
12
  import { readConfig } from './config.mjs'
11
13
  import { ensureDaemon, daemonHealth, fetchDaemon } from './daemon-client.mjs'
12
14
 
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
17
+
13
18
  // O servidor MCP roda DENTRO do ambiente do TUI (cwd = projeto do usuário) —
14
19
  // é daqui que repo/branch são detectados e viram tags nos traces.
15
20
  function detectContext() {
@@ -29,7 +34,7 @@ function detectContext() {
29
34
 
30
35
  const TICKET_RE = /[A-Z][A-Z0-9]+-\d+/i
31
36
 
32
- const SERVER_INFO = { name: 'token-trace-manager', version: '0.6.0' }
37
+ const SERVER_INFO = { name: 'token-trace-manager', version: VERSION }
33
38
  // Negociação (spec): se a versão pedida está na lista, ecoa; senão responde
34
39
  // com a nossa mais recente (não é erro — o client decide se desconecta).
35
40
  const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2024-11-05']
@@ -153,6 +158,7 @@ async function callTool(name, args) {
153
158
  }
154
159
  case 'ttm_set_label': {
155
160
  await ensureDaemon()
161
+ if (!Object.hasOwn(args, 'label')) throw new Error('label é obrigatório; passe string ou null para limpar')
156
162
  const r = await fetchDaemon('/api/label', { method: 'POST', body: { label: args.label ?? null } })
157
163
  return { defaultLabel: r.defaultLabel }
158
164
  }
@@ -1,6 +1,6 @@
1
1
  import { listUsageEvents, listRollups } from './db.mjs'
2
2
 
3
- function sinceToIso(since) {
3
+ export function sinceToIso(since) {
4
4
  if (!since || since === 'all') return null
5
5
  if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since
6
6
  const m = /^(\d+)([dh])$/.exec(since)
@@ -10,6 +10,7 @@ function sinceToIso(since) {
10
10
  }
11
11
 
12
12
  export async function buildReport({ since = '30d', label = null } = {}) {
13
+ since = since ?? '30d'
13
14
  if (label) label = label.toUpperCase() // labels são armazenados em maiúsculas
14
15
  const sinceIso = sinceToIso(since)
15
16
  const rows = await listUsageEvents({ since: sinceIso, label })
@@ -21,7 +21,8 @@ export function setupSnippet(tui, cfg) {
21
21
  Para desfazer: ttm unlink claude-code
22
22
 
23
23
  Manual (sem auto-link): setar ANTHROPIC_BASE_URL=${P}/anthropic
24
- (ou ${P}/anthropic/PROJ-123 p/ ticket embutido). Apontar pra host !=
24
+ (ou ${P}/anthropic/PROJ-123 p/ ticket embutido; para slug descritivo,
25
+ use ${P}/anthropic/-/meu-slug). Apontar pra host !=
25
26
  api.anthropic.com desativa o Remote Control do Claude Code (v2.1.196+).
26
27
  Auth continua passando intacta.`,
27
28
 
@@ -41,6 +42,7 @@ export function setupSnippet(tui, cfg) {
41
42
  wire_api = "responses"
42
43
  env_key = "OPENAI_API_KEY"
43
44
  (com ticket: base_url = "${P}/openai/PROJ-123/v1")
45
+ (com slug: base_url = "${P}/openai/-/meu-slug/v1")
44
46
  Prefira model_providers a openai_base_url/OPENAI_BASE_URL — há releases do
45
47
  Codex com bug que ignora essas formas (issue #16719).`,
46
48
 
@@ -59,7 +61,8 @@ export function setupSnippet(tui, cfg) {
59
61
  "openai": { "options": { "baseURL": "${P}/openai/v1" } }
60
62
  }
61
63
  }
62
- (com ticket: "baseURL": "${P}/anthropic/PROJ-123")`,
64
+ (com ticket: "baseURL": "${P}/anthropic/PROJ-123";
65
+ com slug: "baseURL": "${P}/anthropic/-/meu-slug")`,
63
66
 
64
67
  'gemini-cli': `Gemini CLI (Google) — caminho primário: auto-link:
65
68
 
@@ -73,7 +76,8 @@ export function setupSnippet(tui, cfg) {
73
76
  Manual: env vars (http:// permitido para 127.0.0.1):
74
77
  export GEMINI_API_KEY=sua-chave
75
78
  export GOOGLE_GEMINI_BASE_URL="${P}/gemini"
76
- (com ticket: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/PROJ-123")
79
+ (com ticket: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/PROJ-123";
80
+ com slug: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/-/meu-slug")
77
81
  Limitação: só o modo API key é rastreável. No login OAuth pessoal o Gemini
78
82
  CLI usa o backend interno Code Assist, que essa variável não afeta.`,
79
83
 
@@ -178,7 +182,7 @@ export function setupSnippet(tui, cfg) {
178
182
  return (
179
183
  `TUIs suportados: ${Object.keys(snippets).join(', ')}\n\n` +
180
184
  `Uso: ttm setup <tui>\n` +
181
- `Padrão geral: aponte a base URL da ferramenta para ${P}/{target}[/{TICKET-123}]\n` +
185
+ `Padrão geral: aponte a base URL da ferramenta para ${P}/{target}[/{TICKET-123}] ou ${P}/{target}/-/{slug}\n` +
182
186
  `targets: ${Object.keys(cfg.targets).join(', ')}`
183
187
  )
184
188
  }
@@ -100,6 +100,20 @@ function tomlTopLevelKey(text, key) {
100
100
  return null
101
101
  }
102
102
 
103
+ function tomlSetTopLevelKey(text, key, value) {
104
+ const lines = text.split('\n')
105
+ let firstSection = lines.findIndex(line => /^\s*\[[^\]]+\]\s*$/.test(line))
106
+ if (firstSection === -1) firstSection = lines.length
107
+ for (let i = 0; i < firstSection; i++) {
108
+ if (new RegExp(`^\\s*${key}\\s*=`).test(lines[i])) {
109
+ lines[i] = `${key} = "${value}"`
110
+ return lines.join('\n')
111
+ }
112
+ }
113
+ lines.splice(firstSection, 0, `${key} = "${value}"`, '')
114
+ return lines.join('\n')
115
+ }
116
+
103
117
  function tomlSectionKey(text, section, key) {
104
118
  const sections = parseTomlSections(text)
105
119
  const s = sections.find(x => x.header === section)
@@ -274,14 +288,14 @@ export function discoverTui(tui, { cwd = process.cwd() } = {}) {
274
288
  tui,
275
289
  baseUrl: base ?? null,
276
290
  source: base ? 'config.toml' : 'default',
277
- canWriteBack: Boolean(section),
291
+ canWriteBack: true,
278
292
  writeTarget: p,
279
293
  repoWriteTarget: null,
280
294
  envVar,
281
295
  authEnv,
282
296
  protocol: 'openai',
283
297
  defaultUpstream: DEFAULT_UPSTREAM[tui],
284
- tomlSection: section,
298
+ tomlSection: section ?? 'model_providers.ttm',
285
299
  }
286
300
  }
287
301
 
@@ -369,6 +383,12 @@ export function writeTuiBaseUrl(tui, newUrl, { writeTarget, cwd = process.cwd()
369
383
  } catch {
370
384
  txt = `model_provider = "ttm"\n\n[${section}]\nname = "OpenAI via ttm"\nwire_api = "responses"\nenv_key = "OPENAI_API_KEY"\n`
371
385
  }
386
+ if (section === 'model_providers.ttm') {
387
+ txt = tomlSetTopLevelKey(txt, 'model_provider', 'ttm')
388
+ txt = tomlSetSectionKey(txt, section, 'name', 'OpenAI via ttm')
389
+ txt = tomlSetSectionKey(txt, section, 'wire_api', 'responses')
390
+ txt = tomlSetSectionKey(txt, section, 'env_key', 'OPENAI_API_KEY')
391
+ }
372
392
  txt = tomlSetSectionKey(txt, section, 'base_url', newUrl)
373
393
  fs.mkdirSync(path.dirname(writeTarget), { recursive: true })
374
394
  fs.writeFileSync(writeTarget, txt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-trace-manager",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Proxy local universal de rastreio de tokens de LLM para TUIs de código (Claude Code, Codex, OpenCode, Gemini CLI, Kilo...), com labels por tarefa, dashboard local e auto-link que preserva redirects prévios (z.ai/GLM/...).",