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.
@@ -0,0 +1,171 @@
1
+ import { listUsageEvents, listRollups } from './db.mjs'
2
+
3
+ function sinceToIso(since) {
4
+ if (!since || since === 'all') return null
5
+ if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since
6
+ const m = /^(\d+)([dh])$/.exec(since)
7
+ if (!m) return null
8
+ const ms = m[2] === 'd' ? 86400000 : 3600000
9
+ return new Date(Date.now() - parseInt(m[1], 10) * ms).toISOString()
10
+ }
11
+
12
+ export async function buildReport({ since = '30d', label = null } = {}) {
13
+ if (label) label = label.toUpperCase() // labels são armazenados em maiúsculas
14
+ const sinceIso = sinceToIso(since)
15
+ const rows = await listUsageEvents({ since: sinceIso, label })
16
+
17
+ // Histórico agregado (traces já expirados pela retenção) entra como
18
+ // pseudo-eventos diários — sem risco de dupla contagem: um rollup só
19
+ // existe para eventos que JÁ foram apagados.
20
+ const rollups = await listRollups({
21
+ sinceDay: sinceIso ? sinceIso.slice(0, 10) : null,
22
+ label: label ?? undefined,
23
+ })
24
+ for (const r of rollups) {
25
+ rows.push({
26
+ ts: r.day + 'T12:00:00.000Z',
27
+ label: r.label || null,
28
+ target: r.target,
29
+ model: r.model || null,
30
+ client: r.client || null,
31
+ input_tokens: r.input_tokens,
32
+ cache_create_tokens: r.cache_create_tokens,
33
+ cache_create_1h_tokens: r.cache_create_1h_tokens,
34
+ cache_read_tokens: r.cache_read_tokens,
35
+ output_tokens: r.output_tokens,
36
+ reasoning_tokens: r.reasoning_tokens,
37
+ cost_usd: r.cost_known ? r.cost_usd : null,
38
+ _rollup: true,
39
+ _count: r.events,
40
+ })
41
+ }
42
+
43
+ let totalCost = 0
44
+ let totalTokens = 0
45
+ let tokensIn = 0 // "lidos": tudo que o modelo leu (entrada + cache lido + cache escrito)
46
+ let tokensOut = 0 // "escritos": tudo que o modelo gerou (inclui reasoning)
47
+ let costKnown = true
48
+ const byLabel = new Map()
49
+ const byTarget = new Map()
50
+ const byModel = new Map()
51
+ const byDay = new Map()
52
+ const byClient = new Map()
53
+ const tasks = new Map() // visão rica por tarefa — a razão do projeto existir
54
+
55
+ const rowTokens = r =>
56
+ (r.input_tokens || 0) + (r.cache_create_tokens || 0) + (r.cache_read_tokens || 0) + (r.output_tokens || 0)
57
+
58
+ const bump = (map, key, row) => {
59
+ const cur = map.get(key) || { cost: 0, tokens: 0, events: 0 }
60
+ cur.cost += row.cost_usd || 0
61
+ cur.tokens += rowTokens(row)
62
+ cur.events += row._count ?? 1
63
+ map.set(key, cur)
64
+ }
65
+
66
+ for (const r of rows) {
67
+ totalCost += r.cost_usd || 0
68
+ totalTokens += rowTokens(r)
69
+ tokensIn += (r.input_tokens || 0) + (r.cache_create_tokens || 0) + (r.cache_read_tokens || 0)
70
+ tokensOut += r.output_tokens || 0
71
+ if (r.cost_usd === null || r.cost_usd === undefined) costKnown = false
72
+
73
+ const labelKey = r.label || 'Sem label'
74
+ bump(byLabel, labelKey, r)
75
+ bump(byTarget, r.target, r)
76
+ bump(byModel, r.model || '(desconhecido)', r)
77
+ bump(byClient, (r.client || '(desconhecido)').split('/')[0].split(' ')[0], r)
78
+ bump(byDay, (r.ts || '').slice(0, 10), r)
79
+
80
+ let t = tasks.get(labelKey)
81
+ if (!t) {
82
+ t = {
83
+ key: labelKey,
84
+ tokens: 0,
85
+ input: 0,
86
+ output: 0,
87
+ cacheRead: 0,
88
+ cacheCreate: 0,
89
+ reasoning: 0,
90
+ cost: 0,
91
+ events: 0,
92
+ firstTs: null,
93
+ lastTs: null,
94
+ byDay: new Map(),
95
+ }
96
+ tasks.set(labelKey, t)
97
+ }
98
+ t.tokens += rowTokens(r)
99
+ t.input += r.input_tokens || 0
100
+ t.output += r.output_tokens || 0
101
+ t.cacheRead += r.cache_read_tokens || 0
102
+ t.cacheCreate += r.cache_create_tokens || 0
103
+ t.reasoning += r.reasoning_tokens || 0
104
+ t.cost += r.cost_usd || 0
105
+ t.events += r._count ?? 1
106
+ if (r.ts) {
107
+ if (!t.firstTs || r.ts < t.firstTs) t.firstTs = r.ts
108
+ if (!t.lastTs || r.ts > t.lastTs) t.lastTs = r.ts
109
+ const day = r.ts.slice(0, 10)
110
+ t.byDay.set(day, (t.byDay.get(day) || 0) + rowTokens(r))
111
+ }
112
+ }
113
+
114
+ const toSorted = m =>
115
+ [...m.entries()]
116
+ .sort((a, b) => b[1].cost - a[1].cost || b[1].tokens - a[1].tokens)
117
+ .map(([key, v]) => ({ key, ...v }))
118
+
119
+ return {
120
+ generatedAt: new Date().toISOString(),
121
+ since,
122
+ label,
123
+ totalCost,
124
+ totalTokens,
125
+ tokensIn,
126
+ tokensOut,
127
+ costKnown,
128
+ eventCount: rows.reduce((n, r) => n + (r._count ?? 1), 0),
129
+ taskCount: tasks.size,
130
+ // tarefas ordenadas por TOKENS (a métrica que vai pro campo do Jira), não por custo
131
+ tasks: [...tasks.values()]
132
+ .sort((a, b) => b.tokens - a.tokens)
133
+ .map(t => ({
134
+ ...t,
135
+ tokensIn: t.input + t.cacheRead + t.cacheCreate,
136
+ tokensOut: t.output,
137
+ byDay: [...t.byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, tokens]) => ({ day, tokens })),
138
+ })),
139
+ byLabel: toSorted(byLabel),
140
+ byTarget: toSorted(byTarget),
141
+ byModel: toSorted(byModel),
142
+ byClient: toSorted(byClient),
143
+ byDay: [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, v]) => ({ day, ...v })),
144
+ // traces individuais (auditoria) — rows já vêm ordenadas por ts DESC
145
+ recentCalls: rows.filter(r => !r._rollup).slice(0, 50).map(r => {
146
+ let rates = null
147
+ try {
148
+ rates = r.rates ? JSON.parse(r.rates) : null
149
+ } catch {
150
+ /* evento antigo */
151
+ }
152
+ return {
153
+ ts: r.ts,
154
+ label: r.label,
155
+ target: r.target,
156
+ model: r.model,
157
+ providerId: r.provider_id ?? null,
158
+ input: r.input_tokens || 0,
159
+ cacheCreate: r.cache_create_tokens || 0,
160
+ cacheCreate1h: r.cache_create_1h_tokens || 0,
161
+ cacheRead: r.cache_read_tokens || 0,
162
+ output: r.output_tokens || 0,
163
+ cost: r.cost_usd,
164
+ rates,
165
+ durationMs: r.duration_ms,
166
+ client: r.client,
167
+ status: r.status,
168
+ }
169
+ }),
170
+ }
171
+ }
@@ -0,0 +1,201 @@
1
+ // Snippets de configuração por TUI, confirmados nas docs oficiais de cada
2
+ // ferramenta (pesquisa 2026-07-06). Fonte da lista: agentes suportados no
3
+ // skills.sh + principais TUIs de mercado.
4
+
5
+ export function setupSnippet(tui, cfg) {
6
+ const P = `http://127.0.0.1:${cfg.port}`
7
+ const snippets = {
8
+ 'claude-code': `Claude Code — caminho primário: auto-link (descobre seu redirect e se insere no meio):
9
+
10
+ ttm link claude-code [--label PROJ-123]
11
+ # depois reinicie o Claude Code (ele relê ANTHROPIC_BASE_URL no boot)
12
+
13
+ O que ele faz: lê sua base URL atual (env ANTHROPIC_BASE_URL,
14
+ ~/.claude/settings.json ou <repo>/.claude/settings.json), vira upstream
15
+ dela, e reescreve a config do Claude Code pra apontar pro ttm.
16
+ - Se você JÁ redireciona pra um compatível-anthropic (z.ai/GLM/...), o
17
+ ttm PRESERVA o redirect — continua batendo lá, com contagem de tokens.
18
+ - Se não tem redirect, o ttm usa o vendor real (api.anthropic.com).
19
+ - --repo escreve em <repo>/.claude/settings.json (útil p/ multi-ticket).
20
+ - --label seta o label padrão (atribuição de tarefa).
21
+ Para desfazer: ttm unlink claude-code
22
+
23
+ Manual (sem auto-link): setar ANTHROPIC_BASE_URL=${P}/anthropic
24
+ (ou ${P}/anthropic/PROJ-123 p/ ticket embutido). Apontar pra host !=
25
+ api.anthropic.com desativa o Remote Control do Claude Code (v2.1.196+).
26
+ Auth continua passando intacta.`,
27
+
28
+ codex: `Codex (OpenAI) — caminho primário: auto-link:
29
+
30
+ ttm link codex [--label TICKET]
31
+ # reinicie o Codex depois
32
+
33
+ Lê ~/.codex/config.toml (model_providers.<ativo>.base_url), vira upstream,
34
+ reescreve o base_url pra apontar pro ttm. Preserva redirect prévio.
35
+
36
+ Manual: ~/.codex/config.toml:
37
+ model_provider = "ttm"
38
+ [model_providers.ttm]
39
+ name = "OpenAI via ttm"
40
+ base_url = "${P}/openai/v1"
41
+ wire_api = "responses"
42
+ env_key = "OPENAI_API_KEY"
43
+ (com ticket: base_url = "${P}/openai/PROJ-123/v1")
44
+ Prefira model_providers a openai_base_url/OPENAI_BASE_URL — há releases do
45
+ Codex com bug que ignora essas formas (issue #16719).`,
46
+
47
+ opencode: `OpenCode (sst) — caminho primário: auto-link:
48
+
49
+ ttm link opencode [--label TICKET]
50
+ # reinicie o OpenCode depois
51
+
52
+ Lê opencode.json (projeto) ou ~/.config/opencode/opencode.json, acha o
53
+ provider com baseURL, vira upstream e reescreve. Preserva redirect prévio.
54
+
55
+ Manual: opencode.json (projeto ou global):
56
+ {
57
+ "provider": {
58
+ "anthropic": { "options": { "baseURL": "${P}/anthropic" } },
59
+ "openai": { "options": { "baseURL": "${P}/openai/v1" } }
60
+ }
61
+ }
62
+ (com ticket: "baseURL": "${P}/anthropic/PROJ-123")`,
63
+
64
+ 'gemini-cli': `Gemini CLI (Google) — caminho primário: auto-link:
65
+
66
+ ttm link gemini-cli
67
+ # depois: export GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:${cfg.port}/gemini
68
+ # (gemini-cli é só-env: o ttm imprime o export; rode no shell e reinicie)
69
+
70
+ Lê GOOGLE_GEMINI_BASE_URL (se setada), vira upstream, e instrui o export.
71
+ Preserva redirect prévio.
72
+
73
+ Manual: env vars (http:// permitido para 127.0.0.1):
74
+ export GEMINI_API_KEY=sua-chave
75
+ export GOOGLE_GEMINI_BASE_URL="${P}/gemini"
76
+ (com ticket: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/PROJ-123")
77
+ Limitação: só o modo API key é rastreável. No login OAuth pessoal o Gemini
78
+ CLI usa o backend interno Code Assist, que essa variável não afeta.`,
79
+
80
+ kilo: `Kilo Code — kilo.jsonc (ou na extensão VS Code: Settings > Providers >
81
+ Custom Provider > Base URL, com Provider API = OpenAI Compatible):
82
+
83
+ {
84
+ "provider": {
85
+ "openai-compatible": {
86
+ "options": {
87
+ "apiKey": "{env:OPENAI_API_KEY}",
88
+ "baseURL": "${P}/openai/v1"
89
+ }
90
+ }
91
+ }
92
+ }`,
93
+
94
+ goose: `Goose (Block) — env vars (ou ~/.config/goose/config.yaml):
95
+
96
+ # provider OpenAI-compatible:
97
+ export OPENAI_HOST="${P}/openai"
98
+ # provider Anthropic:
99
+ export ANTHROPIC_HOST="${P}/anthropic"`,
100
+
101
+ crush: `Crush (Charm) — crush.json (ou ~/.config/crush/crush.json):
102
+
103
+ {
104
+ "providers": {
105
+ "ttm-anthropic": {
106
+ "type": "anthropic",
107
+ "base_url": "${P}/anthropic/v1",
108
+ "api_key": "$ANTHROPIC_API_KEY"
109
+ },
110
+ "ttm-openai": {
111
+ "type": "openai-compat",
112
+ "base_url": "${P}/openai/v1",
113
+ "api_key": "$OPENAI_API_KEY"
114
+ }
115
+ }
116
+ }`,
117
+
118
+ aider: `Aider — env var ou flag:
119
+
120
+ export OPENAI_API_BASE="${P}/openai/v1"
121
+ # ou: aider --openai-api-base ${P}/openai/v1
122
+ # ou em .aider.conf.yml: openai-api-base: ${P}/openai/v1`,
123
+
124
+ cline: `Cline — só via interface (sem arquivo de config para isso):
125
+ Settings (engrenagem) > API Provider = "OpenAI Compatible"
126
+ > Base URL: ${P}/openai/v1`,
127
+
128
+ 'roo-code': `Roo Code — só via interface:
129
+ Settings > Providers > API Provider = "OpenAI Compatible"
130
+ > Base URL: ${P}/openai/v1`,
131
+
132
+ droid: `Droid (Factory AI) — ~/.factory/settings.json:
133
+
134
+ {
135
+ "customModels": [{
136
+ "model": "claude-opus-4-8",
137
+ "provider": "anthropic",
138
+ "baseUrl": "${P}/anthropic",
139
+ "apiKey": "\${ANTHROPIC_API_KEY}"
140
+ }]
141
+ }`,
142
+
143
+ 'copilot-cli': `GitHub Copilot CLI — modo BYOK via env vars:
144
+
145
+ export COPILOT_PROVIDER_BASE_URL="${P}/openai/v1"
146
+ export COPILOT_PROVIDER_TYPE=openai
147
+ export COPILOT_MODEL=gpt-5.5
148
+ export COPILOT_PROVIDER_API_KEY=sua-chave
149
+
150
+ Obs: o modo padrão (backend proprietário do Copilot) não é rastreável.`,
151
+
152
+ amp: `Amp (Sourcegraph) — NÃO suportado: o Amp roteia modelos pelo backend
153
+ proprietário (ampcode.com) e não documenta como redirecionar as chamadas
154
+ de modelo. HTTP_PROXY existe mas é só transporte, não muda o destino.`,
155
+
156
+ cursor: `Cursor CLI — NÃO suportado oficialmente: o cursor-agent fala com o
157
+ backend proprietário da Cursor e não há override de base URL documentado
158
+ para o CLI (o campo BYOK da IDE não se aplica ao agente headless).`,
159
+
160
+ kiro: `Kiro CLI (AWS) — NÃO suportado: não há mecanismo documentado de
161
+ override de base URL ou proxy para o backend de modelos.`,
162
+ }
163
+
164
+ const aliases = {
165
+ claude: 'claude-code',
166
+ claudecode: 'claude-code',
167
+ gemini: 'gemini-cli',
168
+ 'kilo-code': 'kilo',
169
+ kilocode: 'kilo',
170
+ roo: 'roo-code',
171
+ roocode: 'roo-code',
172
+ copilot: 'copilot-cli',
173
+ factory: 'droid',
174
+ }
175
+
176
+ const key = aliases[tui] ?? tui
177
+ if (!key || !snippets[key]) {
178
+ return (
179
+ `TUIs suportados: ${Object.keys(snippets).join(', ')}\n\n` +
180
+ `Uso: ttm setup <tui>\n` +
181
+ `Padrão geral: aponte a base URL da ferramenta para ${P}/{target}[/{TICKET-123}]\n` +
182
+ `targets: ${Object.keys(cfg.targets).join(', ')}`
183
+ )
184
+ }
185
+ return snippets[key]
186
+ }
187
+
188
+ export const SUPPORTED_TUIS = [
189
+ 'claude-code',
190
+ 'codex',
191
+ 'opencode',
192
+ 'gemini-cli',
193
+ 'kilo',
194
+ 'goose',
195
+ 'crush',
196
+ 'aider',
197
+ 'cline',
198
+ 'roo-code',
199
+ 'droid',
200
+ 'copilot-cli',
201
+ ]
@@ -0,0 +1,155 @@
1
+ import { listUsageEvents } from './db.mjs'
2
+
3
+ // Sessionização agnóstica ao harness: o proxy não vê "sessão" no protocolo,
4
+ // então agrupa por (cliente, label) com corte por inatividade — mesma técnica
5
+ // de sessionização de analytics. Heurística honesta: duas janelas do MESMO
6
+ // TUI na MESMA tarefa ao mesmo tempo viram uma sessão só (documentado).
7
+ const DEFAULT_GAP_MIN = 30
8
+
9
+ const clientShortOf = c => (c || '(desconhecido)').split('/')[0].split(' ')[0]
10
+
11
+ function sinceToIso(since) {
12
+ if (!since || since === 'all') return null
13
+ if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since
14
+ const m = /^(\d+)([dh])$/.exec(since)
15
+ if (!m) return null
16
+ const ms = m[2] === 'd' ? 86400000 : 3600000
17
+ return new Date(Date.now() - parseInt(m[1], 10) * ms).toISOString()
18
+ }
19
+
20
+ function shapeCall(r) {
21
+ let rates = null
22
+ try {
23
+ rates = r.rates ? JSON.parse(r.rates) : null
24
+ } catch {
25
+ /* evento antigo */
26
+ }
27
+ return {
28
+ id: r.id,
29
+ ts: r.ts,
30
+ label: r.label,
31
+ target: r.target,
32
+ model: r.model,
33
+ providerId: r.provider_id ?? null,
34
+ input: r.input_tokens || 0,
35
+ cacheCreate: r.cache_create_tokens || 0,
36
+ cacheCreate1h: r.cache_create_1h_tokens || 0,
37
+ cacheRead: r.cache_read_tokens || 0,
38
+ output: r.output_tokens || 0,
39
+ reasoning: r.reasoning_tokens || 0,
40
+ cost: r.cost_usd,
41
+ rates,
42
+ durationMs: r.duration_ms,
43
+ client: r.client,
44
+ status: r.status,
45
+ repo: r.repo ?? null,
46
+ branch: r.branch ?? null,
47
+ payload: Boolean(r.payload),
48
+ }
49
+ }
50
+
51
+ export async function buildTraces({
52
+ since = '7d',
53
+ label = null,
54
+ target = null,
55
+ model = null,
56
+ client = null,
57
+ gapMin = DEFAULT_GAP_MIN,
58
+ maxSessions = 200,
59
+ } = {}) {
60
+ let rows = await listUsageEvents({ since: sinceToIso(since), label: label ? label.toUpperCase() : label })
61
+ if (target) rows = rows.filter(r => r.target === target)
62
+ if (model) rows = rows.filter(r => r.model === model)
63
+ // casa exatamente com o MESMO valor exibido no filtro (clientShort),
64
+ // inclusive '(desconhecido)' para cliente nulo
65
+ if (client) rows = rows.filter(r => clientShortOf(r.client) === client)
66
+
67
+ // opções de filtro (sobre o conjunto já recortado por período/label)
68
+ const opt = key => [...new Set(rows.map(r => r[key]).filter(Boolean))].sort()
69
+ const clientShort = clientShortOf
70
+ const filterOptions = {
71
+ targets: opt('target'),
72
+ models: opt('model'),
73
+ clients: [...new Set(rows.map(r => clientShort(r.client)))].sort(),
74
+ }
75
+
76
+ // sessioniza: ordena por (cliente, label, ts) e corta por gap de inatividade
77
+ const sorted = rows
78
+ .slice()
79
+ .sort((a, b) =>
80
+ (a.client || '').localeCompare(b.client || '') ||
81
+ (a.label || '').localeCompare(b.label || '') ||
82
+ a.ts.localeCompare(b.ts),
83
+ )
84
+ const gapMs = gapMin * 60000
85
+ const sessions = []
86
+ let cur = null
87
+ for (const r of sorted) {
88
+ const sameGroup =
89
+ cur &&
90
+ (cur.clientFull || '') === (r.client || '') &&
91
+ (cur.label || '') === (r.label || '') &&
92
+ Date.parse(r.ts) - Date.parse(cur.end) <= gapMs
93
+ if (!sameGroup) {
94
+ cur = {
95
+ label: r.label,
96
+ client: clientShort(r.client),
97
+ clientFull: r.client,
98
+ start: r.ts,
99
+ end: r.ts,
100
+ calls: [],
101
+ tokensIn: 0,
102
+ tokensOut: 0,
103
+ cost: 0,
104
+ costKnown: true,
105
+ targets: new Set(),
106
+ models: new Set(),
107
+ repo: null,
108
+ branch: null,
109
+ }
110
+ sessions.push(cur)
111
+ }
112
+ cur.end = r.ts
113
+ cur.calls.push(shapeCall(r))
114
+ cur.tokensIn += (r.input_tokens || 0) + (r.cache_create_tokens || 0) + (r.cache_read_tokens || 0)
115
+ cur.tokensOut += r.output_tokens || 0
116
+ cur.cost += r.cost_usd || 0
117
+ if (r.cost_usd === null || r.cost_usd === undefined) cur.costKnown = false
118
+ if (r.target) cur.targets.add(r.target)
119
+ if (r.model) cur.models.add(r.model)
120
+ if (r.repo && !cur.repo) cur.repo = r.repo
121
+ if (r.branch && !cur.branch) cur.branch = r.branch
122
+ }
123
+
124
+ sessions.sort((a, b) => b.end.localeCompare(a.end))
125
+ const truncated = sessions.length > maxSessions
126
+ return {
127
+ generatedAt: new Date().toISOString(),
128
+ since,
129
+ filters: { label, target, model, client },
130
+ filterOptions,
131
+ gapMin,
132
+ sessionCount: sessions.length,
133
+ callCount: rows.length,
134
+ callsShown: sessions.slice(0, maxSessions).reduce((n, s) => n + s.calls.length, 0),
135
+ truncated,
136
+ sessions: sessions.slice(0, maxSessions).map((s, i) => ({
137
+ id: `s${i + 1}`,
138
+ label: s.label,
139
+ client: s.client,
140
+ clientFull: s.clientFull,
141
+ start: s.start,
142
+ end: s.end,
143
+ durationMs: Date.parse(s.end) - Date.parse(s.start),
144
+ targets: [...s.targets],
145
+ models: [...s.models],
146
+ repo: s.repo,
147
+ branch: s.branch,
148
+ tokensIn: s.tokensIn,
149
+ tokensOut: s.tokensOut,
150
+ cost: s.cost,
151
+ costKnown: s.costKnown,
152
+ calls: s.calls,
153
+ })),
154
+ }
155
+ }