@nectarcrm/ccx 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/bin/ccx.js +59 -22
  2. package/package.json +1 -1
package/bin/ccx.js CHANGED
@@ -101,6 +101,14 @@ function writeSettings(s) {
101
101
  writeJson(SETTINGS, s)
102
102
  if (!WIN) chmodSync(SETTINGS, 0o600) // agora guarda um token
103
103
  }
104
+ // Quando cada conta passou a valer neste computador (o sync usa pra saber a conta de cada sessão)
105
+ const HISTORY_DAYS = 45
106
+ function withHistory(app, id) {
107
+ const since = new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString()
108
+ const history = (app?.history ?? []).filter(h => h.at >= since)
109
+ if (history.at(-1)?.id !== id) history.push({ at: new Date().toISOString(), id })
110
+ return history
111
+ }
104
112
  function applyAccount(app, key, account, token) {
105
113
  const s = readSettings()
106
114
  const env = { ...s.env }
@@ -117,7 +125,7 @@ function applyAccount(app, key, account, token) {
117
125
  // Métricas não são usadas; desliga só se a pessoa não configurou as dela
118
126
  if (!('OTEL_METRICS_EXPORTER' in env) || app.owned?.includes('OTEL_METRICS_EXPORTER')) vars.OTEL_METRICS_EXPORTER = 'none'
119
127
  writeSettings({ ...s, env: { ...env, ...vars } })
120
- return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars) }
128
+ return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars), history: withHistory(app, account.id) }
121
129
  }
122
130
  function removeAccount(app) {
123
131
  if (app?.owned?.length && existsSync(SETTINGS)) {
@@ -128,7 +136,7 @@ function removeAccount(app) {
128
136
  else delete s.env
129
137
  writeSettings(s)
130
138
  }
131
- return { ...app, account: null, token_fp: null, owned: [] }
139
+ return { ...app, account: null, token_fp: null, owned: [], history: withHistory(app, null) }
132
140
  }
133
141
 
134
142
  // ---------- vigia em segundo plano ----------
@@ -248,8 +256,8 @@ async function agentOnce() {
248
256
  }
249
257
 
250
258
  // ---------- sync: resumo das sessões do Claude Code ----------
251
- // Lê ~/.claude/projects/**/*.jsonl, soma por sessão, projeto e dia (de Brasília) e manda só os
252
- // totais: pedidos, respostas, ferramentas e tokens. Nunca o texto da conversa.
259
+ // Lê ~/.claude/projects/**/*.jsonl, soma por sessão, projeto e dia (de Brasília), separando branch e modelo,
260
+ // e manda só os totais: pedidos, respostas, ferramentas e tokens. Nunca o texto da conversa.
253
261
  // Os arquivos só crescem: guardamos até onde lemos cada um e os totais, e mandamos só o que mudou.
254
262
  const PROJECTS = join(dirname(SETTINGS), 'projects')
255
263
  const SYNC_STATE = join(DIR, 'sync.json')
@@ -257,6 +265,7 @@ const SYNC_LOCK = join(DIR, 'sync.lock')
257
265
  const SYNC_BATCH = 2000
258
266
  const SYNC_CHUNK = 1 << 20
259
267
  const SYNC_KEEP_DAYS = 40 // o Claude apaga as sessões com 30 dias
268
+ const SYNC_VERSION = 2 // mudou o formato do sync.json: lê tudo de novo
260
269
  const brtDay = ts => new Date(Date.parse(ts) - 3 * 3600_000).toISOString().slice(0, 10)
261
270
 
262
271
  function jsonlFiles(dir) {
@@ -326,9 +335,10 @@ function addLine(events, text) {
326
335
  const u = o.message.usage
327
336
  const tools = (events.get(o.requestId)?.tools ?? 0) + (o.message.content ?? []).filter(b => b?.type === 'tool_use').length
328
337
  events.set(o.requestId, {
329
- ...base, kind: 'resposta', tools,
338
+ ...base, kind: 'resposta', tools, model: typeof o.message.model === 'string' ? o.message.model : '',
330
339
  input_tokens: u.input_tokens ?? 0, output_tokens: u.output_tokens ?? 0,
331
340
  cache_read_tokens: u.cache_read_input_tokens ?? 0, cache_creation_tokens: u.cache_creation_input_tokens ?? 0,
341
+ cache_1h_tokens: u.cache_creation?.ephemeral_1h_input_tokens ?? 0,
332
342
  })
333
343
  return
334
344
  }
@@ -340,17 +350,19 @@ function addLine(events, text) {
340
350
  }
341
351
  }
342
352
 
343
- // Soma um evento no total da sessão, projeto e dia
353
+ // Soma um evento no dia da sessão e projeto, na parte da branch e do modelo dele
354
+ const SUMS = ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'cache_1h_tokens']
344
355
  function addToDay(days, e) {
345
356
  const day = brtDay(e.at)
346
357
  const key = `${e.session_id}\t${e.project}\t${day}`
347
- const d = days[key] ??= { session_id: e.session_id, project: e.project, day, branch: null, last_at: e.at,
348
- prompts: 0, responses: 0, tools: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }
349
- if (e.at >= d.last_at) { d.last_at = e.at; d.branch = e.branch ?? d.branch }
350
- if (e.kind === 'pedido') d.prompts++
358
+ const d = days[key] ??= { session_id: e.session_id, project: e.project, day, parts: {} }
359
+ const branch = e.branch ?? '', model = e.model ?? ''
360
+ const p = d.parts[`${branch}\t${model}`] ??= { branch, model, last_at: e.at, prompts: 0, responses: 0, ...Object.fromEntries(SUMS.map(k => [k, 0])) }
361
+ if (e.at > p.last_at) p.last_at = e.at
362
+ if (e.kind === 'pedido') p.prompts++
351
363
  else {
352
- d.responses++
353
- for (const k of ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens']) d[k] += e[k]
364
+ p.responses++
365
+ for (const k of SUMS) p[k] += e[k]
354
366
  }
355
367
  return key
356
368
  }
@@ -370,9 +382,16 @@ async function sync(app, key, quiet = false) {
370
382
  const say = msg => { if (!quiet) process.stderr.write(msg) }
371
383
  if (!takeLock()) return say('Outro sync já está rodando. Tente de novo daqui a pouco.\n')
372
384
  try {
373
- const state = readJson(SYNC_STATE, {})
374
- const offsets = state.files ?? {}, days = state.days ?? {}
385
+ let state = readJson(SYNC_STATE, {})
386
+ if (state.v !== SYNC_VERSION) state = {}
387
+ const offsets = state.files ?? {}, days = state.days ?? {}, sessions = state.sessions ?? {}
375
388
  const changed = new Set()
389
+ // Modo oculto: da conta marcada pelo superadmin, só os projetos da lista saem daqui. App antigo: nenhuma.
390
+ const h = await api(app, key, 'GET', '/api/cli/hidden', null, 20_000)
391
+ if (h.status !== 200 && h.status !== 404) throw new Error(h.data.message ?? `HTTP ${h.status}`) // na dúvida, não manda nada
392
+ const hidden = h.status === 200 ? h.data.accounts ?? {} : {}
393
+ const accountAt = at => (app.history ?? []).filter(x => x.at <= at).at(-1)?.id ?? null
394
+ const excluded = new Set()
376
395
  const files = jsonlFiles(PROJECTS)
377
396
  say(`Lendo as sessões do Claude Code em ${PROJECTS}`)
378
397
  for (const file of files) {
@@ -380,7 +399,13 @@ async function sync(app, key, quiet = false) {
380
399
  const from = offsets[file] ?? 0
381
400
  if (from >= size) continue // nada novo (ou arquivo trocado: não soma de novo)
382
401
  const { events, offset } = readNewLines(file, from)
383
- for (const e of events) changed.add(addToDay(days, e))
402
+ for (const e of events) {
403
+ // A conta da sessão é a que valia quando ela começou (claude aberto não troca de conta)
404
+ const s = sessions[e.session_id] ??= { id: accountAt(e.at), at: e.at }
405
+ const only = s.id === null ? null : hidden[s.id]
406
+ if (only && !only.includes(e.project)) { excluded.add(e.session_id); continue }
407
+ changed.add(addToDay(days, e))
408
+ }
384
409
  offsets[file] = offset
385
410
  }
386
411
  // Esquece arquivo apagado e dia antigo
@@ -388,17 +413,29 @@ async function sync(app, key, quiet = false) {
388
413
  for (const f of Object.keys(offsets)) if (!alive.has(f)) delete offsets[f]
389
414
  const oldest = brtDay(new Date(Date.now() - SYNC_KEEP_DAYS * 86_400_000).toISOString())
390
415
  for (const [k, d] of Object.entries(days)) if (d.day < oldest) delete days[k]
391
-
392
- const items = [...changed].map(k => days[k]).filter(Boolean)
393
- for (let i = 0; i < items.length; i += SYNC_BATCH) {
416
+ for (const [k, s] of Object.entries(sessions)) if (brtDay(s.at) < oldest) delete sessions[k]
417
+
418
+ // O app troca o dia inteiro da sessão: as partes de um dia vão sempre no mesmo lote
419
+ const sent = [...changed].map(k => days[k]).filter(Boolean)
420
+ const items = sent.map(({ parts, ...d }) => Object.values(parts).map(p => ({ ...d, ...p })))
421
+ let batch = [], exclude = [...excluded]
422
+ const send = async () => {
423
+ if (!batch.length && !exclude.length) return
394
424
  say('.')
395
- const r = await api(app, key, 'POST', '/api/cli/sync', { items: items.slice(i, i + SYNC_BATCH) })
425
+ const r = await api(app, key, 'POST', '/api/cli/sync', { items: batch, ...(exclude.length && { exclude_sessions: exclude }) })
396
426
  if (r.status !== 200) throw new Error(r.data.message ?? `HTTP ${r.status}`)
427
+ batch = []
428
+ exclude = []
429
+ }
430
+ for (const day of items) {
431
+ if (batch.length + day.length > SYNC_BATCH) await send()
432
+ batch.push(...day.slice(0, SYNC_BATCH))
397
433
  }
434
+ await send()
398
435
  // Só grava depois de enviar: se falhar, a próxima vez lê e manda de novo
399
- writeJson(SYNC_STATE, { files: offsets, days })
400
- say(`\n${c(32, '✓')} Enviado: ${items.length} ${items.length === 1 ? 'resumo' : 'resumos'} (sessão, projeto e dia).\n`)
401
- return items.length
436
+ writeJson(SYNC_STATE, { v: SYNC_VERSION, files: offsets, days, sessions })
437
+ say(`\n${c(32, '✓')} Enviado: ${sent.length} ${sent.length === 1 ? 'resumo' : 'resumos'} (sessão, projeto e dia).\n`)
438
+ return sent.length
402
439
  }
403
440
  finally { rmSync(SYNC_LOCK, { force: true }) }
404
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectarcrm/ccx",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Escolhe qual conta Claude este computador usa, conectado ao app Contas Claude da Nectar.",
5
5
  "type": "module",
6
6
  "bin": { "ccx": "bin/ccx.js" },