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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/ttm.mjs +343 -0
- package/commands/config.md +13 -0
- package/commands/dashboard.md +7 -0
- package/commands/link.md +12 -0
- package/commands/setup.md +19 -0
- package/commands/status.md +13 -0
- package/lib/adapters.mjs +428 -0
- package/lib/audit.mjs +238 -0
- package/lib/config.mjs +135 -0
- package/lib/daemon-client.mjs +139 -0
- package/lib/daemon.mjs +614 -0
- package/lib/db.mjs +444 -0
- package/lib/link.mjs +152 -0
- package/lib/mcp-server.mjs +275 -0
- package/lib/otel-export.mjs +122 -0
- package/lib/pricing.mjs +137 -0
- package/lib/report-data.mjs +171 -0
- package/lib/setup-snippets.mjs +201 -0
- package/lib/traces-data.mjs +155 -0
- package/lib/tui-config.mjs +429 -0
- package/package.json +56 -0
- package/templates/config.html +208 -0
- package/templates/dashboard.html +629 -0
- package/templates/traces.html +407 -0
package/lib/db.mjs
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export function payloadsDir() {
|
|
6
|
+
const dir = path.join(dataDir(), 'payloads')
|
|
7
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
8
|
+
return dir
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function payloadPath(id) {
|
|
12
|
+
return path.join(payloadsDir(), `${id}.json`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function unlinkPayloads(ids) {
|
|
16
|
+
for (const id of ids) {
|
|
17
|
+
try {
|
|
18
|
+
fs.unlinkSync(payloadPath(id))
|
|
19
|
+
} catch {
|
|
20
|
+
/* sem payload */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function dataDir() {
|
|
26
|
+
const dir =
|
|
27
|
+
process.env.TTM_DATA_DIR ||
|
|
28
|
+
path.join(os.homedir(), '.claude', 'plugins', 'data', 'token-trace-manager')
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
30
|
+
return dir
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCHEMA = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
ts TEXT NOT NULL,
|
|
37
|
+
label TEXT,
|
|
38
|
+
target TEXT NOT NULL,
|
|
39
|
+
protocol TEXT NOT NULL,
|
|
40
|
+
model TEXT,
|
|
41
|
+
input_tokens INTEGER DEFAULT 0,
|
|
42
|
+
cache_create_tokens INTEGER DEFAULT 0,
|
|
43
|
+
cache_create_1h_tokens INTEGER DEFAULT 0,
|
|
44
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
45
|
+
output_tokens INTEGER DEFAULT 0,
|
|
46
|
+
reasoning_tokens INTEGER DEFAULT 0,
|
|
47
|
+
cost_usd REAL,
|
|
48
|
+
rates TEXT,
|
|
49
|
+
provider_id TEXT,
|
|
50
|
+
repo TEXT,
|
|
51
|
+
branch TEXT,
|
|
52
|
+
status INTEGER,
|
|
53
|
+
duration_ms INTEGER,
|
|
54
|
+
client TEXT,
|
|
55
|
+
payload INTEGER DEFAULT 0,
|
|
56
|
+
usage_fields TEXT,
|
|
57
|
+
usage_unknown INTEGER DEFAULT 0
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_ts ON usage_events(ts);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_label ON usage_events(label);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
63
|
+
key TEXT PRIMARY KEY,
|
|
64
|
+
value TEXT
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
-- Histórico permanente por tarefa: quando um trace expira (retenção), ele é
|
|
68
|
+
-- somado aqui ANTES de ser apagado — o detalhe morre, a soma sobrevive.
|
|
69
|
+
CREATE TABLE IF NOT EXISTS daily_rollups (
|
|
70
|
+
day TEXT NOT NULL,
|
|
71
|
+
label TEXT NOT NULL DEFAULT '',
|
|
72
|
+
target TEXT NOT NULL,
|
|
73
|
+
model TEXT NOT NULL DEFAULT '',
|
|
74
|
+
client TEXT NOT NULL DEFAULT '',
|
|
75
|
+
input_tokens INTEGER DEFAULT 0,
|
|
76
|
+
cache_create_tokens INTEGER DEFAULT 0,
|
|
77
|
+
cache_create_1h_tokens INTEGER DEFAULT 0,
|
|
78
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
79
|
+
output_tokens INTEGER DEFAULT 0,
|
|
80
|
+
reasoning_tokens INTEGER DEFAULT 0,
|
|
81
|
+
cost_usd REAL DEFAULT 0,
|
|
82
|
+
cost_known INTEGER DEFAULT 1,
|
|
83
|
+
events INTEGER DEFAULT 0,
|
|
84
|
+
PRIMARY KEY (day, label, target, model, client)
|
|
85
|
+
);
|
|
86
|
+
`
|
|
87
|
+
|
|
88
|
+
// Migração aditiva: bases criadas por versões antigas ganham as colunas novas.
|
|
89
|
+
// (CREATE TABLE IF NOT EXISTS não altera tabela existente.) O índice de
|
|
90
|
+
// provider_id vive AQUI, nunca no SCHEMA: num banco antigo ele só pode ser
|
|
91
|
+
// criado depois do ALTER TABLE adicionar a coluna.
|
|
92
|
+
const MIGRATION_COLUMNS = [
|
|
93
|
+
['cache_create_1h_tokens', 'INTEGER DEFAULT 0'],
|
|
94
|
+
['rates', 'TEXT'],
|
|
95
|
+
['provider_id', 'TEXT'],
|
|
96
|
+
['repo', 'TEXT'],
|
|
97
|
+
['branch', 'TEXT'],
|
|
98
|
+
['payload', 'INTEGER DEFAULT 0'],
|
|
99
|
+
['usage_fields', 'TEXT'],
|
|
100
|
+
['usage_unknown', 'INTEGER DEFAULT 0'],
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
function migrate(db) {
|
|
104
|
+
const existing = new Set(db.prepare('PRAGMA table_info(usage_events)').all().map(c => c.name))
|
|
105
|
+
for (const [col, type] of MIGRATION_COLUMNS) {
|
|
106
|
+
if (!existing.has(col)) db.exec(`ALTER TABLE usage_events ADD COLUMN ${col} ${type}`)
|
|
107
|
+
}
|
|
108
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_usage_events_provider_id ON usage_events(provider_id)')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let backend = null // { kind: 'sqlite', db } | { kind: 'json', file }
|
|
112
|
+
|
|
113
|
+
async function getBackend() {
|
|
114
|
+
if (backend) return backend
|
|
115
|
+
try {
|
|
116
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
117
|
+
const db = new DatabaseSync(path.join(dataDir(), 'ttm.sqlite'))
|
|
118
|
+
db.exec(SCHEMA)
|
|
119
|
+
migrate(db)
|
|
120
|
+
backend = { kind: 'sqlite', db }
|
|
121
|
+
} 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 }
|
|
132
|
+
}
|
|
133
|
+
return backend
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readJson(file) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
139
|
+
} catch {
|
|
140
|
+
return { nextId: 1, events: [], kv: {} }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeJson(file, data) {
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify(data))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function insertUsageEvent(ev) {
|
|
149
|
+
const b = await getBackend()
|
|
150
|
+
const row = {
|
|
151
|
+
ts: ev.ts ?? new Date().toISOString(),
|
|
152
|
+
label: ev.label ?? null,
|
|
153
|
+
target: ev.target,
|
|
154
|
+
protocol: ev.protocol,
|
|
155
|
+
model: ev.model ?? null,
|
|
156
|
+
input_tokens: ev.inputTokens | 0,
|
|
157
|
+
cache_create_tokens: ev.cacheCreateTokens | 0,
|
|
158
|
+
cache_create_1h_tokens: ev.cacheCreate1hTokens | 0,
|
|
159
|
+
cache_read_tokens: ev.cacheReadTokens | 0,
|
|
160
|
+
output_tokens: ev.outputTokens | 0,
|
|
161
|
+
reasoning_tokens: ev.reasoningTokens | 0,
|
|
162
|
+
cost_usd: ev.costUsd ?? null,
|
|
163
|
+
rates: ev.rates ? JSON.stringify(ev.rates) : null,
|
|
164
|
+
provider_id: ev.providerId ?? null,
|
|
165
|
+
repo: ev.repo ?? null,
|
|
166
|
+
branch: ev.branch ?? null,
|
|
167
|
+
status: ev.status ?? null,
|
|
168
|
+
duration_ms: ev.durationMs ?? null,
|
|
169
|
+
client: ev.client ?? null,
|
|
170
|
+
payload: ev.payload ? 1 : 0,
|
|
171
|
+
usage_fields: ev.usageFields && ev.usageFields.length ? JSON.stringify(ev.usageFields) : null,
|
|
172
|
+
usage_unknown: ev.usageUnknown ? 1 : 0,
|
|
173
|
+
}
|
|
174
|
+
if (b.kind === 'sqlite') {
|
|
175
|
+
const r = b.db
|
|
176
|
+
.prepare(
|
|
177
|
+
`INSERT INTO usage_events (ts, label, target, protocol, model, input_tokens, cache_create_tokens,
|
|
178
|
+
cache_create_1h_tokens, cache_read_tokens, output_tokens, reasoning_tokens, cost_usd, rates,
|
|
179
|
+
provider_id, repo, branch, status, duration_ms, client, payload, usage_fields, usage_unknown)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
181
|
+
)
|
|
182
|
+
.run(
|
|
183
|
+
row.ts,
|
|
184
|
+
row.label,
|
|
185
|
+
row.target,
|
|
186
|
+
row.protocol,
|
|
187
|
+
row.model,
|
|
188
|
+
row.input_tokens,
|
|
189
|
+
row.cache_create_tokens,
|
|
190
|
+
row.cache_create_1h_tokens,
|
|
191
|
+
row.cache_read_tokens,
|
|
192
|
+
row.output_tokens,
|
|
193
|
+
row.reasoning_tokens,
|
|
194
|
+
row.cost_usd,
|
|
195
|
+
row.rates,
|
|
196
|
+
row.provider_id,
|
|
197
|
+
row.repo,
|
|
198
|
+
row.branch,
|
|
199
|
+
row.status,
|
|
200
|
+
row.duration_ms,
|
|
201
|
+
row.client,
|
|
202
|
+
row.payload,
|
|
203
|
+
row.usage_fields,
|
|
204
|
+
row.usage_unknown,
|
|
205
|
+
)
|
|
206
|
+
return Number(r.lastInsertRowid)
|
|
207
|
+
}
|
|
208
|
+
const data = readJson(b.file)
|
|
209
|
+
const id = data.nextId++
|
|
210
|
+
data.events.push({ id, ...row })
|
|
211
|
+
writeJson(b.file, data)
|
|
212
|
+
return id
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function listUsageEvents({ since, label, target, limit } = {}) {
|
|
216
|
+
const b = await getBackend()
|
|
217
|
+
let rows
|
|
218
|
+
if (b.kind === 'sqlite') {
|
|
219
|
+
const where = []
|
|
220
|
+
const params = []
|
|
221
|
+
if (since) {
|
|
222
|
+
where.push('ts >= ?')
|
|
223
|
+
params.push(since)
|
|
224
|
+
}
|
|
225
|
+
if (label) {
|
|
226
|
+
where.push('label = ?')
|
|
227
|
+
params.push(label)
|
|
228
|
+
}
|
|
229
|
+
if (target) {
|
|
230
|
+
where.push('target = ?')
|
|
231
|
+
params.push(target)
|
|
232
|
+
}
|
|
233
|
+
const sql =
|
|
234
|
+
'SELECT * FROM usage_events' +
|
|
235
|
+
(where.length ? ' WHERE ' + where.join(' AND ') : '') +
|
|
236
|
+
' ORDER BY ts DESC' +
|
|
237
|
+
(limit ? ` LIMIT ${limit | 0}` : '')
|
|
238
|
+
rows = b.db.prepare(sql).all(...params)
|
|
239
|
+
} else {
|
|
240
|
+
rows = readJson(b.file).events.slice().reverse()
|
|
241
|
+
if (since) rows = rows.filter(r => r.ts >= since)
|
|
242
|
+
if (label) rows = rows.filter(r => r.label === label)
|
|
243
|
+
if (target) rows = rows.filter(r => r.target === target)
|
|
244
|
+
if (limit) rows = rows.slice(0, limit)
|
|
245
|
+
}
|
|
246
|
+
return rows
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const clientShort = c => (c || '').split('/')[0].split(' ')[0]
|
|
250
|
+
|
|
251
|
+
// Expira traces mais antigos que cutoffIso: agrega em daily_rollups e apaga.
|
|
252
|
+
export async function purgeExpired(cutoffIso) {
|
|
253
|
+
const b = await getBackend()
|
|
254
|
+
const groups = new Map()
|
|
255
|
+
const keyOf = r =>
|
|
256
|
+
`${(r.ts || '').slice(0, 10)}|${r.label ?? ''}|${r.target}|${r.model ?? ''}|${clientShort(r.client)}`
|
|
257
|
+
const addTo = r => {
|
|
258
|
+
const k = keyOf(r)
|
|
259
|
+
let g = groups.get(k)
|
|
260
|
+
if (!g) {
|
|
261
|
+
g = {
|
|
262
|
+
day: (r.ts || '').slice(0, 10),
|
|
263
|
+
label: r.label ?? '',
|
|
264
|
+
target: r.target,
|
|
265
|
+
model: r.model ?? '',
|
|
266
|
+
client: clientShort(r.client),
|
|
267
|
+
input_tokens: 0,
|
|
268
|
+
cache_create_tokens: 0,
|
|
269
|
+
cache_create_1h_tokens: 0,
|
|
270
|
+
cache_read_tokens: 0,
|
|
271
|
+
output_tokens: 0,
|
|
272
|
+
reasoning_tokens: 0,
|
|
273
|
+
cost_usd: 0,
|
|
274
|
+
cost_known: 1,
|
|
275
|
+
events: 0,
|
|
276
|
+
}
|
|
277
|
+
groups.set(k, g)
|
|
278
|
+
}
|
|
279
|
+
g.input_tokens += r.input_tokens || 0
|
|
280
|
+
g.cache_create_tokens += r.cache_create_tokens || 0
|
|
281
|
+
g.cache_create_1h_tokens += r.cache_create_1h_tokens || 0
|
|
282
|
+
g.cache_read_tokens += r.cache_read_tokens || 0
|
|
283
|
+
g.output_tokens += r.output_tokens || 0
|
|
284
|
+
g.reasoning_tokens += r.reasoning_tokens || 0
|
|
285
|
+
g.cost_usd += r.cost_usd || 0
|
|
286
|
+
if (r.cost_usd === null || r.cost_usd === undefined) g.cost_known = 0
|
|
287
|
+
g.events += 1
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (b.kind === 'sqlite') {
|
|
291
|
+
const expiring = b.db.prepare('SELECT * FROM usage_events WHERE ts < ?').all(cutoffIso)
|
|
292
|
+
if (!expiring.length) return { purged: 0 }
|
|
293
|
+
for (const r of expiring) addTo(r)
|
|
294
|
+
unlinkPayloads(expiring.map(r => r.id))
|
|
295
|
+
// TRANSAÇÃO: agregar + apagar precisam ser tudo-ou-nada — um crash entre
|
|
296
|
+
// os dois deixaria os eventos contados DUAS vezes no próximo purge.
|
|
297
|
+
b.db.exec('BEGIN IMMEDIATE')
|
|
298
|
+
const upsert = b.db.prepare(
|
|
299
|
+
`INSERT INTO daily_rollups (day, label, target, model, client, input_tokens, cache_create_tokens,
|
|
300
|
+
cache_create_1h_tokens, cache_read_tokens, output_tokens, reasoning_tokens, cost_usd, cost_known, events)
|
|
301
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
302
|
+
ON CONFLICT(day, label, target, model, client) DO UPDATE SET
|
|
303
|
+
input_tokens = input_tokens + excluded.input_tokens,
|
|
304
|
+
cache_create_tokens = cache_create_tokens + excluded.cache_create_tokens,
|
|
305
|
+
cache_create_1h_tokens = cache_create_1h_tokens + excluded.cache_create_1h_tokens,
|
|
306
|
+
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
307
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
308
|
+
reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens,
|
|
309
|
+
cost_usd = cost_usd + excluded.cost_usd,
|
|
310
|
+
cost_known = MIN(cost_known, excluded.cost_known),
|
|
311
|
+
events = events + excluded.events`,
|
|
312
|
+
)
|
|
313
|
+
try {
|
|
314
|
+
for (const g of groups.values()) {
|
|
315
|
+
upsert.run(
|
|
316
|
+
g.day, g.label, g.target, g.model, g.client,
|
|
317
|
+
g.input_tokens, g.cache_create_tokens, g.cache_create_1h_tokens, g.cache_read_tokens,
|
|
318
|
+
g.output_tokens, g.reasoning_tokens, g.cost_usd, g.cost_known, g.events,
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
b.db.prepare('DELETE FROM usage_events WHERE ts < ?').run(cutoffIso)
|
|
322
|
+
b.db.exec('COMMIT')
|
|
323
|
+
} catch (err) {
|
|
324
|
+
try {
|
|
325
|
+
b.db.exec('ROLLBACK')
|
|
326
|
+
} catch {
|
|
327
|
+
/* rollback já implícito */
|
|
328
|
+
}
|
|
329
|
+
throw err
|
|
330
|
+
}
|
|
331
|
+
return { purged: expiring.length }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const data = readJson(b.file)
|
|
335
|
+
const expiring = data.events.filter(e => e.ts < cutoffIso)
|
|
336
|
+
if (!expiring.length) return { purged: 0 }
|
|
337
|
+
for (const r of expiring) addTo(r)
|
|
338
|
+
unlinkPayloads(expiring.map(r => r.id))
|
|
339
|
+
data.rollups = data.rollups || {}
|
|
340
|
+
for (const [k, g] of groups) {
|
|
341
|
+
const prev = data.rollups[k]
|
|
342
|
+
if (!prev) data.rollups[k] = g
|
|
343
|
+
else {
|
|
344
|
+
for (const f of ['input_tokens','cache_create_tokens','cache_create_1h_tokens','cache_read_tokens','output_tokens','reasoning_tokens','cost_usd','events']) prev[f] += g[f]
|
|
345
|
+
prev.cost_known = Math.min(prev.cost_known, g.cost_known)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
data.events = data.events.filter(e => e.ts >= cutoffIso)
|
|
349
|
+
writeJson(b.file, data)
|
|
350
|
+
return { purged: expiring.length }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function listRollups({ sinceDay, label } = {}) {
|
|
354
|
+
const b = await getBackend()
|
|
355
|
+
let rows
|
|
356
|
+
if (b.kind === 'sqlite') {
|
|
357
|
+
rows = b.db.prepare('SELECT * FROM daily_rollups').all()
|
|
358
|
+
} else {
|
|
359
|
+
rows = Object.values(readJson(b.file).rollups || {})
|
|
360
|
+
}
|
|
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)
|
|
363
|
+
return rows
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// "Poder de deletar": traces, ou tudo (traces + histórico agregado).
|
|
367
|
+
export async function deleteData({ mode = 'events', label = null } = {}) {
|
|
368
|
+
const b = await getBackend()
|
|
369
|
+
if (b.kind === 'sqlite') {
|
|
370
|
+
let deleted = 0
|
|
371
|
+
if (label) {
|
|
372
|
+
unlinkPayloads(b.db.prepare('SELECT id FROM usage_events WHERE label = ?').all(label).map(r => r.id))
|
|
373
|
+
deleted = b.db.prepare('DELETE FROM usage_events WHERE label = ?').run(label).changes
|
|
374
|
+
if (mode === 'everything') {
|
|
375
|
+
deleted += b.db.prepare('DELETE FROM daily_rollups WHERE label = ?').run(label).changes
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
unlinkPayloads(b.db.prepare('SELECT id FROM usage_events').all().map(r => r.id))
|
|
379
|
+
deleted = b.db.prepare('DELETE FROM usage_events').run().changes
|
|
380
|
+
if (mode === 'everything') deleted += b.db.prepare('DELETE FROM daily_rollups').run().changes
|
|
381
|
+
}
|
|
382
|
+
return { deleted }
|
|
383
|
+
}
|
|
384
|
+
const data = readJson(b.file)
|
|
385
|
+
const before = data.events.length + Object.keys(data.rollups || {}).length
|
|
386
|
+
unlinkPayloads((label ? data.events.filter(e => e.label === label) : data.events).map(e => e.id))
|
|
387
|
+
data.events = label ? data.events.filter(e => e.label !== label) : []
|
|
388
|
+
if (mode === 'everything') {
|
|
389
|
+
if (label) {
|
|
390
|
+
for (const k of Object.keys(data.rollups || {})) if ((data.rollups[k].label || '') === label) delete data.rollups[k]
|
|
391
|
+
} else data.rollups = {}
|
|
392
|
+
}
|
|
393
|
+
writeJson(b.file, data)
|
|
394
|
+
return { deleted: before - (data.events.length + Object.keys(data.rollups || {}).length) }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Relabel retroativo: o usuário começou a trabalhar antes de criar o card e
|
|
398
|
+
// ativou o ttm depois — as mensagens SEM label da janela recebem a tarefa.
|
|
399
|
+
export async function relabelUnlabeled({ label, sinceIso }) {
|
|
400
|
+
const b = await getBackend()
|
|
401
|
+
if (b.kind === 'sqlite') {
|
|
402
|
+
const r = b.db
|
|
403
|
+
.prepare('UPDATE usage_events SET label = ? WHERE label IS NULL AND ts >= ?')
|
|
404
|
+
.run(label, sinceIso)
|
|
405
|
+
return { relabeled: r.changes }
|
|
406
|
+
}
|
|
407
|
+
const data = readJson(b.file)
|
|
408
|
+
let n = 0
|
|
409
|
+
for (const e of data.events) {
|
|
410
|
+
if (e.label === null && e.ts >= sinceIso) {
|
|
411
|
+
e.label = label
|
|
412
|
+
n++
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
writeJson(b.file, data)
|
|
416
|
+
return { relabeled: n }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function getKv(key) {
|
|
420
|
+
const b = await getBackend()
|
|
421
|
+
if (b.kind === 'sqlite') {
|
|
422
|
+
const row = b.db.prepare('SELECT value FROM kv WHERE key = ?').get(key)
|
|
423
|
+
return row ? row.value : null
|
|
424
|
+
}
|
|
425
|
+
return readJson(b.file).kv[key] ?? null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function setKv(key, value) {
|
|
429
|
+
const b = await getBackend()
|
|
430
|
+
if (b.kind === 'sqlite') {
|
|
431
|
+
if (value === null) {
|
|
432
|
+
b.db.prepare('DELETE FROM kv WHERE key = ?').run(key)
|
|
433
|
+
} else {
|
|
434
|
+
b.db
|
|
435
|
+
.prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
|
436
|
+
.run(key, value)
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
const data = readJson(b.file)
|
|
440
|
+
if (value === null) delete data.kv[key]
|
|
441
|
+
else data.kv[key] = value
|
|
442
|
+
writeJson(b.file, data)
|
|
443
|
+
}
|
|
444
|
+
}
|
package/lib/link.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Orquestração do `ttm link <tui>` / `ttm unlink <tui>`.
|
|
2
|
+
//
|
|
3
|
+
// Fluxo do link:
|
|
4
|
+
// 1. descobre a base URL efetiva do TUI (env var ou arquivo de config)
|
|
5
|
+
// 2. registra um target no ttm apontando pra esse upstream (preserva o
|
|
6
|
+
// original em links.<tui> p/ unlink + detecção de loop)
|
|
7
|
+
// 3. reescreve a config do TUI pra apontar pro ttm
|
|
8
|
+
// 4. (garante daemon rodando; seta label default se --label)
|
|
9
|
+
//
|
|
10
|
+
// Idempotente: se o TUI já aponta pro ttm, não re-descobre (evita loop).
|
|
11
|
+
// Re-link pós troca de upstream exige --force (detecção via links.<tui>).
|
|
12
|
+
|
|
13
|
+
import { readConfig, setTarget, removeTarget, setLink } from './config.mjs'
|
|
14
|
+
import { discoverTui, writeTuiBaseUrl, restoreTuiBaseUrl, AUTOLINKABLE_TUIS } from './tui-config.mjs'
|
|
15
|
+
import { ensureDaemon, fetchDaemon } from './daemon-client.mjs'
|
|
16
|
+
|
|
17
|
+
export async function linkTui(tui, { label, repo, force, cwd } = {}) {
|
|
18
|
+
if (!AUTOLINKABLE_TUIS.includes(tui)) {
|
|
19
|
+
return { supported: false, tui }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cfg = readConfig()
|
|
23
|
+
const existing = cfg.links?.[tui]
|
|
24
|
+
const discover = discoverTui(tui, { cwd: cwd ?? process.cwd() })
|
|
25
|
+
const proxyUrl = cfg.proxyUrl
|
|
26
|
+
const expectedProxyBase = `${proxyUrl}/${tui}`
|
|
27
|
+
|
|
28
|
+
// já linkado?
|
|
29
|
+
if (existing) {
|
|
30
|
+
const pointingAtTtm = discover.baseUrl && discover.baseUrl.startsWith(proxyUrl)
|
|
31
|
+
if (pointingAtTtm) {
|
|
32
|
+
// idempotente — só garante label/daemon
|
|
33
|
+
if (label) {
|
|
34
|
+
await ensureDaemon()
|
|
35
|
+
await fetchDaemon('/api/label', { method: 'POST', body: { label } })
|
|
36
|
+
}
|
|
37
|
+
return { alreadyLinked: true, tui, upstream: existing.upstream, proxyBaseUrl: expectedProxyBase, label }
|
|
38
|
+
}
|
|
39
|
+
// usuário mudou o upstream manualmente desde o último link
|
|
40
|
+
if (!force) {
|
|
41
|
+
return {
|
|
42
|
+
needsForce: true,
|
|
43
|
+
tui,
|
|
44
|
+
currentBaseUrl: discover.baseUrl,
|
|
45
|
+
existingUpstream: existing.upstream,
|
|
46
|
+
hint: `você já está linkado (upstream registrado: ${existing.upstream}). Se mudou o upstream, rode com --force p/ relinkar a partir de ${discover.baseUrl}`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// --force: re-linka usando o baseUrl atual como novo upstream
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// determina o upstream
|
|
53
|
+
let upstream = discover.baseUrl
|
|
54
|
+
// loop guard: se o baseUrl atual já aponta pro ttm (ex: link re-rodado sem
|
|
55
|
+
// ter mudado nada mas o discovery via settings retornou o proxy), usa o
|
|
56
|
+
// original guardado ou o default do TUI
|
|
57
|
+
if (!upstream || upstream.startsWith(proxyUrl)) {
|
|
58
|
+
upstream = existing?.upstream ?? discover.defaultUpstream
|
|
59
|
+
}
|
|
60
|
+
if (!upstream) {
|
|
61
|
+
return { error: `não consegui determinar o upstream para ${tui} (sem redirect prévio e sem default)` }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// registra o target no ttm
|
|
65
|
+
const spec = { targetUrl: upstream, protocol: discover.protocol }
|
|
66
|
+
if (discover.envVar && discover.source === 'env') {
|
|
67
|
+
// env-following: segue a env var em runtime (se o usuário trocar de
|
|
68
|
+
// redirect só mudando a env, o ttm acompanha)
|
|
69
|
+
spec.targetUrlEnv = discover.envVar
|
|
70
|
+
spec.targetUrl = upstream
|
|
71
|
+
}
|
|
72
|
+
setTarget(tui, spec)
|
|
73
|
+
|
|
74
|
+
// garante daemon de pé (link não funciona sem proxy ouvindo)
|
|
75
|
+
await ensureDaemon()
|
|
76
|
+
|
|
77
|
+
// seta label default se pedido
|
|
78
|
+
if (label) {
|
|
79
|
+
await fetchDaemon('/api/label', { method: 'POST', body: { label } })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// reescreve a config do TUI
|
|
83
|
+
let rewrite
|
|
84
|
+
if (discover.canWriteBack) {
|
|
85
|
+
const writeTarget = repo && discover.repoWriteTarget ? discover.repoWriteTarget : discover.writeTarget
|
|
86
|
+
writeTuiBaseUrl(tui, expectedProxyBase, { writeTarget, cwd })
|
|
87
|
+
rewrite = { done: true, writeTarget, source: discover.source }
|
|
88
|
+
} else {
|
|
89
|
+
// TUI só-env (gemini-cli, ou claude-code quando redirect via env):
|
|
90
|
+
// ttm não consegue persistir — instrui o usuário a fazer o export
|
|
91
|
+
rewrite = {
|
|
92
|
+
done: false,
|
|
93
|
+
envVar: discover.envVar,
|
|
94
|
+
exportLine: `export ${discover.envVar}=${expectedProxyBase}`,
|
|
95
|
+
note: `o ${tui} lê a base URL de uma env var que o ttm não pode alterar — rode o export acima no seu shell e reinicie o TUI`,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// guarda o estado do link p/ unlink + detecção de loop/idempotência
|
|
100
|
+
setLink(tui, {
|
|
101
|
+
originalBaseUrl: discover.baseUrl,
|
|
102
|
+
source: discover.source,
|
|
103
|
+
writeTarget: discover.writeTarget,
|
|
104
|
+
repoWriteTarget: discover.repoWriteTarget ?? null,
|
|
105
|
+
envVar: discover.envVar ?? null,
|
|
106
|
+
upstream,
|
|
107
|
+
protocol: discover.protocol,
|
|
108
|
+
targetUrlEnv: spec.targetUrlEnv ?? null,
|
|
109
|
+
linkedAt: new Date().toISOString(),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
linked: true,
|
|
114
|
+
tui,
|
|
115
|
+
upstream,
|
|
116
|
+
protocol: discover.protocol,
|
|
117
|
+
proxyBaseUrl: expectedProxyBase,
|
|
118
|
+
rewrite,
|
|
119
|
+
label,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function unlinkTui(tui, { cwd } = {}) {
|
|
124
|
+
const cfg = readConfig()
|
|
125
|
+
const info = cfg.links?.[tui]
|
|
126
|
+
if (!info) return { notLinked: true, tui }
|
|
127
|
+
|
|
128
|
+
let restore
|
|
129
|
+
if (info.source === 'env' || !info.writeTarget) {
|
|
130
|
+
// fonte só-env: só dá pra instruir o usuário
|
|
131
|
+
const target = info.originalBaseUrl
|
|
132
|
+
? `export ${info.envVar}=${info.originalBaseUrl}`
|
|
133
|
+
: `unset ${info.envVar}`
|
|
134
|
+
restore = { done: false, envVar: info.envVar, exportLine: target, note: 'fonte só-env — rode o export/unset no seu shell' }
|
|
135
|
+
} else {
|
|
136
|
+
try {
|
|
137
|
+
restoreTuiBaseUrl(tui, {
|
|
138
|
+
original: info.originalBaseUrl,
|
|
139
|
+
source: info.source,
|
|
140
|
+
writeTarget: info.writeTarget,
|
|
141
|
+
cwd,
|
|
142
|
+
})
|
|
143
|
+
restore = { done: true, writeTarget: info.writeTarget }
|
|
144
|
+
} catch (err) {
|
|
145
|
+
restore = { done: false, error: err.message }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
removeTarget(tui)
|
|
150
|
+
setLink(tui, null)
|
|
151
|
+
return { unlinked: true, tui, restore }
|
|
152
|
+
}
|