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,429 @@
1
+ // Discovery e rewrite da config de cada TUI, para o `ttm link <tui>`.
2
+ //
3
+ // Princípio: o ttm descobre a base URL que o TUI JÁ usa (env var ou arquivo
4
+ // de config), vira upstream dela, e reescreve a config do TUI pra apontar pro
5
+ // ttm — um comando, sem editar config.json à mão. Assim, quem já redireciona
6
+ // o Claude Code pra um compatível-anthropic da z.ai/GLM continua batendo lá,
7
+ // só que com contagem de tokens no meio.
8
+ //
9
+ // O protocolo é propriedade do TUI (dialeto que ele EMITE), não do upstream:
10
+ // mesmo apontando pra z.ai, o Claude Code continua emitindo Messages →
11
+ // protocol 'anthropic'. Isso resolve a incerteza de protocolo do redirect.
12
+
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+
17
+ export const AUTOLINKABLE_TUIS = ['claude-code', 'codex', 'opencode', 'gemini-cli']
18
+
19
+ // Default upstream quando o TUI não tem redirect prévio (aponta pro vendor
20
+ // real daquele TUI). Usado só pra registrar o target — não é batido em teste.
21
+ const DEFAULT_UPSTREAM = {
22
+ 'claude-code': 'https://api.anthropic.com',
23
+ codex: 'https://api.openai.com',
24
+ opencode: 'https://api.anthropic.com', // opencode fala Anthropic por default
25
+ 'gemini-cli': 'https://generativelanguage.googleapis.com',
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // helpers de JSON settings (claude-code, opencode)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function readJsonFile(p) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ function writeJsonAtomic(p, obj) {
41
+ fs.mkdirSync(path.dirname(p), { recursive: true })
42
+ const tmp = p + '.tmp'
43
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2))
44
+ try {
45
+ fs.renameSync(tmp, p)
46
+ } catch {
47
+ // Windows: rename sobre existente pode falhar (EPERM) — fallback
48
+ try { fs.unlinkSync(p) } catch {}
49
+ fs.renameSync(tmp, p)
50
+ }
51
+ }
52
+
53
+ function settingsEnvRead(filePath, key) {
54
+ const j = readJsonFile(filePath)
55
+ return j?.env?.[key] ?? null
56
+ }
57
+
58
+ function settingsEnvSet(filePath, key, value) {
59
+ const j = readJsonFile(filePath) ?? {}
60
+ if (!j.env || typeof j.env !== 'object') j.env = {}
61
+ j.env[key] = value
62
+ writeJsonAtomic(filePath, j)
63
+ }
64
+
65
+ function settingsEnvRemove(filePath, key) {
66
+ const j = readJsonFile(filePath)
67
+ if (!j?.env) return
68
+ delete j.env[key]
69
+ writeJsonAtomic(filePath, j)
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // TOML mínimo (codex ~/.codex/config.toml) — só o necessário p/ base_url
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function parseTomlSections(text) {
77
+ // Retorna [{ header, body, start }]; header '' = top-level.
78
+ const lines = text.split('\n')
79
+ const sections = []
80
+ let cur = { header: '', body: [], start: 0 }
81
+ sections.push(cur)
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i]
84
+ const m = /^\s*\[([^\]]+)\]\s*$/.exec(line)
85
+ if (m) {
86
+ cur = { header: m[1], body: [], start: i }
87
+ sections.push(cur)
88
+ } else {
89
+ cur.body.push(line)
90
+ }
91
+ }
92
+ return sections
93
+ }
94
+
95
+ function tomlTopLevelKey(text, key) {
96
+ for (const line of text.split('\n')) {
97
+ const m = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"\\s*$`).exec(line)
98
+ if (m) return m[1]
99
+ }
100
+ return null
101
+ }
102
+
103
+ function tomlSectionKey(text, section, key) {
104
+ const sections = parseTomlSections(text)
105
+ const s = sections.find(x => x.header === section)
106
+ if (!s) return null
107
+ for (const line of s.body) {
108
+ const m = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"\\s*$`).exec(line)
109
+ if (m) return m[1]
110
+ }
111
+ return null
112
+ }
113
+
114
+ function tomlSetSectionKey(text, section, key, value) {
115
+ const sections = parseTomlSections(text)
116
+ const s = sections.find(x => x.header === section)
117
+ if (!s) {
118
+ // cria a seção no fim
119
+ const prefix = text && !text.endsWith('\n') ? '\n' : ''
120
+ return text + `${prefix}[${section}]\n${key} = "${value}"\n`
121
+ }
122
+ // procura a linha do key dentro do body e substitui
123
+ const lines = text.split('\n')
124
+ let inSection = false
125
+ let sectionStart = -1
126
+ let keyLineIdx = -1
127
+ for (let i = 0; i < lines.length; i++) {
128
+ const m = /^\s*\[([^\]]+)\]\s*$/.exec(lines[i])
129
+ if (m) {
130
+ inSection = m[1] === section
131
+ if (inSection && sectionStart === -1) sectionStart = i
132
+ continue
133
+ }
134
+ if (inSection) {
135
+ const km = new RegExp(`^(\\s*)${key}\\s*=\\s*".*"\\s*$`).exec(lines[i])
136
+ if (km) {
137
+ keyLineIdx = i
138
+ break
139
+ }
140
+ }
141
+ }
142
+ if (keyLineIdx >= 0) {
143
+ lines[keyLineIdx] = `${key} = "${value}"`
144
+ return lines.join('\n')
145
+ }
146
+ // insere logo após o header da seção
147
+ if (sectionStart >= 0) {
148
+ lines.splice(sectionStart + 1, 0, `${key} = "${value}"`)
149
+ return lines.join('\n')
150
+ }
151
+ return text
152
+ }
153
+
154
+ function tomlRemoveSectionKey(text, section, key) {
155
+ const lines = text.split('\n')
156
+ let inSection = false
157
+ const out = []
158
+ for (const line of lines) {
159
+ const m = /^\s*\[([^\]]+)\]\s*$/.exec(line)
160
+ if (m) {
161
+ inSection = m[1] === section
162
+ out.push(line)
163
+ continue
164
+ }
165
+ if (inSection && new RegExp(`^\\s*${key}\\s*=\\s*".*"\\s*$`).exec(line)) continue
166
+ out.push(line)
167
+ }
168
+ return out.join('\n')
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Discovery por TUI
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export function discoverTui(tui, { cwd = process.cwd() } = {}) {
176
+ if (!AUTOLINKABLE_TUIS.includes(tui)) {
177
+ return { supported: false, tui }
178
+ }
179
+
180
+ if (tui === 'claude-code') {
181
+ const envVar = 'ANTHROPIC_BASE_URL'
182
+ const authEnv = process.env.ANTHROPIC_AUTH_TOKEN ? 'ANTHROPIC_AUTH_TOKEN' : 'ANTHROPIC_API_KEY'
183
+ const repoPath = path.join(cwd, '.claude', 'settings.json')
184
+ const globalPath = path.join(os.homedir(), '.claude', 'settings.json')
185
+ if (process.env[envVar]) {
186
+ return {
187
+ tui,
188
+ baseUrl: process.env[envVar],
189
+ source: 'env',
190
+ canWriteBack: false,
191
+ writeTarget: null,
192
+ repoWriteTarget: repoPath,
193
+ envVar,
194
+ authEnv,
195
+ protocol: 'anthropic',
196
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
197
+ }
198
+ }
199
+ const repoVal = settingsEnvRead(repoPath, envVar)
200
+ if (repoVal) {
201
+ return {
202
+ tui,
203
+ baseUrl: repoVal,
204
+ source: 'settings-repo',
205
+ canWriteBack: true,
206
+ writeTarget: repoPath,
207
+ repoWriteTarget: repoPath,
208
+ envVar,
209
+ authEnv,
210
+ protocol: 'anthropic',
211
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
212
+ }
213
+ }
214
+ const globalVal = settingsEnvRead(globalPath, envVar)
215
+ if (globalVal) {
216
+ return {
217
+ tui,
218
+ baseUrl: globalVal,
219
+ source: 'settings-global',
220
+ canWriteBack: true,
221
+ writeTarget: globalPath,
222
+ repoWriteTarget: repoPath,
223
+ envVar,
224
+ authEnv,
225
+ protocol: 'anthropic',
226
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
227
+ }
228
+ }
229
+ return {
230
+ tui,
231
+ baseUrl: null,
232
+ source: 'default',
233
+ canWriteBack: true,
234
+ writeTarget: globalPath,
235
+ repoWriteTarget: repoPath,
236
+ envVar,
237
+ authEnv,
238
+ protocol: 'anthropic',
239
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
240
+ }
241
+ }
242
+
243
+ if (tui === 'codex') {
244
+ const p = path.join(os.homedir(), '.codex', 'config.toml')
245
+ const envVar = 'OPENAI_BASE_URL'
246
+ const authEnv = 'OPENAI_API_KEY'
247
+ // 1) env var (Codex honors OPENAI_BASE_URL em alguns setups)
248
+ if (process.env[envVar]) {
249
+ return {
250
+ tui, baseUrl: process.env[envVar], source: 'env', canWriteBack: false,
251
+ writeTarget: null, repoWriteTarget: null, envVar, authEnv,
252
+ protocol: 'openai', defaultUpstream: DEFAULT_UPSTREAM[tui],
253
+ }
254
+ }
255
+ let txt = ''
256
+ try {
257
+ txt = fs.readFileSync(p, 'utf8')
258
+ } catch {
259
+ return {
260
+ tui, baseUrl: null, source: 'default', canWriteBack: true,
261
+ writeTarget: p, repoWriteTarget: null, envVar, authEnv,
262
+ protocol: 'openai', defaultUpstream: DEFAULT_UPSTREAM[tui],
263
+ }
264
+ }
265
+ const active = tomlTopLevelKey(txt, 'model_provider')
266
+ let section = active ? `model_providers.${active}` : null
267
+ if (!section) {
268
+ // primeira seção model_providers.* que aparecer
269
+ const m = /\[model_providers\.([^.\]]+)\]/.exec(txt)
270
+ section = m ? `model_providers.${m[1]}` : null
271
+ }
272
+ const base = section ? tomlSectionKey(txt, section, 'base_url') : null
273
+ return {
274
+ tui,
275
+ baseUrl: base ?? null,
276
+ source: base ? 'config.toml' : 'default',
277
+ canWriteBack: Boolean(section),
278
+ writeTarget: p,
279
+ repoWriteTarget: null,
280
+ envVar,
281
+ authEnv,
282
+ protocol: 'openai',
283
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
284
+ tomlSection: section,
285
+ }
286
+ }
287
+
288
+ if (tui === 'opencode') {
289
+ const envVar = null
290
+ const authEnv = null
291
+ const repoPath = path.join(cwd, 'opencode.json')
292
+ const globalPath = path.join(os.homedir(), '.config', 'opencode', 'opencode.json')
293
+ const tryFile = (p) => {
294
+ const j = readJsonFile(p)
295
+ if (!j?.provider) return null
296
+ for (const [name, prov] of Object.entries(j.provider)) {
297
+ const base = prov?.options?.baseURL
298
+ if (base) return { name, base, file: p }
299
+ }
300
+ return null
301
+ }
302
+ const found = tryFile(repoPath) || tryFile(globalPath)
303
+ if (found) {
304
+ return {
305
+ tui,
306
+ baseUrl: found.base,
307
+ source: 'opencode.json',
308
+ canWriteBack: true,
309
+ writeTarget: found.file,
310
+ repoWriteTarget: repoPath,
311
+ envVar,
312
+ authEnv,
313
+ protocol: found.name === 'openai' ? 'openai' : 'anthropic',
314
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
315
+ opencodeProvider: found.name,
316
+ }
317
+ }
318
+ return {
319
+ tui,
320
+ baseUrl: null,
321
+ source: 'default',
322
+ canWriteBack: true,
323
+ writeTarget: repoPath,
324
+ repoWriteTarget: repoPath,
325
+ envVar,
326
+ authEnv,
327
+ protocol: 'anthropic',
328
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
329
+ opencodeProvider: 'anthropic',
330
+ }
331
+ }
332
+
333
+ if (tui === 'gemini-cli') {
334
+ const envVar = 'GOOGLE_GEMINI_BASE_URL'
335
+ const authEnv = 'GEMINI_API_KEY'
336
+ const val = process.env[envVar]
337
+ return {
338
+ tui,
339
+ baseUrl: val ?? null,
340
+ source: val ? 'env' : 'default',
341
+ canWriteBack: false, // gemini-cli é só-env: ttm não pode persistir
342
+ writeTarget: null,
343
+ repoWriteTarget: null,
344
+ envVar,
345
+ authEnv,
346
+ protocol: 'gemini',
347
+ defaultUpstream: DEFAULT_UPSTREAM[tui],
348
+ }
349
+ }
350
+
351
+ return { supported: false, tui }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Rewrite (escrever nova base URL no TUI) — só quando canWriteBack
356
+ // ---------------------------------------------------------------------------
357
+
358
+ export function writeTuiBaseUrl(tui, newUrl, { writeTarget, cwd = process.cwd() } = {}) {
359
+ if (tui === 'claude-code') {
360
+ settingsEnvSet(writeTarget, 'ANTHROPIC_BASE_URL', newUrl)
361
+ return { ok: true, writeTarget }
362
+ }
363
+ if (tui === 'codex') {
364
+ const d = discoverTui(tui, { cwd })
365
+ const section = d.tomlSection || 'model_providers.ttm'
366
+ let txt = ''
367
+ try {
368
+ txt = fs.readFileSync(writeTarget, 'utf8')
369
+ } catch {
370
+ txt = `model_provider = "ttm"\n\n[${section}]\nname = "OpenAI via ttm"\nwire_api = "responses"\nenv_key = "OPENAI_API_KEY"\n`
371
+ }
372
+ txt = tomlSetSectionKey(txt, section, 'base_url', newUrl)
373
+ fs.mkdirSync(path.dirname(writeTarget), { recursive: true })
374
+ fs.writeFileSync(writeTarget, txt)
375
+ return { ok: true, writeTarget }
376
+ }
377
+ if (tui === 'opencode') {
378
+ const d = discoverTui(tui, { cwd })
379
+ const prov = d.opencodeProvider || 'anthropic'
380
+ const j = readJsonFile(writeTarget) ?? {}
381
+ if (!j.provider || typeof j.provider !== 'object') j.provider = {}
382
+ if (!j.provider[prov]) j.provider[prov] = { options: {} }
383
+ if (!j.provider[prov].options) j.provider[prov].options = {}
384
+ j.provider[prov].options.baseURL = newUrl
385
+ writeJsonAtomic(writeTarget, j)
386
+ return { ok: true, writeTarget, provider: prov }
387
+ }
388
+ return { ok: false, reason: 'TUI só-env (use o export impresso)' }
389
+ }
390
+
391
+ export function restoreTuiBaseUrl(tui, { original, source, writeTarget, cwd = process.cwd() } = {}) {
392
+ if (source === 'env' || source === 'default' && !writeTarget) {
393
+ return { ok: false, reason: 'fonte só-env/default — não dá pra restaurar via arquivo' }
394
+ }
395
+ if (tui === 'claude-code') {
396
+ if (original === null) settingsEnvRemove(writeTarget, 'ANTHROPIC_BASE_URL')
397
+ else settingsEnvSet(writeTarget, 'ANTHROPIC_BASE_URL', original)
398
+ return { ok: true, writeTarget }
399
+ }
400
+ if (tui === 'codex') {
401
+ const d = discoverTui(tui, { cwd })
402
+ const section = d.tomlSection
403
+ if (!section) return { ok: false, reason: 'seção do provider não encontrada' }
404
+ let txt = ''
405
+ try {
406
+ txt = fs.readFileSync(writeTarget, 'utf8')
407
+ } catch {
408
+ return { ok: false, reason: 'config.toml sumiu' }
409
+ }
410
+ if (original === null) txt = tomlRemoveSectionKey(txt, section, 'base_url')
411
+ else txt = tomlSetSectionKey(txt, section, 'base_url', original)
412
+ fs.writeFileSync(writeTarget, txt)
413
+ return { ok: true, writeTarget }
414
+ }
415
+ if (tui === 'opencode') {
416
+ const j = readJsonFile(writeTarget) ?? {}
417
+ const prov = j.provider ? Object.keys(j.provider)[0] : null
418
+ if (!prov) return { ok: false, reason: 'sem provider' }
419
+ if (original === null) {
420
+ delete j.provider[prov].options?.baseURL
421
+ } else {
422
+ if (!j.provider[prov].options) j.provider[prov].options = {}
423
+ j.provider[prov].options.baseURL = original
424
+ }
425
+ writeJsonAtomic(writeTarget, j)
426
+ return { ok: true, writeTarget, provider: prov }
427
+ }
428
+ return { ok: false, reason: 'TUI não-escritável' }
429
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "token-trace-manager",
3
+ "version": "0.7.0",
4
+ "private": false,
5
+ "type": "module",
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/...).",
7
+ "license": "MIT",
8
+ "author": {
9
+ "name": "Mpaape",
10
+ "url": "https://github.com/Mpaape"
11
+ },
12
+ "homepage": "https://github.com/cloud104/token-trace-manager",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/cloud104/token-trace-manager.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/cloud104/token-trace-manager/issues"
19
+ },
20
+ "keywords": [
21
+ "tokens",
22
+ "llm",
23
+ "proxy",
24
+ "cost",
25
+ "dashboard",
26
+ "jira",
27
+ "mcp",
28
+ "claude-code",
29
+ "codex",
30
+ "opencode",
31
+ "gemini-cli",
32
+ "kilo",
33
+ "observability"
34
+ ],
35
+ "bin": {
36
+ "ttm": "bin/ttm.mjs"
37
+ },
38
+ "files": [
39
+ "bin",
40
+ "lib",
41
+ "commands",
42
+ "templates",
43
+ ".claude-plugin",
44
+ ".mcp.json",
45
+ "CHANGELOG.md",
46
+ "LICENSE",
47
+ "README.md"
48
+ ],
49
+ "scripts": {
50
+ "test": "node test/e2e.mjs",
51
+ "prepublishOnly": "npm test"
52
+ },
53
+ "engines": {
54
+ "node": ">=22.5"
55
+ }
56
+ }
@@ -0,0 +1,208 @@
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ttm — config</title>
7
+ <style>
8
+ :root {
9
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #f2f1ec; --ink-2: #c3c2b7; --muted: #898781;
10
+ --grid: #2c2c2a; --border: rgba(255,255,255,0.09);
11
+ --accent: #3987e5; --accent-dim: #86b6ef; --accent-wash: rgba(57,135,229,0.13);
12
+ --warning: #fab219; --good: #0ca30c; --err: #e66767;
13
+ --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
14
+ --sans: system-ui, -apple-system, "Segoe UI", sans-serif;
15
+ }
16
+ * { box-sizing: border-box; }
17
+ html { color-scheme: dark; }
18
+ body {
19
+ margin: 0;
20
+ background:
21
+ repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
22
+ var(--page);
23
+ color: var(--ink); font-family: var(--sans); font-size: 13px; line-height: 1.5;
24
+ }
25
+ .wrap { max-width: 860px; margin: 0 auto; padding: 0 28px 56px; }
26
+ .topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
27
+ .brand .mark { font-family: var(--mono); font-weight: 700; font-size: 15px; }
28
+ .brand .mark::before { content: "▮▯▮ "; color: var(--accent); letter-spacing: -0.12em; margin-right: 2px; }
29
+ .nav { display: flex; gap: 4px; }
30
+ .nav a { color: var(--muted); text-decoration: none; font-size: 12px; padding: 5px 12px; border-radius: 6px; }
31
+ .nav a:hover { color: var(--ink); background: rgba(255,255,255,0.05); }
32
+ .nav a.active { color: var(--accent-dim); background: var(--accent-wash); font-weight: 600; }
33
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; }
34
+ .card h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.13em; color: var(--muted); margin: 0 0 12px; font-weight: 600; }
35
+ p { color: var(--ink-2); margin: 6px 0; }
36
+ .hint { color: var(--muted); font-size: 12px; }
37
+ input[type="number"] {
38
+ background: var(--page); border: 1px solid var(--border); border-radius: 7px;
39
+ color: var(--ink); padding: 7px 10px; font-size: 13px; width: 110px; font-family: var(--mono);
40
+ }
41
+ input:focus { outline: none; border-color: var(--accent); }
42
+ button {
43
+ background: var(--accent-wash); border: 1px solid rgba(57,135,229,0.5); border-radius: 7px;
44
+ color: var(--accent-dim); padding: 7px 16px; font-size: 12.5px; cursor: pointer; font-family: var(--sans);
45
+ }
46
+ button:hover { background: rgba(57,135,229,0.22); color: var(--ink); }
47
+ button.danger { background: rgba(230,103,103,0.1); border-color: rgba(230,103,103,0.45); color: var(--err); }
48
+ button.danger:hover { background: rgba(230,103,103,0.2); }
49
+ button.ghost { background: none; border-color: var(--border); color: var(--ink-2); }
50
+ .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0; }
51
+ .fb { font-size: 12px; font-family: var(--mono); }
52
+ .fb.ok { color: var(--good); }
53
+ .fb.err { color: var(--err); }
54
+ dl { display: grid; grid-template-columns: 190px 1fr; gap: 6px 14px; margin: 0; font-size: 12.5px; }
55
+ dt { color: var(--muted); }
56
+ dd { margin: 0; font-family: var(--mono); color: var(--ink-2); word-break: break-all; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="wrap">
61
+ <div class="topbar">
62
+ <div class="brand"><span class="mark">ttm</span></div>
63
+ <nav class="nav">
64
+ <a href="/dashboard">Visão geral</a>
65
+ <a href="/traces">Traces</a>
66
+ <a href="/config" class="active">Config</a>
67
+ </nav>
68
+ </div>
69
+
70
+ <div class="card">
71
+ <h2>Retenção dos traces</h2>
72
+ <p>Traces detalhados (mensagem a mensagem) são mantidos pelo período abaixo. Ao expirar, cada trace é <strong>somado no histórico diário por tarefa</strong> e só então apagado — o total pro seu ticket nunca se perde, só o detalhe.</p>
73
+ <div class="row">
74
+ <label for="ret">manter por</label>
75
+ <input type="number" id="ret" min="0" max="8760" step="1" />
76
+ <span class="hint">horas (0 = nunca apagar)</span>
77
+ <button id="save-ret">salvar</button>
78
+ <span class="fb" id="ret-fb"></span>
79
+ </div>
80
+ <div class="row">
81
+ <button class="ghost" id="purge-now">rodar limpeza da retenção agora</button>
82
+ <span class="fb" id="purge-fb"></span>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="card">
87
+ <h2>Conteúdo das chamadas</h2>
88
+ <p>Com isto ligado, request/response de cada chamada ficam em JSONs locais (<code>payloads/</code>) e a página Traces mostra o que houve dentro da sessão, mensagem a mensagem. Seguem a <strong>mesma retenção</strong> dos traces. Chaves de API nunca são gravadas.</p>
89
+ <div class="row">
90
+ <label><input type="checkbox" id="store-payloads" /> armazenar conteúdo das chamadas</label>
91
+ <span class="fb" id="sp-fb"></span>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="card">
96
+ <h2>Apagar dados</h2>
97
+ <p class="hint">Ações imediatas e irreversíveis — o proxy continua funcionando normalmente depois.</p>
98
+ <div class="row">
99
+ <button class="danger" id="del-events">apagar todos os traces (mantém o histórico por tarefa)</button>
100
+ <button class="danger" id="del-everything">apagar TUDO (traces + histórico)</button>
101
+ <span class="fb" id="del-fb"></span>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="card">
106
+ <h2>Resumo da configuração</h2>
107
+ <dl id="summary"></dl>
108
+ <p class="hint" style="margin-top:12px">Arquivo: <code>~/.claude/plugins/data/token-trace-manager/config.json</code> — porta, targets, pricing.override.json e export OTel/Langfuse são editados lá (ver README).</p>
109
+ </div>
110
+ </div>
111
+
112
+ <script id="report-data" type="application/json">"__REPORT_DATA__"</script>
113
+ <script>
114
+ const cfg = JSON.parse(document.getElementById('report-data').textContent)
115
+
116
+ document.getElementById('ret').value = cfg.retentionHours
117
+ const sp = document.getElementById('store-payloads')
118
+ sp.checked = cfg.storePayloads
119
+ sp.addEventListener('change', async () => {
120
+ try {
121
+ const r = await post('/api/settings', { storePayloads: sp.checked })
122
+ fb('sp-fb', r.storePayloads ? 'ligado' : 'desligado — novos payloads não serão gravados', true)
123
+ } catch (e) {
124
+ sp.checked = !sp.checked
125
+ fb('sp-fb', e.message, false)
126
+ }
127
+ })
128
+
129
+ const dl = document.getElementById('summary')
130
+ const rows = [
131
+ ['versão', 'v' + cfg.version],
132
+ ['porta', String(cfg.port) + ' (só 127.0.0.1)'],
133
+ ['retenção de traces', cfg.retentionHours === 0 ? 'nunca apagar' : cfg.retentionHours + 'h'],
134
+ ['label padrão', cfg.defaultLabel ?? '(nenhum)'],
135
+ ['conteúdo das chamadas', cfg.storePayloads ? 'armazenado (payloads/, segue a retenção)' : 'desligado'],
136
+ ['injeção include_usage', cfg.injectUsage ? 'ligada' : 'desligada'],
137
+ ['export OTel/Langfuse', cfg.otelOn ? 'ligado' : 'desligado (nenhum byte sai da máquina)'],
138
+ ['targets', Object.entries(cfg.targets).map(([k, t]) => `/${k} → ${t.targetUrl}`).join('\n')],
139
+ ]
140
+ for (const [k, v] of rows) {
141
+ const dt = document.createElement('dt')
142
+ dt.textContent = k
143
+ const dd = document.createElement('dd')
144
+ dd.textContent = v
145
+ dd.style.whiteSpace = 'pre-line'
146
+ dl.append(dt, dd)
147
+ }
148
+
149
+ function fb(id, msg, ok) {
150
+ const el = document.getElementById(id)
151
+ el.textContent = msg
152
+ el.className = 'fb ' + (ok ? 'ok' : 'err')
153
+ setTimeout(() => (el.textContent = ''), 4000)
154
+ }
155
+
156
+ async function post(path, body) {
157
+ const res = await fetch(path, {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify(body),
161
+ })
162
+ const json = await res.json()
163
+ if (!res.ok) throw new Error(json.error || 'HTTP ' + res.status)
164
+ return json
165
+ }
166
+
167
+ document.getElementById('save-ret').addEventListener('click', async () => {
168
+ const hours = Number(document.getElementById('ret').value)
169
+ try {
170
+ const r = await post('/api/retention', { hours })
171
+ fb('ret-fb', r.retentionHours === 0 ? 'salvo: nunca apagar' : `salvo: ${r.retentionHours}h`, true)
172
+ } catch (e) {
173
+ fb('ret-fb', e.message, false)
174
+ }
175
+ })
176
+
177
+ document.getElementById('purge-now').addEventListener('click', async () => {
178
+ try {
179
+ const r = await post('/api/purge', { mode: 'expired' })
180
+ fb('purge-fb', r.note ?? `${r.purged} trace(s) agregados e apagados`, true)
181
+ } catch (e) {
182
+ fb('purge-fb', e.message, false)
183
+ }
184
+ })
185
+
186
+ document.getElementById('del-events').addEventListener('click', async () => {
187
+ if (!confirm('Apagar TODOS os traces detalhados? O histórico agregado por tarefa é mantido.')) return
188
+ try {
189
+ const r = await post('/api/purge', { mode: 'events' })
190
+ fb('del-fb', `${r.deleted} registro(s) apagados`, true)
191
+ } catch (e) {
192
+ fb('del-fb', e.message, false)
193
+ }
194
+ })
195
+
196
+ document.getElementById('del-everything').addEventListener('click', async () => {
197
+ if (!confirm('Apagar TUDO — traces E histórico por tarefa? Não tem volta.')) return
198
+ if (!confirm('Confirmação final: apagar todo o banco de uso?')) return
199
+ try {
200
+ const r = await post('/api/purge', { mode: 'everything' })
201
+ fb('del-fb', `${r.deleted} registro(s) apagados`, true)
202
+ } catch (e) {
203
+ fb('del-fb', e.message, false)
204
+ }
205
+ })
206
+ </script>
207
+ </body>
208
+ </html>