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/lib/daemon.mjs ADDED
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env node
2
+ // Daemon do token-trace-manager: proxy transparente + API local + dashboard.
3
+ //
4
+ // Regras de ouro:
5
+ // - Escuta APENAS em 127.0.0.1 (nunca exposto na rede).
6
+ // - Repassa headers de autenticação intactos; nunca guarda nem loga chaves.
7
+ // - Conteúdo de prompts/respostas só é gravado (em payloads/<id>.json) se
8
+ // storePayloads estiver ligado (default: ligado) — chaves de API nunca,
9
+ // em nenhuma hipótese, vão parar lá.
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
15
+
16
+ import fs from 'node:fs'
17
+ import http from 'node:http'
18
+ import https from 'node:https'
19
+ import path from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { StringDecoder } from 'node:string_decoder'
22
+ import { readConfig, pidFilePath, writeConfig, KNOWN_PROTOCOLS, validateConfig } from './config.mjs'
23
+ import { insertUsageEvent, getKv, setKv, purgeExpired, deleteData, relabelUnlabeled, payloadPath } from './db.mjs'
24
+ import { buildTraces } from './traces-data.mjs'
25
+ import { createExtractor, SseParser, maybeInjectIncludeUsage, isUsageOnlyChunk } from './adapters.mjs'
26
+ import { exportUsageEvent } from './otel-export.mjs'
27
+ import { costBreakdown } from './pricing.mjs'
28
+ import { buildReport } from './report-data.mjs'
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
31
+ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
32
+
33
+ // Label = qualquer identificador de tarefa: ticket real (PROJ-123) OU slug
34
+ // 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
+ const LABEL_RE = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/
40
+ const LABEL_MAX_LEN = 120
41
+ const HOP_BY_HOP = new Set([
42
+ 'connection',
43
+ 'keep-alive',
44
+ 'proxy-authenticate',
45
+ 'proxy-authorization',
46
+ 'te',
47
+ 'trailer',
48
+ 'transfer-encoding',
49
+ 'upgrade',
50
+ 'host',
51
+ ])
52
+ const MAX_JSON_CAPTURE = 32 * 1024 * 1024
53
+
54
+ const startedAt = Date.now()
55
+
56
+ function sendJson(res, status, obj) {
57
+ const body = JSON.stringify(obj)
58
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) })
59
+ res.end(body)
60
+ }
61
+
62
+ function collectBody(req) {
63
+ return new Promise((resolve, reject) => {
64
+ const chunks = []
65
+ req.on('data', c => chunks.push(c))
66
+ req.on('end', () => resolve(Buffer.concat(chunks)))
67
+ req.on('error', reject)
68
+ })
69
+ }
70
+
71
+ async function handleLocal(req, res, pathname, searchParams) {
72
+ if (pathname === '/healthz') {
73
+ return sendJson(res, 200, {
74
+ ok: true,
75
+ version: VERSION,
76
+ uptime_s: Math.round((Date.now() - startedAt) / 1000),
77
+ pid: process.pid,
78
+ })
79
+ }
80
+
81
+ if (pathname === '/api/config') {
82
+ const cfg = readConfig()
83
+ return sendJson(res, 200, {
84
+ proxyUrl: cfg.proxyUrl,
85
+ port: cfg.port,
86
+ injectUsage: cfg.injectUsage,
87
+ targets: cfg.targets,
88
+ defaultLabel: await getKv('default_label'),
89
+ })
90
+ }
91
+
92
+ if (pathname === '/api/label') {
93
+ if (req.method === 'POST') {
94
+ const body = await collectBody(req)
95
+ let payload
96
+ try {
97
+ payload = JSON.parse(body.toString('utf8'))
98
+ } catch {
99
+ return sendJson(res, 400, {
100
+ error: 'JSON inválido; esperado {"label": "PROJ-123"|null, "context"?: {repo, branch}, "retroactiveMinutes"?: n}',
101
+ })
102
+ }
103
+ 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)` })
106
+ }
107
+ const normalized = label ? label.toUpperCase() : null
108
+ await setKv('default_label', normalized)
109
+
110
+ // Contexto do ambiente do agente (repo/branch, detectados pelo MCP) —
111
+ // carimbado em todo evento novo até ser trocado/limpo.
112
+ if ('context' in payload) {
113
+ const ctx = payload.context
114
+ await setKv(
115
+ 'default_context',
116
+ ctx && (ctx.repo || ctx.branch)
117
+ ? JSON.stringify({ repo: ctx.repo ?? null, branch: ctx.branch ?? null })
118
+ : null,
119
+ )
120
+ }
121
+
122
+ // Retroativo: mensagens sem label da janela recebem a tarefa nova
123
+ // (caso "trabalhei antes de criar o card").
124
+ let relabeled = 0
125
+ if (normalized && payload.retroactiveMinutes > 0) {
126
+ const sinceIso = new Date(Date.now() - Math.min(payload.retroactiveMinutes, 24 * 60) * 60000).toISOString()
127
+ relabeled = (await relabelUnlabeled({ label: normalized, sinceIso })).relabeled
128
+ }
129
+
130
+ const ctxRaw = await getKv('default_context')
131
+ return sendJson(res, 200, {
132
+ defaultLabel: normalized,
133
+ context: ctxRaw ? JSON.parse(ctxRaw) : null,
134
+ relabeled,
135
+ })
136
+ }
137
+ const ctxRaw = await getKv('default_context')
138
+ return sendJson(res, 200, {
139
+ defaultLabel: await getKv('default_label'),
140
+ context: ctxRaw ? JSON.parse(ctxRaw) : null,
141
+ })
142
+ }
143
+
144
+ // labels são SEMPRE armazenados em maiúsculas — filtros idem, senão
145
+ // ?label=proj-123 devolve silenciosamente um relatório vazio
146
+ const labelParam = p => {
147
+ const v = p.get('label')
148
+ return v ? v.toUpperCase() : v
149
+ }
150
+
151
+ if (pathname === '/api/summary') {
152
+ const report = await buildReport({
153
+ since: searchParams.get('since'),
154
+ label: labelParam(searchParams),
155
+ })
156
+ return sendJson(res, 200, report)
157
+ }
158
+
159
+ if (pathname === '/api/traces') {
160
+ const traces = await buildTraces({
161
+ since: searchParams.get('since') || '24h',
162
+ label: labelParam(searchParams),
163
+ target: searchParams.get('target'),
164
+ model: searchParams.get('model'),
165
+ client: searchParams.get('client'),
166
+ })
167
+ return sendJson(res, 200, traces)
168
+ }
169
+
170
+ if (pathname === '/api/payload') {
171
+ const id = parseInt(searchParams.get('id') || '', 10)
172
+ if (!Number.isInteger(id) || id <= 0) return sendJson(res, 400, { error: 'id inválido' })
173
+ try {
174
+ const raw = fs.readFileSync(payloadPath(id), 'utf8')
175
+ res.writeHead(200, { 'content-type': 'application/json' })
176
+ return res.end(raw)
177
+ } catch {
178
+ return sendJson(res, 404, { error: 'sem payload para este evento (anterior ao armazenamento, desligado na config, ou expirado)' })
179
+ }
180
+ }
181
+
182
+ if (pathname === '/api/settings' && req.method === 'POST') {
183
+ const body = await collectBody(req)
184
+ let payload
185
+ try {
186
+ payload = JSON.parse(body.toString('utf8'))
187
+ } catch {
188
+ return sendJson(res, 400, { error: 'JSON inválido; esperado {"storePayloads": true|false}' })
189
+ }
190
+ if (typeof payload.storePayloads !== 'boolean') {
191
+ return sendJson(res, 400, { error: 'storePayloads deve ser booleano' })
192
+ }
193
+ writeConfig({ storePayloads: payload.storePayloads })
194
+ return sendJson(res, 200, { storePayloads: payload.storePayloads })
195
+ }
196
+
197
+ if (pathname === '/api/retention' && req.method === 'POST') {
198
+ const body = await collectBody(req)
199
+ let hours
200
+ try {
201
+ hours = JSON.parse(body.toString('utf8')).hours
202
+ } catch {
203
+ return sendJson(res, 400, { error: 'JSON inválido; esperado {"hours": 24} (0 = nunca apagar)' })
204
+ }
205
+ if (typeof hours !== 'number' || hours < 0 || hours > 24 * 365) {
206
+ return sendJson(res, 400, { error: 'hours deve ser um número entre 0 (nunca apagar) e 8760' })
207
+ }
208
+ writeConfig({ retentionHours: hours })
209
+ return sendJson(res, 200, { retentionHours: hours })
210
+ }
211
+
212
+ if (pathname === '/api/purge' && req.method === 'POST') {
213
+ const body = await collectBody(req)
214
+ let payload
215
+ try {
216
+ payload = JSON.parse(body.toString('utf8'))
217
+ } catch {
218
+ return sendJson(res, 400, { error: 'JSON inválido; esperado {"mode": "expired"|"events"|"everything", "label"?} ' })
219
+ }
220
+ if (payload.mode === 'expired') {
221
+ const cfg2 = readConfig()
222
+ if (!cfg2.retentionHours) return sendJson(res, 200, { purged: 0, note: 'retenção desligada (0)' })
223
+ const cutoff = new Date(Date.now() - cfg2.retentionHours * 3600000).toISOString()
224
+ return sendJson(res, 200, await purgeExpired(cutoff))
225
+ }
226
+ if (payload.mode === 'events' || payload.mode === 'everything') {
227
+ let label = payload.label ?? null
228
+ if (label !== null) {
229
+ 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)` })
231
+ }
232
+ label = label.toUpperCase()
233
+ }
234
+ return sendJson(res, 200, await deleteData({ mode: payload.mode, label }))
235
+ }
236
+ return sendJson(res, 400, { error: 'mode deve ser expired | events | everything' })
237
+ }
238
+
239
+ if (pathname === '/api/events') {
240
+ const { listUsageEvents } = await import('./db.mjs')
241
+ const rows = await listUsageEvents({
242
+ since: searchParams.get('since'),
243
+ label: labelParam(searchParams),
244
+ limit: parseInt(searchParams.get('limit') || '100', 10),
245
+ })
246
+ return sendJson(res, 200, { events: rows })
247
+ }
248
+
249
+ // replacement como FUNÇÃO: um "$'" vindo de User-Agent/model no JSON seria
250
+ // interpretado como sequência especial de String.replace e corromperia o HTML
251
+ const servePage = (templateName, data) => {
252
+ const template = fs.readFileSync(path.join(__dirname, '..', 'templates', templateName), 'utf8')
253
+ const json = JSON.stringify(data).replace(/</g, '\\u003c')
254
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
255
+ res.end(template.replace('"__REPORT_DATA__"', () => json))
256
+ }
257
+
258
+ if (pathname === '/dashboard' || pathname === '/dashboard/') {
259
+ const report = await buildReport({ since: searchParams.get('since'), label: searchParams.get('label') })
260
+ return servePage('dashboard.html', report)
261
+ }
262
+
263
+ if (pathname === '/traces' || pathname === '/traces/') {
264
+ const traces = await buildTraces({
265
+ since: searchParams.get('since') || '24h',
266
+ label: labelParam(searchParams),
267
+ target: searchParams.get('target'),
268
+ model: searchParams.get('model'),
269
+ client: searchParams.get('client'),
270
+ })
271
+ return servePage('traces.html', traces)
272
+ }
273
+
274
+ if (pathname === '/config' || pathname === '/config/') {
275
+ const cfg2 = readConfig()
276
+ return servePage('config.html', {
277
+ port: cfg2.port,
278
+ retentionHours: cfg2.retentionHours,
279
+ storePayloads: cfg2.storePayloads,
280
+ injectUsage: cfg2.injectUsage,
281
+ otelOn: Boolean(cfg2.otel?.endpoint),
282
+ targets: cfg2.targets,
283
+ defaultLabel: await getKv('default_label'),
284
+ version: VERSION,
285
+ })
286
+ }
287
+
288
+ if (pathname === '/') {
289
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
290
+ return res.end(
291
+ `token-trace-manager v${VERSION}\n\n` +
292
+ `Dashboard : /dashboard\n` +
293
+ `Health : /healthz\n` +
294
+ `API : /api/summary /api/events /api/label /api/config\n\n` +
295
+ `Uso como proxy: aponte a base URL do seu TUI para\n` +
296
+ ` http://127.0.0.1:${readConfig().port}/{target}[/{TICKET-123}]\n` +
297
+ `targets: ${Object.keys(readConfig().targets).join(', ')}\n`,
298
+ )
299
+ }
300
+
301
+ sendJson(res, 404, { error: 'rota não encontrada' })
302
+ }
303
+
304
+ async function proxyRequest(req, res, cfg, targetName, label, upstreamPath) {
305
+ const target = cfg.targets[targetName]
306
+
307
+ // Protocolo inválido (typo em target custom): falha cedo, sem fabricar
308
+ // evento de 0 tokens. readConfig marca _invalid; aqui é a defesa final.
309
+ if (target._invalid) {
310
+ return sendJson(res, 400, {
311
+ error: `target "${targetName}" tem protocolo inválido "${target.protocol}" (válidos: ${KNOWN_PROTOCOLS.join(', ')})`,
312
+ })
313
+ }
314
+
315
+ const started = Date.now()
316
+ const requestBody = await collectBody(req)
317
+
318
+ let outboundBody = requestBody
319
+ let weInjectedUsage = false
320
+ if (cfg.injectUsage) {
321
+ const injected = maybeInjectIncludeUsage(target.protocol, upstreamPath, requestBody)
322
+ if (injected) {
323
+ outboundBody = injected
324
+ weInjectedUsage = true
325
+ }
326
+ }
327
+
328
+ // Resolve o upstream: env-following (targetUrlEnv) tem precedência sobre
329
+ // targetUrl estática — permite "ttm segue meu redirect" sem reconfig.
330
+ let rawUrl
331
+ if (target.targetUrlEnv) {
332
+ rawUrl = process.env[target.targetUrlEnv]
333
+ if (!rawUrl) {
334
+ return sendJson(res, 502, { error: `target "${targetName}" exige env ${target.targetUrlEnv} (não setada no ambiente do daemon)` })
335
+ }
336
+ } else {
337
+ rawUrl = target.targetUrl
338
+ }
339
+ let upstreamUrl
340
+ try {
341
+ upstreamUrl = new URL(rawUrl)
342
+ } catch {
343
+ return sendJson(res, 502, { error: `targetUrl inválida para "${targetName}": ${rawUrl}` })
344
+ }
345
+ const basePath = upstreamUrl.pathname === '/' ? '' : upstreamUrl.pathname.replace(/\/$/, '')
346
+ const transport = upstreamUrl.protocol === 'http:' ? http : https
347
+
348
+ const headers = {}
349
+ for (const [k, v] of Object.entries(req.headers)) {
350
+ if (!HOP_BY_HOP.has(k)) headers[k] = v
351
+ }
352
+ headers.host = upstreamUrl.host
353
+ headers['accept-encoding'] = 'identity' // sem compressão pra podermos ler usage
354
+ if (outboundBody.length) headers['content-length'] = String(outboundBody.length)
355
+ else delete headers['content-length']
356
+
357
+ const finalLabel = label || (await getKv('default_label'))
358
+ let context = null
359
+ try {
360
+ const raw = await getKv('default_context')
361
+ if (raw) context = JSON.parse(raw)
362
+ } catch {
363
+ /* contexto corrompido — segue sem */
364
+ }
365
+ const extractor = createExtractor(target.protocol, { urlPath: upstreamPath })
366
+
367
+ const upstreamReq = transport.request(
368
+ {
369
+ hostname: upstreamUrl.hostname,
370
+ port: upstreamUrl.port || (upstreamUrl.protocol === 'http:' ? 80 : 443),
371
+ path: basePath + upstreamPath,
372
+ method: req.method,
373
+ headers,
374
+ timeout: 0,
375
+ },
376
+ upstreamRes => {
377
+ const isSse = /text\/event-stream/i.test(upstreamRes.headers['content-type'] || '')
378
+ const sse = isSse ? new SseParser((event, json) => extractor.onSse(event, json)) : null
379
+ const decoder = isSse ? new StringDecoder('utf8') : null
380
+ let jsonCapture = isSse ? null : []
381
+ let jsonCaptured = 0
382
+
383
+ // Quando NÓS injetamos include_usage, o chunk terminal de usage não
384
+ // pode vazar pro cliente — filtramos por evento SSE (UTF-8-safe).
385
+ const mustFilter = isSse && weInjectedUsage
386
+ let filterBuf = ''
387
+
388
+ const responseHeaders = {}
389
+ for (const [k, v] of Object.entries(upstreamRes.headers)) {
390
+ if (!HOP_BY_HOP.has(k)) responseHeaders[k] = v
391
+ }
392
+ if (mustFilter) delete responseHeaders['content-length'] // corpo pode encolher
393
+ res.writeHead(upstreamRes.statusCode, responseHeaders)
394
+
395
+ upstreamRes.on('data', chunk => {
396
+ let text = null
397
+ if (decoder) text = decoder.write(chunk)
398
+
399
+ if (mustFilter) {
400
+ filterBuf += text
401
+ let m
402
+ while ((m = /\r?\n\r?\n/.exec(filterBuf))) {
403
+ const rawEvent = filterBuf.slice(0, m.index)
404
+ const sep = m[0]
405
+ filterBuf = filterBuf.slice(m.index + sep.length)
406
+ if (!isUsageOnlyChunk(rawEvent)) res.write(Buffer.from(rawEvent + sep, 'utf8'))
407
+ }
408
+ } else {
409
+ res.write(chunk)
410
+ }
411
+
412
+ try {
413
+ if (sse) {
414
+ sse.feed(text)
415
+ } else if (jsonCapture && jsonCaptured < MAX_JSON_CAPTURE) {
416
+ jsonCapture.push(chunk)
417
+ jsonCaptured += chunk.length
418
+ }
419
+ } catch {
420
+ /* extração nunca pode derrubar o passthrough */
421
+ }
422
+ })
423
+
424
+ upstreamRes.on('end', () => {
425
+ if (mustFilter) {
426
+ const tail = filterBuf + (decoder ? decoder.end() : '')
427
+ if (tail && !isUsageOnlyChunk(tail)) res.write(Buffer.from(tail, 'utf8'))
428
+ }
429
+ res.end()
430
+ try {
431
+ if (jsonCapture) {
432
+ try {
433
+ extractor.onJsonBody(JSON.parse(Buffer.concat(jsonCapture).toString('utf8')))
434
+ } catch {
435
+ /* corpo não-JSON (ex: binário) — sem usage */
436
+ }
437
+ }
438
+ const u = extractor.result()
439
+ // sawUsage = campos reconhecidos presentes; usageUnknown = houve
440
+ // objeto usage mas nenhum campo bateu. Gravamos flagged (tokens 0)
441
+ // em vez de fantasma "completo" de 0 — fica visível que falhou.
442
+ if (u.sawUsage || u.usageUnknown) {
443
+ const { cost, rates } = costBreakdown(u.model, u)
444
+ const evRecord = {
445
+ label: finalLabel,
446
+ target: targetName,
447
+ protocol: target.protocol,
448
+ model: u.model,
449
+ providerId: u.providerId,
450
+ inputTokens: u.inputTokens,
451
+ cacheCreateTokens: u.cacheCreateTokens,
452
+ cacheCreate1hTokens: u.cacheCreate1hTokens,
453
+ cacheReadTokens: u.cacheReadTokens,
454
+ outputTokens: u.outputTokens,
455
+ reasoningTokens: u.reasoningTokens,
456
+ costUsd: cost,
457
+ rates,
458
+ usageFields: u.usageFields,
459
+ usageUnknown: u.usageUnknown,
460
+ repo: context?.repo ?? null,
461
+ branch: context?.branch ?? null,
462
+ status: upstreamRes.statusCode,
463
+ durationMs: Date.now() - started,
464
+ client: (req.headers['user-agent'] || '').slice(0, 120) || null,
465
+ }
466
+ const storePayloads = cfg.storePayloads
467
+ insertUsageEvent({ ...evRecord, payload: storePayloads })
468
+ .then(id => {
469
+ if (!storePayloads || !id) return
470
+ // Conteúdo da chamada para inspeção (segue a retenção; SEM
471
+ // 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
+ fs.writeFileSync(
483
+ payloadPath(id),
484
+ JSON.stringify({
485
+ event_id: id,
486
+ ts: evRecord.ts ?? new Date().toISOString(),
487
+ label: evRecord.label,
488
+ model: evRecord.model,
489
+ request,
490
+ response: { text: u.text || null },
491
+ }),
492
+ )
493
+ })
494
+ .catch(err => process.stderr.write(`ttm: falha ao gravar evento/payload: ${err.message}\n`))
495
+ exportUsageEvent({ ...evRecord, ts: new Date().toISOString() }, cfg.otel)
496
+ }
497
+ } catch (err) {
498
+ process.stderr.write(`ttm: falha na extração de usage: ${err.message}\n`)
499
+ }
500
+ })
501
+
502
+ upstreamRes.on('error', () => {
503
+ try {
504
+ res.destroy()
505
+ } catch {
506
+ /* já encerrada */
507
+ }
508
+ })
509
+ },
510
+ )
511
+
512
+ upstreamReq.on('error', err => {
513
+ if (!res.headersSent) {
514
+ sendJson(res, 502, { error: `falha ao contatar ${target.targetUrl}: ${err.message}` })
515
+ } else {
516
+ res.destroy()
517
+ }
518
+ })
519
+
520
+ req.on('close', () => upstreamReq.destroy())
521
+ upstreamReq.end(outboundBody)
522
+ }
523
+
524
+ export function createDaemonServer() {
525
+ const server = http.createServer(async (req, res) => {
526
+ try {
527
+ const cfg = readConfig()
528
+ const parsed = new URL(req.url, 'http://localhost')
529
+ const segments = parsed.pathname.split('/').filter(Boolean)
530
+ const first = segments[0]
531
+
532
+ // Object.hasOwn (não `in`): /constructor etc. herdados do protótipo
533
+ // não podem virar target de proxy
534
+ if (!first || !Object.hasOwn(cfg.targets, first)) {
535
+ return await handleLocal(req, res, parsed.pathname, parsed.searchParams)
536
+ }
537
+
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)
546
+ } catch (err) {
547
+ if (!res.headersSent) sendJson(res, 500, { error: err.message })
548
+ else res.destroy()
549
+ }
550
+ })
551
+ server.requestTimeout = 0 // respostas de LLM podem levar muitos minutos
552
+ server.headersTimeout = 60_000
553
+ return server
554
+ }
555
+
556
+ // Purge da retenção: ao expirar, agrega no histórico diário e apaga o trace.
557
+ async function runRetentionPurge() {
558
+ try {
559
+ const cfg = readConfig()
560
+ if (!cfg.retentionHours) return // 0 = nunca apagar
561
+ const cutoff = new Date(Date.now() - cfg.retentionHours * 3600000).toISOString()
562
+ const { purged } = await purgeExpired(cutoff)
563
+ if (purged) process.stdout.write(`ttm: retenção — ${purged} trace(s) agregados no histórico e apagados\n`)
564
+ } catch (err) {
565
+ process.stderr.write(`ttm: purge de retenção falhou: ${err.message}\n`)
566
+ }
567
+ }
568
+
569
+ export function startDaemon() {
570
+ const cfg = readConfig()
571
+ const { warnings } = validateConfig(cfg)
572
+ for (const w of warnings) process.stderr.write(`ttm: ${w}\n`)
573
+ const server = createDaemonServer()
574
+ return new Promise((resolve, reject) => {
575
+ server.once('error', reject)
576
+ server.listen(cfg.port, '127.0.0.1', () => {
577
+ fs.writeFileSync(pidFilePath(), String(process.pid))
578
+ void runRetentionPurge()
579
+ const t = setInterval(runRetentionPurge, 3600000)
580
+ t.unref?.()
581
+ resolve({ server, port: cfg.port })
582
+ })
583
+ })
584
+ }
585
+
586
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
587
+ if (invokedDirectly) {
588
+ startDaemon()
589
+ .then(({ port }) => {
590
+ process.stdout.write(`ttm daemon ouvindo em http://127.0.0.1:${port} (pid ${process.pid})\n`)
591
+ // Shutdown GRACIOSO: para de aceitar conexões novas mas deixa streams
592
+ // em voo terminarem (um restart de upgrade não pode cortar respostas
593
+ // de outros TUIs no meio). Força a saída após 15s se algo pendurar.
594
+ let shuttingDown = false
595
+ const shutdown = () => {
596
+ if (shuttingDown) return
597
+ shuttingDown = true
598
+ try {
599
+ fs.unlinkSync(pidFilePath())
600
+ } catch {
601
+ /* já removido */
602
+ }
603
+ server.close(() => process.exit(0))
604
+ const force = setTimeout(() => process.exit(0), 15_000)
605
+ force.unref?.()
606
+ }
607
+ process.on('SIGTERM', shutdown)
608
+ process.on('SIGINT', shutdown)
609
+ })
610
+ .catch(err => {
611
+ process.stderr.write(`ttm daemon: ${err.message}\n`)
612
+ process.exit(1)
613
+ })
614
+ }