@statforge/claudestat 1.0.1

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 (80) hide show
  1. package/README.md +437 -0
  2. package/dashboard/dist/assets/AnalyticsView-BApcOGsD.js +8 -0
  3. package/dashboard/dist/assets/HistoryView-B331k5oL.js +1 -0
  4. package/dashboard/dist/assets/ProjectsView-DUleaXsP.js +6 -0
  5. package/dashboard/dist/assets/SystemView-BGe__vl1.js +1 -0
  6. package/dashboard/dist/assets/TopView-CXggyydU.js +1 -0
  7. package/dashboard/dist/assets/index-CB01c5lb.js +84 -0
  8. package/dashboard/dist/assets/vendor-lucide-Cym0q5l_.js +344 -0
  9. package/dashboard/dist/assets/vendor-react-B_Jzs0gY.js +24 -0
  10. package/dashboard/dist/index.html +21 -0
  11. package/dist/cache/projects-cache.d.ts +9 -0
  12. package/dist/cache/projects-cache.js +51 -0
  13. package/dist/claude-auth.d.ts +38 -0
  14. package/dist/claude-auth.js +133 -0
  15. package/dist/claude-stats.d.ts +32 -0
  16. package/dist/claude-stats.js +98 -0
  17. package/dist/config.d.ts +43 -0
  18. package/dist/config.js +110 -0
  19. package/dist/daemon.d.ts +15 -0
  20. package/dist/daemon.js +247 -0
  21. package/dist/db.d.ts +134 -0
  22. package/dist/db.js +546 -0
  23. package/dist/doctor.d.ts +1 -0
  24. package/dist/doctor.js +191 -0
  25. package/dist/enricher.d.ts +34 -0
  26. package/dist/enricher.js +394 -0
  27. package/dist/export.d.ts +8 -0
  28. package/dist/export.js +82 -0
  29. package/dist/git.d.ts +22 -0
  30. package/dist/git.js +57 -0
  31. package/dist/github.d.ts +27 -0
  32. package/dist/github.js +62 -0
  33. package/dist/index.d.ts +8 -0
  34. package/dist/index.js +319 -0
  35. package/dist/install.d.ts +14 -0
  36. package/dist/install.js +202 -0
  37. package/dist/intelligence.d.ts +45 -0
  38. package/dist/intelligence.js +105 -0
  39. package/dist/meta-stats.d.ts +28 -0
  40. package/dist/meta-stats.js +137 -0
  41. package/dist/middleware/rate-limiter.d.ts +2 -0
  42. package/dist/middleware/rate-limiter.js +30 -0
  43. package/dist/notifier.d.ts +1 -0
  44. package/dist/notifier.js +22 -0
  45. package/dist/paths.d.ts +79 -0
  46. package/dist/paths.js +134 -0
  47. package/dist/pattern-analyzer.d.ts +35 -0
  48. package/dist/pattern-analyzer.js +123 -0
  49. package/dist/project-scanner.d.ts +71 -0
  50. package/dist/project-scanner.js +619 -0
  51. package/dist/quota-tracker.d.ts +45 -0
  52. package/dist/quota-tracker.js +320 -0
  53. package/dist/render.d.ts +55 -0
  54. package/dist/render.js +229 -0
  55. package/dist/routes/events.d.ts +18 -0
  56. package/dist/routes/events.js +272 -0
  57. package/dist/routes/history.d.ts +1 -0
  58. package/dist/routes/history.js +65 -0
  59. package/dist/routes/misc.d.ts +1 -0
  60. package/dist/routes/misc.js +280 -0
  61. package/dist/routes/projects.d.ts +15 -0
  62. package/dist/routes/projects.js +153 -0
  63. package/dist/routes/reports.d.ts +11 -0
  64. package/dist/routes/reports.js +205 -0
  65. package/dist/routes/stream.d.ts +8 -0
  66. package/dist/routes/stream.js +70 -0
  67. package/dist/routes/top.d.ts +1 -0
  68. package/dist/routes/top.js +30 -0
  69. package/dist/session-state.d.ts +35 -0
  70. package/dist/session-state.js +50 -0
  71. package/dist/summarizer.d.ts +18 -0
  72. package/dist/summarizer.js +137 -0
  73. package/dist/watch.d.ts +8 -0
  74. package/dist/watch.js +157 -0
  75. package/dist/watchdog.d.ts +11 -0
  76. package/dist/watchdog.js +75 -0
  77. package/dist/weekly.d.ts +13 -0
  78. package/dist/weekly.js +39 -0
  79. package/hooks/event.js +80 -0
  80. package/package.json +78 -0
@@ -0,0 +1,320 @@
1
+ "use strict";
2
+ /**
3
+ * quota-tracker.ts — Seguimiento de cuota de uso de Claude Code
4
+ *
5
+ * Calcula en base a los JSONL de ~/.claude/projects/:
6
+ * - Prompts reales en el ciclo actual de 5 horas (ventana deslizante desde epoch UTC)
7
+ * - Tiempo hasta el reset del ciclo
8
+ * - Horas de sesión semanales por modelo (Sonnet vs Opus)
9
+ * - Burn rate: tokens/min promedio de los últimos 30 minutos
10
+ * - Plan detectado automáticamente desde el máximo histórico
11
+ *
12
+ * Por qué 5h desde epoch UTC (no desde una hora fija):
13
+ * Claude Code usa ventanas deslizantes de exactamente 5 horas desde el epoch Unix.
14
+ * Ciclo actual = floor(now / 5h) * 5h. El reset ocurre cuando cruza ese límite.
15
+ *
16
+ * Caché de 30 segundos para no re-leer todos los JSONL en cada request del dashboard.
17
+ */
18
+ var __importDefault = (this && this.__importDefault) || function (mod) {
19
+ return (mod && mod.__esModule) ? mod : { "default": mod };
20
+ };
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.computeQuota = computeQuota;
23
+ exports.invalidateQuotaCache = invalidateQuotaCache;
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const claude_auth_1 = require("./claude-auth");
27
+ const paths_1 = require("./paths");
28
+ const PLAN_LIMITS = {
29
+ free: { prompts5h: 10, weeklyHoursSonnet: 40, weeklyHoursOpus: 0 },
30
+ pro: { prompts5h: 45, weeklyHoursSonnet: 80, weeklyHoursOpus: 0 },
31
+ max5: { prompts5h: 225, weeklyHoursSonnet: 280, weeklyHoursOpus: 35 },
32
+ max20: { prompts5h: 900, weeklyHoursSonnet: 480, weeklyHoursOpus: 40 },
33
+ };
34
+ // ─── Helpers de ventanas temporales ──────────────────────────────────────────
35
+ const CYCLE_MS = 5 * 60 * 60 * 1000; // 5 horas en ms
36
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
37
+ const WINDOW_5MIN = 5 * 60 * 1000; // ventana de 5 min para agrupar actividad por modelo
38
+ /**
39
+ * Calcula el timestamp de reset usando ventana rolling real.
40
+ *
41
+ * Claude Code NO usa floor(now/5h) desde epoch UTC — usa una ventana rolling
42
+ * que empieza desde el primer mensaje del ciclo actual.
43
+ *
44
+ * Enfoque: buscar el primer mensaje humano en los últimos 5h de actividad.
45
+ * resetAt = primerMensaje.ts + 5h
46
+ *
47
+ * Si no hay mensajes en las últimas 5h → el ciclo ya reseteó, el próximo
48
+ * reset es en 5h desde el primer mensaje futuro (mostramos ~5h).
49
+ */
50
+ function computeResetAt(entries, now) {
51
+ const fiveHoursAgo = now - CYCLE_MS;
52
+ const recentHuman = entries
53
+ .filter(e => e.type === 'human' && e.ts >= fiveHoursAgo)
54
+ .sort((a, b) => a.ts - b.ts);
55
+ if (recentHuman.length > 0) {
56
+ // Usamos el PRIMER mensaje humano (más antiguo) + 5h
57
+ // = momento en que el primer prompt de la ventana actual expira → cuota empieza a liberarse
58
+ // Los mensajes tool_result de sub-agentes ya están filtrados en el caller
59
+ return recentHuman[0].ts + CYCLE_MS;
60
+ }
61
+ // Sin actividad en las últimas 5h → cuota ya libre, no hay reset pendiente
62
+ // Retornar `now` hace que cycleResetMs = 0, la UI puede mostrar "Disponible"
63
+ return now;
64
+ }
65
+ function getWeekStart(now) {
66
+ // Lunes 00:00 hora local
67
+ const d = new Date(now);
68
+ const day = d.getDay(); // 0=domingo … 6=sábado
69
+ const diff = day === 0 ? -6 : 1 - day; // días hasta el lunes anterior
70
+ d.setDate(d.getDate() + diff);
71
+ d.setHours(0, 0, 0, 0);
72
+ return d.getTime();
73
+ }
74
+ // ─── Parser de un archivo JSONL ───────────────────────────────────────────────
75
+ function parseJSONLFile(filePath) {
76
+ const entries = [];
77
+ let content;
78
+ try {
79
+ content = fs_1.default.readFileSync(filePath, 'utf8');
80
+ }
81
+ catch {
82
+ return entries;
83
+ }
84
+ for (const raw of content.split('\n')) {
85
+ const line = raw.trim();
86
+ if (!line)
87
+ continue;
88
+ try {
89
+ const obj = JSON.parse(line);
90
+ // Timestamp: ISO string en la raíz del objeto
91
+ const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : 0;
92
+ if (!ts || isNaN(ts))
93
+ continue;
94
+ // Mensajes de usuario: tipo 'human' (algunas versiones usan 'user')
95
+ if (obj.type === 'human' || obj.type === 'user') {
96
+ // Filtrar comandos locales — no son prompts reales del usuario
97
+ const content = obj.message?.content;
98
+ // Saltar mensajes internos de sub-agentes: su content comienza con tool_result
99
+ // (son respuestas de herramientas que el sub-agente recibe, no prompts del usuario)
100
+ if (Array.isArray(content) && content[0]?.type === 'tool_result')
101
+ continue;
102
+ const text = typeof content === 'string' ? content
103
+ : Array.isArray(content)
104
+ ? content.find(c => c?.type === 'text')?.text ?? ''
105
+ : '';
106
+ if (text.includes('<command-name>') ||
107
+ text.includes('<local-command-stdout>') ||
108
+ text.includes('<system-reminder>'))
109
+ continue;
110
+ entries.push({ ts, type: 'human' });
111
+ }
112
+ // Respuestas del asistente: tienen datos de uso de tokens
113
+ if (obj.type === 'assistant') {
114
+ const usage = obj.message?.usage;
115
+ const model = obj.message?.model ?? 'claude-sonnet-4-6';
116
+ entries.push({
117
+ ts, type: 'assistant',
118
+ model,
119
+ inputTokens: usage?.input_tokens ?? 0,
120
+ outputTokens: usage?.output_tokens ?? 0,
121
+ });
122
+ }
123
+ }
124
+ catch {
125
+ // Línea malformada — ignorar y continuar
126
+ }
127
+ }
128
+ return entries;
129
+ }
130
+ // ─── Detección automática de plan ────────────────────────────────────────────
131
+ /**
132
+ * Infiere el plan mirando el máximo de prompts humanos en cualquier ciclo de 5h.
133
+ * Si en algún ciclo hubo >200 prompts → Max20. Si >40 → Max5. Si ≤40 → Pro.
134
+ *
135
+ * Es conservador: si nunca se ha llegado al límite, asume Pro.
136
+ */
137
+ function detectPlan(entries) {
138
+ const countsByCycle = new Map();
139
+ for (const e of entries) {
140
+ if (e.type !== 'human')
141
+ continue;
142
+ const cycle = Math.floor(e.ts / CYCLE_MS) * CYCLE_MS;
143
+ countsByCycle.set(cycle, (countsByCycle.get(cycle) ?? 0) + 1);
144
+ }
145
+ const maxSeen = countsByCycle.size > 0 ? Math.max(...countsByCycle.values()) : 0;
146
+ if (maxSeen > 200)
147
+ return 'max20';
148
+ if (maxSeen > 40)
149
+ return 'max5';
150
+ return 'pro';
151
+ }
152
+ // ─── Lector de todos los JSONL ────────────────────────────────────────────────
153
+ const PROJECTS_DIR = path_1.default.join((0, paths_1.getClaudeDir)(), 'projects');
154
+ function readAllEntries(sinceTs) {
155
+ const all = [];
156
+ try {
157
+ if (!fs_1.default.existsSync(PROJECTS_DIR))
158
+ return all;
159
+ for (const dir of fs_1.default.readdirSync(PROJECTS_DIR)) {
160
+ const dirPath = path_1.default.join(PROJECTS_DIR, dir);
161
+ try {
162
+ if (!fs_1.default.statSync(dirPath).isDirectory())
163
+ continue;
164
+ // Recopilar todos los subdirectorios a leer: el propio dir + cualquier <uuid>/subagents/
165
+ const subdirs = [dirPath];
166
+ try {
167
+ for (const entry of fs_1.default.readdirSync(dirPath)) {
168
+ const entryPath = path_1.default.join(dirPath, entry);
169
+ if (fs_1.default.statSync(entryPath).isDirectory()) {
170
+ const subagentsPath = path_1.default.join(entryPath, 'subagents');
171
+ if (fs_1.default.existsSync(subagentsPath))
172
+ subdirs.push(subagentsPath);
173
+ }
174
+ }
175
+ }
176
+ catch { /* ignorar */ }
177
+ for (const subdir of subdirs) {
178
+ try {
179
+ if (!fs_1.default.existsSync(subdir))
180
+ continue;
181
+ // Recopilar archivos con su mtime para ordenar por recencia y limitar
182
+ const candidates = [];
183
+ for (const file of fs_1.default.readdirSync(subdir)) {
184
+ if (!file.endsWith('.jsonl'))
185
+ continue;
186
+ if (!file.includes('-') || file.length < 15)
187
+ continue;
188
+ try {
189
+ const mtime = fs_1.default.statSync(path_1.default.join(subdir, file)).mtimeMs;
190
+ if (mtime < sinceTs - WEEK_MS)
191
+ continue;
192
+ candidates.push({ path: path_1.default.join(subdir, file), mtime });
193
+ }
194
+ catch {
195
+ continue;
196
+ }
197
+ }
198
+ // Procesar solo los 300 más recientes — evita bloquear el event loop
199
+ candidates
200
+ .sort((a, b) => b.mtime - a.mtime)
201
+ .slice(0, 300)
202
+ .forEach(c => all.push(...parseJSONLFile(c.path)));
203
+ }
204
+ catch { /* subdir inaccesible */ }
205
+ }
206
+ }
207
+ catch { /* directorio inaccesible */ }
208
+ }
209
+ }
210
+ catch { /* PROJECTS_DIR inaccesible */ }
211
+ return all;
212
+ }
213
+ // ─── Caché de 30 segundos ─────────────────────────────────────────────────────
214
+ let cache = null;
215
+ const CACHE_TTL = 30000; // 30 segundos
216
+ // ─── Función principal ────────────────────────────────────────────────────────
217
+ /**
218
+ * Calcula y retorna QuotaData.
219
+ * Usa caché de 30s para no re-leer todos los JSONL en cada request del dashboard.
220
+ * Pasar `forcePlan` para fijar el plan manualmente (por config del usuario).
221
+ */
222
+ function computeQuota(forcePlan) {
223
+ const now = Date.now();
224
+ // Devolver caché si está fresco y no se fuerza plan
225
+ if (!forcePlan && cache && now - cache.ts < CACHE_TTL) {
226
+ return cache.data;
227
+ }
228
+ const weekStart = getWeekStart(now);
229
+ const thirtyMinAgo = now - 30 * 60 * 1000;
230
+ // Leer entradas relevantes (última semana + un poco más para detección de plan)
231
+ const entries = readAllEntries(weekStart - CYCLE_MS);
232
+ // ─ Plan (prioridad: config manual → keychain → inferencia JSONL) ─
233
+ let plan;
234
+ let planSource;
235
+ if (forcePlan) {
236
+ plan = forcePlan;
237
+ planSource = 'config';
238
+ }
239
+ else {
240
+ // Intentar leer plan desde las credenciales del keychain (más fiable que inferir)
241
+ const auth = (0, claude_auth_1.readClaudeAuth)();
242
+ if (auth.source !== 'unknown' && auth.subscriptionType !== 'unknown') {
243
+ plan = (0, claude_auth_1.subscriptionTypeToPlan)(auth.subscriptionType, auth.rateLimitTier);
244
+ planSource = 'keychain';
245
+ }
246
+ else {
247
+ plan = detectPlan(entries);
248
+ planSource = 'inferred';
249
+ }
250
+ }
251
+ const limits = PLAN_LIMITS[plan];
252
+ // ─ Ciclo 5h: ventana deslizante [now-5h, now] ─
253
+ const fiveHAgo = now - CYCLE_MS;
254
+ const cycleResetAt = computeResetAt(entries, now);
255
+ const cycleStart = fiveHAgo; // inicio real de la ventana de conteo
256
+ const cyclePrompts = entries.filter(e => e.type === 'human' && e.ts >= fiveHAgo).length;
257
+ const cyclePct = Math.min(100, Math.round(cyclePrompts / limits.prompts5h * 100));
258
+ const cycleResetMs = Math.max(0, cycleResetAt - now);
259
+ // ─ Semanal por modelo: ventanas de 5 min con actividad ─
260
+ // Contamos ventanas de 5 min distintas con al menos 1 respuesta por modelo
261
+ const sonnetWindows = new Set();
262
+ const opusWindows = new Set();
263
+ const haikuWindows = new Set();
264
+ let weeklyTokensSonnet = 0;
265
+ let weeklyTokensOpus = 0;
266
+ let weeklyTokensHaiku = 0;
267
+ for (const e of entries) {
268
+ if (e.type !== 'assistant' || e.ts < weekStart)
269
+ continue;
270
+ const win = Math.floor(e.ts / WINDOW_5MIN) * WINDOW_5MIN;
271
+ const tokens = (e.inputTokens ?? 0) + (e.outputTokens ?? 0);
272
+ if (e.model?.includes('opus')) {
273
+ opusWindows.add(win);
274
+ weeklyTokensOpus += tokens;
275
+ }
276
+ else if (e.model?.includes('haiku')) {
277
+ haikuWindows.add(win);
278
+ weeklyTokensHaiku += tokens;
279
+ }
280
+ else {
281
+ sonnetWindows.add(win);
282
+ weeklyTokensSonnet += tokens;
283
+ }
284
+ }
285
+ const weeklyHoursSonnet = parseFloat((sonnetWindows.size * 5 / 60).toFixed(1));
286
+ const weeklyHoursOpus = parseFloat((opusWindows.size * 5 / 60).toFixed(1));
287
+ const weeklyHoursHaiku = parseFloat((haikuWindows.size * 5 / 60).toFixed(1));
288
+ // ─ Burn rate: tokens/min en los últimos 30 min ─
289
+ const recentAssistant = entries.filter(e => e.type === 'assistant' && e.ts >= thirtyMinAgo);
290
+ const totalRecentTok = recentAssistant.reduce((sum, e) => sum + (e.inputTokens ?? 0) + (e.outputTokens ?? 0), 0);
291
+ const burnRateTokensPerMin = recentAssistant.length > 0
292
+ ? Math.round(totalRecentTok / 30)
293
+ : 0;
294
+ const data = {
295
+ cyclePrompts,
296
+ cycleLimit: limits.prompts5h,
297
+ cyclePct,
298
+ cycleResetMs,
299
+ cycleResetAt,
300
+ cycleStartTs: cycleStart,
301
+ weeklyHoursSonnet,
302
+ weeklyHoursOpus,
303
+ weeklyHoursHaiku,
304
+ weeklyTokensSonnet,
305
+ weeklyTokensOpus,
306
+ weeklyTokensHaiku,
307
+ weeklyLimitSonnet: limits.weeklyHoursSonnet,
308
+ weeklyLimitOpus: limits.weeklyHoursOpus,
309
+ burnRateTokensPerMin,
310
+ detectedPlan: plan,
311
+ planSource,
312
+ computedAt: now,
313
+ };
314
+ cache = { data, ts: now };
315
+ return data;
316
+ }
317
+ /** Invalida la caché (llamar cuando llega un nuevo evento para que el siguiente /quota sea fresco) */
318
+ function invalidateQuotaCache() {
319
+ cache = null;
320
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * render.ts — Renderizado del trace tree en terminal (Phase 2 visual upgrade)
3
+ *
4
+ * Mejoras sobre Phase 1:
5
+ * - Agrupa tool calls por bloque de respuesta (entre Stop events)
6
+ * - Detecta modo por bloque: Claude directo / Con agentes / Con skills
7
+ * - Barra visual de contexto (cuánto % del contexto está en uso)
8
+ * - Barra visual de eficiencia
9
+ * - Tokens en formato legible (K / M)
10
+ * - Badge de loop por línea de tool repetido
11
+ * - Coste y stats por bloque individual
12
+ */
13
+ export interface TraceEvent {
14
+ type: string;
15
+ tool_name?: string;
16
+ tool_input?: string;
17
+ ts: number;
18
+ duration_ms?: number;
19
+ session_id?: string;
20
+ cwd?: string;
21
+ }
22
+ export interface LoopAlert {
23
+ toolName: string;
24
+ count: number;
25
+ ts: number;
26
+ }
27
+ export interface CostInfo {
28
+ cost_usd: number;
29
+ input_tokens: number;
30
+ output_tokens: number;
31
+ cache_read: number;
32
+ cache_creation: number;
33
+ efficiency_score: number;
34
+ loops: LoopAlert[];
35
+ summary?: string;
36
+ context_used?: number;
37
+ context_window?: number;
38
+ }
39
+ export interface WeeklyStats {
40
+ totalTokens: number;
41
+ byDay: {
42
+ date: string;
43
+ tokens: number;
44
+ }[];
45
+ lastUpdated: string | null;
46
+ }
47
+ export interface RenderState {
48
+ sessionId: string;
49
+ cwd: string;
50
+ startedAt: number;
51
+ events: TraceEvent[];
52
+ cost?: CostInfo;
53
+ weekly?: WeeklyStats;
54
+ }
55
+ export declare function renderTrace(state: RenderState): string;
package/dist/render.js ADDED
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ /**
3
+ * render.ts — Renderizado del trace tree en terminal (Phase 2 visual upgrade)
4
+ *
5
+ * Mejoras sobre Phase 1:
6
+ * - Agrupa tool calls por bloque de respuesta (entre Stop events)
7
+ * - Detecta modo por bloque: Claude directo / Con agentes / Con skills
8
+ * - Barra visual de contexto (cuánto % del contexto está en uso)
9
+ * - Barra visual de eficiencia
10
+ * - Tokens en formato legible (K / M)
11
+ * - Badge de loop por línea de tool repetido
12
+ * - Coste y stats por bloque individual
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.renderTrace = renderTrace;
16
+ const TOOL_ICONS = {
17
+ Read: '📖', Write: '✏️', Edit: '✏️', Bash: '🖥️',
18
+ Glob: '🔍', Grep: '🔎', WebSearch: '🌐', WebFetch: '🌐',
19
+ Agent: '🤖', Skill: '⚡', TodoWrite: '📝', TodoRead: '📝',
20
+ Task: '📋', default: '🔧'
21
+ };
22
+ const C = {
23
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
24
+ green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m',
25
+ cyan: '\x1b[36m', red: '\x1b[31m', gray: '\x1b[90m',
26
+ bgRed: '\x1b[41m',
27
+ };
28
+ // ─── Helpers de formato ───────────────────────────────────────────────────────
29
+ function relTs(base, ts) {
30
+ const diff = ts - base;
31
+ const s = Math.floor(diff / 1000);
32
+ const ms = diff % 1000;
33
+ return `${String(s).padStart(2, '0')}:${String(ms).padStart(3, '0')}`;
34
+ }
35
+ function fmtMs(ms) {
36
+ if (!ms)
37
+ return '';
38
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
39
+ }
40
+ // Formatea tokens en K o M para legibilidad
41
+ function fmtTok(n) {
42
+ if (n >= 1000000)
43
+ return `${(n / 1000000).toFixed(1)}M`;
44
+ if (n >= 1000)
45
+ return `${(n / 1000).toFixed(1)}K`;
46
+ return String(n);
47
+ }
48
+ function trunc(s, n = 48) {
49
+ return s.length > n ? s.slice(0, n - 3) + '...' : s;
50
+ }
51
+ function detail(toolName, rawInput) {
52
+ if (!toolName || !rawInput)
53
+ return '';
54
+ try {
55
+ const inp = JSON.parse(rawInput);
56
+ if (['Read', 'Write', 'Edit'].includes(toolName))
57
+ return trunc(inp.file_path || inp.path || '');
58
+ if (toolName === 'Bash')
59
+ return trunc(inp.command || '');
60
+ if (['Glob', 'Grep'].includes(toolName))
61
+ return trunc(inp.pattern || inp.query || '');
62
+ if (['WebSearch', 'WebFetch'].includes(toolName))
63
+ return trunc(inp.query || inp.url || '');
64
+ if (toolName === 'Agent')
65
+ return trunc((inp.prompt || '').slice(0, 45));
66
+ if (toolName === 'Skill')
67
+ return trunc(inp.skill || inp.name || '');
68
+ }
69
+ catch { }
70
+ return '';
71
+ }
72
+ // Barra de progreso visual: ████████░░░░ 63%
73
+ function progressBar(pct, width = 18, color = C.cyan) {
74
+ const clamped = Math.max(0, Math.min(100, pct));
75
+ const filled = Math.round(clamped / 100 * width);
76
+ const empty = width - filled;
77
+ return `${color}${'█'.repeat(filled)}${C.dim}${'░'.repeat(empty)}${C.reset}`;
78
+ }
79
+ function detectMode(tools) {
80
+ const hasAgent = tools.some(e => e.tool_name === 'Agent');
81
+ const hasSkill = tools.some(e => e.tool_name === 'Skill');
82
+ if (hasAgent && hasSkill)
83
+ return 'agentes+skills';
84
+ if (hasAgent)
85
+ return 'agentes';
86
+ if (hasSkill)
87
+ return 'skills';
88
+ return 'directo';
89
+ }
90
+ function modeLabel(mode) {
91
+ const labels = {
92
+ 'directo': `${C.dim}Claude directo${C.reset}`,
93
+ 'agentes': `${C.yellow}🤖 Con agentes${C.reset}`,
94
+ 'skills': `${C.cyan}⚡ Con skills${C.reset}`,
95
+ 'agentes+skills': `${C.yellow}🤖⚡ Agentes + skills${C.reset}`,
96
+ };
97
+ return labels[mode];
98
+ }
99
+ function groupByResponse(events) {
100
+ const blocks = [];
101
+ let current = { index: 1, tools: [], mode: 'directo' };
102
+ for (const ev of events) {
103
+ if (ev.type === 'SessionStart')
104
+ continue;
105
+ if (ev.type === 'Stop') {
106
+ current.stop = ev;
107
+ current.mode = detectMode(current.tools);
108
+ blocks.push(current);
109
+ current = { index: blocks.length + 1, tools: [], mode: 'directo' };
110
+ }
111
+ else {
112
+ current.tools.push(ev);
113
+ }
114
+ }
115
+ // Bloque en curso (sin Stop todavía — Claude está respondiendo)
116
+ if (current.tools.length > 0) {
117
+ current.mode = detectMode(current.tools);
118
+ blocks.push(current);
119
+ }
120
+ return blocks;
121
+ }
122
+ // ─── Render principal ─────────────────────────────────────────────────────────
123
+ function renderTrace(state) {
124
+ const { sessionId, cwd, startedAt, events, cost } = state;
125
+ const lines = [];
126
+ // ── Header de sesión ──────────────────────────────────────────────────────
127
+ lines.push('');
128
+ lines.push(`${C.bold}● claudestat${C.reset} ` +
129
+ `${C.dim}session:${C.reset} ${C.cyan}${sessionId.slice(0, 8)}${C.reset} ` +
130
+ `${C.dim}dir:${C.reset} ${C.blue}${cwd || '—'}${C.reset}`);
131
+ // ── Barra de contexto ─────────────────────────────────────────────────────
132
+ if (cost?.context_used && cost.context_window) {
133
+ const pct = Math.round(cost.context_used / cost.context_window * 100);
134
+ const barColor = pct > 80 ? C.red : pct > 60 ? C.yellow : C.green;
135
+ const bar = progressBar(pct, 24, barColor);
136
+ const remaining = 100 - pct;
137
+ lines.push(` ${C.dim}auto-compact en:${C.reset} ${bar} ` +
138
+ `${barColor}${remaining}% restante${C.reset} ` +
139
+ `${C.dim}${fmtTok(cost.context_used)} / ${fmtTok(cost.context_window)} tokens usados${C.reset}`);
140
+ }
141
+ else {
142
+ lines.push(` ${C.dim}contexto: calculando...${C.reset}`);
143
+ }
144
+ lines.push(C.dim + '─'.repeat(72) + C.reset);
145
+ // ── Bloques de respuesta ──────────────────────────────────────────────────
146
+ const blocks = groupByResponse(events);
147
+ for (const block of blocks) {
148
+ const isLast = block.index === blocks.length;
149
+ const inProgress = !block.stop && isLast;
150
+ // Contar repeticiones de cada tool en este bloque (para badge de loop)
151
+ const toolCount = new Map();
152
+ for (const t of block.tools) {
153
+ if (t.tool_name)
154
+ toolCount.set(t.tool_name, (toolCount.get(t.tool_name) || 0) + 1);
155
+ }
156
+ const isLooping = (name) => (toolCount.get(name) || 0) >= 3;
157
+ // Cabecera del bloque
158
+ const blockHeader = inProgress
159
+ ? `${C.yellow}⟳ Respuesta #${block.index}${C.reset}`
160
+ : `${C.dim}Respuesta #${block.index}${C.reset}`;
161
+ lines.push(` ${blockHeader} ${modeLabel(block.mode)}`);
162
+ // Tool calls del bloque
163
+ for (const ev of block.tools) {
164
+ const ts = `${C.gray}[${relTs(startedAt, ev.ts)}]${C.reset}`;
165
+ const det = detail(ev.tool_name, ev.tool_input);
166
+ const ico = TOOL_ICONS[ev.tool_name || ''] || TOOL_ICONS.default;
167
+ const loopBadge = ev.tool_name && isLooping(ev.tool_name)
168
+ ? ` ${C.red}⚠ loop${C.reset}` : '';
169
+ if (ev.type === 'PreToolUse') {
170
+ lines.push(` ${ts} ${ico} ${C.yellow}${ev.tool_name}${C.reset}` +
171
+ (det ? ` ${C.dim}${det}${C.reset}` : '') +
172
+ ` ${C.dim}⟳${C.reset}${loopBadge}`);
173
+ }
174
+ else if (ev.type === 'Done') {
175
+ lines.push(` ${ts} ${ico} ${C.green}${ev.tool_name}${C.reset}` +
176
+ (det ? ` ${C.dim}${det}${C.reset}` : '') +
177
+ (ev.duration_ms ? ` ${C.dim}(${fmtMs(ev.duration_ms)})${C.reset}` : '') +
178
+ loopBadge);
179
+ }
180
+ }
181
+ // Pie del bloque (solo si terminó)
182
+ if (block.stop) {
183
+ const toolsDone = block.tools.filter(e => e.type === 'Done').length;
184
+ const elapsed = fmtMs(block.stop.ts - (block.tools[0]?.ts ?? block.stop.ts));
185
+ lines.push(` ${C.dim}└─ ✅ ${toolsDone} tools · ${elapsed}${C.reset}`);
186
+ }
187
+ lines.push('');
188
+ }
189
+ // ── Footer global ─────────────────────────────────────────────────────────
190
+ lines.push(C.dim + '─'.repeat(72) + C.reset);
191
+ if (cost && cost.cost_usd > 0) {
192
+ // Alertas de loops
193
+ if (cost.loops?.length) {
194
+ for (const loop of cost.loops) {
195
+ lines.push(` ${C.red}⚠ Loop: ${loop.toolName} x${loop.count} en 60s${C.reset}`);
196
+ }
197
+ }
198
+ // Barra de eficiencia — si score es 0 y cost es muy bajo, aún no calculó
199
+ const score = (cost.efficiency_score === 0 && cost.cost_usd < 0.001) ? 100 : cost.efficiency_score;
200
+ const scoreColor = score >= 90 ? C.green : score >= 70 ? C.yellow : C.red;
201
+ const scoreBar = progressBar(score, 14, scoreColor);
202
+ // Tokens
203
+ const tokenLine = `${C.dim}↑${C.reset}${fmtTok(cost.input_tokens)} ` +
204
+ `${C.dim}↓${C.reset}${fmtTok(cost.output_tokens)} ` +
205
+ `${C.dim}🗄${C.reset}${fmtTok(cost.cache_read)}`;
206
+ lines.push(` ${C.bold}💰 $${cost.cost_usd.toFixed(4)}${C.reset} ` +
207
+ `${tokenLine} ` +
208
+ `eficiencia: ${scoreBar} ${scoreColor}${score}/100${C.reset}`);
209
+ }
210
+ else {
211
+ const totalDone = events.filter(e => e.type === 'Done').length;
212
+ const elapsed = fmtMs((events.at(-1)?.ts ?? startedAt) - startedAt);
213
+ lines.push(` ${C.dim}⏱ ${elapsed} ✅ ${totalDone} tools 💰 calculando...${C.reset}`);
214
+ }
215
+ // ── Barra semanal (stats-cache.json) ──────────────────────────────────────
216
+ if (state.weekly && state.weekly.totalTokens > 0) {
217
+ const { totalTokens, byDay, lastUpdated } = state.weekly;
218
+ // Mini sparkline: un char por día de la semana
219
+ const maxDay = Math.max(...byDay.map(d => d.tokens), 1);
220
+ const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
221
+ const spark = byDay.map(d => BARS[Math.min(7, Math.floor(d.tokens / maxDay * 7))]).join('');
222
+ const padded = spark.padStart(7, '▁'); // garantizar 7 chars (1 por día)
223
+ lines.push(` ${C.dim}semanal:${C.reset} ${C.cyan}${padded}${C.reset} ` +
224
+ `${C.bold}${fmtTok(totalTokens)} tokens${C.reset} ` +
225
+ `${C.dim}(últimos 7 días${lastUpdated ? ' · datos al ' + lastUpdated : ''})${C.reset}`);
226
+ }
227
+ lines.push('');
228
+ return lines.join('\n');
229
+ }
@@ -0,0 +1,18 @@
1
+ import { processLatestForSession, type CostUpdateCallback, type CompactDetectedCallback } from '../enricher';
2
+ export declare const eventsRouter: import("express-serve-static-core").Router;
3
+ export declare const lastAgentByCwd: Map<string, {
4
+ pre_ts: number;
5
+ session_id: string;
6
+ }>;
7
+ export declare const taggedSessionParents: Set<string>;
8
+ /** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
9
+ export declare function findProjectCwdForFile(filePath: string): string | undefined;
10
+ /**
11
+ * Cuando el enricher detecta nuevos tokens en un JSONL:
12
+ * 1. Corre el análisis de inteligencia
13
+ * 2. Guarda el coste + score en DB
14
+ * 3. Hace broadcast vía SSE para que el watch muestre el coste actualizado
15
+ */
16
+ export declare const onCostUpdate: CostUpdateCallback;
17
+ export declare const onCompactDetected: CompactDetectedCallback;
18
+ export { processLatestForSession };