@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,133 @@
1
+ "use strict";
2
+ /**
3
+ * claude-auth.ts — Lee las credenciales OAuth de Claude Code desde el sistema.
4
+ *
5
+ * macOS: las credenciales se guardan en el keychain del sistema con el servicio
6
+ * "Claude Code-credentials". El campo "claudeAiOauth" contiene el token
7
+ * OAuth, su expiración, scopes y el tipo de suscripción del plan.
8
+ *
9
+ * Linux/Windows: Claude Code puede guardar las credenciales en
10
+ * ~/.config/Claude/credentials.json (Electron userData path)
11
+ * o en el keyring del sistema.
12
+ *
13
+ * Propósito: obtener el `subscriptionType` y `rateLimitTier` del plan real del
14
+ * usuario, sin inferirlo del máximo histórico de prompts en los JSONL.
15
+ */
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.readClaudeAuth = readClaudeAuth;
21
+ exports.subscriptionTypeToPlan = subscriptionTypeToPlan;
22
+ const child_process_1 = require("child_process");
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const os_1 = __importDefault(require("os"));
26
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
27
+ /** Caché en memoria de 5 minutos */
28
+ let cache = null;
29
+ const CACHE_TTL = 5 * 60000;
30
+ // ─── Leer desde macOS Keychain ────────────────────────────────────────────────
31
+ function readFromKeychain() {
32
+ try {
33
+ const raw = (0, child_process_1.execSync)(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w`, { stdio: ['pipe', 'pipe', 'pipe'], timeout: parseInt(process.env.CLAUDESTAT_KEYCHAIN_TIMEOUT ?? '3000', 10) }).toString().trim();
34
+ if (!raw)
35
+ return null;
36
+ // El valor puede ser JSON directo o base64
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(Buffer.from(raw, 'base64').toString());
40
+ }
41
+ catch {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ const oa = parsed?.claudeAiOauth;
45
+ if (!oa)
46
+ return null;
47
+ return {
48
+ subscriptionType: (oa.subscriptionType ?? 'unknown').toLowerCase(),
49
+ rateLimitTier: oa.rateLimitTier ?? 'unknown',
50
+ expiresAt: oa.expiresAt ?? 0,
51
+ tokenValid: Date.now() < (oa.expiresAt ?? 0),
52
+ source: 'keychain',
53
+ };
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ // ─── Leer desde archivo (Linux / Electron userData) ──────────────────────────
60
+ function readFromFile() {
61
+ const homeDir = os_1.default.homedir();
62
+ const candidates = [
63
+ // Windows Credential Manager / Electron userData
64
+ path_1.default.join(process.env.APPDATA ?? path_1.default.join(homeDir, 'AppData', 'Roaming'), 'Claude', 'credentials.json'),
65
+ path_1.default.join(process.env.LOCALAPPDATA ?? path_1.default.join(homeDir, 'AppData', 'Local'), 'Claude', 'credentials.json'),
66
+ // Linux Electron userData
67
+ path_1.default.join(homeDir, '.config', 'Claude', 'credentials.json'),
68
+ path_1.default.join(homeDir, '.config', 'Claude', '.credentials.json'),
69
+ // macOS Electron userData fallback
70
+ path_1.default.join(homeDir, 'Library', 'Application Support', 'Claude', 'credentials.json'),
71
+ ];
72
+ for (const p of candidates) {
73
+ try {
74
+ const raw = fs_1.default.readFileSync(p, 'utf8');
75
+ const parsed = JSON.parse(raw);
76
+ const oa = parsed?.claudeAiOauth ?? parsed;
77
+ if (!oa?.subscriptionType)
78
+ continue;
79
+ return {
80
+ subscriptionType: (oa.subscriptionType ?? 'unknown').toLowerCase(),
81
+ rateLimitTier: oa.rateLimitTier ?? 'unknown',
82
+ expiresAt: oa.expiresAt ?? 0,
83
+ tokenValid: Date.now() < (oa.expiresAt ?? 0),
84
+ source: 'file',
85
+ };
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ // ─── API pública ──────────────────────────────────────────────────────────────
94
+ /**
95
+ * Lee las credenciales de autenticación de Claude Code.
96
+ * Intenta: keychain (macOS) → archivo → unknown.
97
+ * Resultado cacheado 5 minutos para no golpear el keychain en cada request.
98
+ */
99
+ function readClaudeAuth() {
100
+ const now = Date.now();
101
+ if (cache && now - cache.ts < CACHE_TTL)
102
+ return cache.data;
103
+ const info = (os_1.default.platform() === 'darwin' ? readFromKeychain() : null)
104
+ ?? readFromFile()
105
+ ?? { subscriptionType: 'unknown', rateLimitTier: 'unknown', expiresAt: 0, tokenValid: false, source: 'unknown' };
106
+ cache = { data: info, ts: now };
107
+ return info;
108
+ }
109
+ /**
110
+ * Mapea el subscriptionType de las credenciales al ClaudePlan usado por quota-tracker.
111
+ *
112
+ * Valores conocidos de Claude Code:
113
+ * "free" → Free plan (10 prompts/5h)
114
+ * "pro" → Pro plan (45 prompts/5h)
115
+ * "max" → Max plan — puede ser max5 o max20, diferenciado por rateLimitTier
116
+ * "max_5" → Max 5× (225 prompts/5h)
117
+ * "max_20" → Max 20× (900 prompts/5h)
118
+ */
119
+ function subscriptionTypeToPlan(subscriptionType, rateLimitTier) {
120
+ const sub = subscriptionType.toLowerCase();
121
+ const tier = rateLimitTier.toLowerCase();
122
+ if (sub.includes('max_20') || tier.includes('max_20'))
123
+ return 'max20';
124
+ if (sub.includes('max_5') || tier.includes('max_5'))
125
+ return 'max5';
126
+ if (sub === 'max' || tier.includes('max'))
127
+ return 'max5'; // conservador
128
+ if (sub === 'pro')
129
+ return 'pro';
130
+ if (sub === 'free')
131
+ return 'free';
132
+ return 'pro'; // fallback conservador
133
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * claude-stats.ts — Lee ~/.claude/stats-cache.json
3
+ *
4
+ * Claude Code mantiene este archivo con actividad agregada por día:
5
+ * - messageCount: total de mensajes (human + assistant) — ≈ 2× prompts reales
6
+ * - sessionCount: sesiones abiertas
7
+ * - toolCallCount: llamadas a herramientas
8
+ * - tokensByModel: tokens de OUTPUT por modelo (lo que genera Claude)
9
+ *
10
+ * NOTA: lastComputedDate indica hasta qué fecha están los datos. El día actual
11
+ * puede no aparecer todavía si Claude Code aún no actualizó el cache.
12
+ */
13
+ export interface DayActivity {
14
+ date: string;
15
+ messages: number;
16
+ sessions: number;
17
+ tools: number;
18
+ outputTokens: number;
19
+ }
20
+ export interface ClaudeStatsData {
21
+ today: DayActivity | null;
22
+ yesterday: DayActivity | null;
23
+ last7: DayActivity;
24
+ allTime: {
25
+ sessions: number;
26
+ messages: number;
27
+ };
28
+ cacheDate: string | null;
29
+ todayLabel: string | null;
30
+ cacheIsStale: boolean;
31
+ }
32
+ export declare function readClaudeStats(): ClaudeStatsData;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * claude-stats.ts — Lee ~/.claude/stats-cache.json
4
+ *
5
+ * Claude Code mantiene este archivo con actividad agregada por día:
6
+ * - messageCount: total de mensajes (human + assistant) — ≈ 2× prompts reales
7
+ * - sessionCount: sesiones abiertas
8
+ * - toolCallCount: llamadas a herramientas
9
+ * - tokensByModel: tokens de OUTPUT por modelo (lo que genera Claude)
10
+ *
11
+ * NOTA: lastComputedDate indica hasta qué fecha están los datos. El día actual
12
+ * puede no aparecer todavía si Claude Code aún no actualizó el cache.
13
+ */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.readClaudeStats = readClaudeStats;
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const paths_1 = require("./paths");
22
+ const STATS_PATH = path_1.default.join((0, paths_1.getClaudeDir)(), 'stats-cache.json');
23
+ // ─── Lectura ──────────────────────────────────────────────────────────────────
24
+ function readClaudeStats() {
25
+ const empty = {
26
+ today: null, yesterday: null,
27
+ last7: { date: 'last7', messages: 0, sessions: 0, tools: 0, outputTokens: 0 },
28
+ allTime: { sessions: 0, messages: 0 },
29
+ cacheDate: null, todayLabel: null, cacheIsStale: false,
30
+ };
31
+ try {
32
+ const raw = fs_1.default.readFileSync(STATS_PATH, 'utf8');
33
+ const data = JSON.parse(raw);
34
+ // Fechas de referencia
35
+ const now = new Date();
36
+ const todayStr = now.toISOString().slice(0, 10);
37
+ const yest = new Date(now.getTime() - 86400000);
38
+ const yestStr = yest.toISOString().slice(0, 10);
39
+ const cutoffStr = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10);
40
+ // Índice de tokens por fecha
41
+ const tokensByDate = {};
42
+ for (const entry of (data.dailyModelTokens ?? [])) {
43
+ tokensByDate[entry.date] = entry.tokensByModel ?? {};
44
+ }
45
+ // Suma de tokens por fecha
46
+ function tokensForDate(d) {
47
+ const byModel = tokensByDate[d] ?? {};
48
+ return Object.values(byModel).reduce((a, b) => a + b, 0);
49
+ }
50
+ // Construir DayActivity por fecha
51
+ const actByDate = {};
52
+ for (const a of (data.dailyActivity ?? [])) {
53
+ actByDate[a.date] = {
54
+ date: a.date, messages: a.messageCount ?? 0,
55
+ sessions: a.sessionCount ?? 0, tools: a.toolCallCount ?? 0,
56
+ outputTokens: tokensForDate(a.date),
57
+ };
58
+ }
59
+ // Last 7 days aggregate
60
+ const last7 = { date: 'last7', messages: 0, sessions: 0, tools: 0, outputTokens: 0 };
61
+ for (const [date, act] of Object.entries(actByDate)) {
62
+ if (date >= cutoffStr) {
63
+ last7.messages += act.messages;
64
+ last7.sessions += act.sessions;
65
+ last7.tools += act.tools;
66
+ last7.outputTokens += act.outputTokens;
67
+ }
68
+ }
69
+ // Si hoy/ayer no están en el cache, usar el día más reciente disponible
70
+ const todayData = actByDate[todayStr] ?? null;
71
+ const yesterdayData = actByDate[yestStr] ?? null;
72
+ let mostRecentDay = todayData ?? yesterdayData;
73
+ let mostRecentLabel = todayData ? 'Hoy' : yesterdayData ? 'Ayer' : null;
74
+ if (!mostRecentDay) {
75
+ // Cache antiguo — buscar el día más reciente disponible
76
+ const sortedDates = Object.keys(actByDate).sort().reverse();
77
+ if (sortedDates.length > 0) {
78
+ mostRecentDay = actByDate[sortedDates[0]];
79
+ mostRecentLabel = sortedDates[0]; // ej. "2026-04-16"
80
+ }
81
+ }
82
+ return {
83
+ today: mostRecentDay,
84
+ yesterday: yesterdayData,
85
+ last7,
86
+ allTime: {
87
+ sessions: data.totalSessions ?? 0,
88
+ messages: data.totalMessages ?? 0,
89
+ },
90
+ cacheDate: data.lastComputedDate ?? null,
91
+ todayLabel: mostRecentLabel,
92
+ cacheIsStale: !todayData && !yesterdayData,
93
+ };
94
+ }
95
+ catch {
96
+ return empty;
97
+ }
98
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * config.ts — Configuración de claudestat
3
+ *
4
+ * Lee/escribe ~/.claudestat/config.json con valores por defecto.
5
+ * Los thresholds de warning controlan cuándo se emite una alerta SSE
6
+ * y cuándo se activa el kill switch (hook bloqueante PreToolUse).
7
+ *
8
+ * Estructura del archivo:
9
+ * {
10
+ * "killSwitchEnabled": true, // activar/desactivar el hook bloqueante
11
+ * "killSwitchThreshold": 95, // % de cuota para bloquear (1-100)
12
+ * "warnThresholds": [70, 85, 95],// niveles de aviso: amarillo, naranja, rojo
13
+ * "plan": null, // forzar plan: "pro"|"max5"|"max20"|null (auto)
14
+ * "reportsEnabled": false, // activar/desactivar informes automáticos
15
+ * "reportFrequency": "weekly", // "weekly"|"biweekly"|"monthly"
16
+ * "reportDay": 1, // día de la semana: 0=Dom, 1=Lun … 6=Sáb
17
+ * "reportTime": "09:00" // hora HH:MM en que se genera el informe
18
+ * }
19
+ */
20
+ import type { ClaudePlan } from './quota-tracker';
21
+ export type ReportFrequency = 'weekly' | 'biweekly' | 'monthly';
22
+ export interface ClaudestatConfig {
23
+ killSwitchEnabled: boolean;
24
+ killSwitchThreshold: number;
25
+ warnThresholds: number[];
26
+ plan: ClaudePlan | null;
27
+ reportsEnabled: boolean;
28
+ reportFrequency: ReportFrequency;
29
+ reportDay: number;
30
+ reportTime: string;
31
+ alertsEnabled: boolean;
32
+ }
33
+ /** Lee la config del disco. Valores ausentes se rellenan con defaults. */
34
+ export declare function readConfig(): ClaudestatConfig;
35
+ /** Escribe la config en disco. Crea el directorio si no existe. */
36
+ export declare function writeConfig(cfg: ClaudestatConfig): void;
37
+ /**
38
+ * Valida y sanitiza los campos de una config recibida por la API.
39
+ * Devuelve un string de error si algo es inválido, o null si está bien.
40
+ */
41
+ export declare function validateConfig(raw: unknown): string | null;
42
+ /** Devuelve el nivel de warning para un % dado, o null si no alcanza ningún threshold. */
43
+ export declare function getWarnLevel(pct: number, thresholds: number[]): 'yellow' | 'orange' | 'red' | null;
package/dist/config.js ADDED
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ /**
3
+ * config.ts — Configuración de claudestat
4
+ *
5
+ * Lee/escribe ~/.claudestat/config.json con valores por defecto.
6
+ * Los thresholds de warning controlan cuándo se emite una alerta SSE
7
+ * y cuándo se activa el kill switch (hook bloqueante PreToolUse).
8
+ *
9
+ * Estructura del archivo:
10
+ * {
11
+ * "killSwitchEnabled": true, // activar/desactivar el hook bloqueante
12
+ * "killSwitchThreshold": 95, // % de cuota para bloquear (1-100)
13
+ * "warnThresholds": [70, 85, 95],// niveles de aviso: amarillo, naranja, rojo
14
+ * "plan": null, // forzar plan: "pro"|"max5"|"max20"|null (auto)
15
+ * "reportsEnabled": false, // activar/desactivar informes automáticos
16
+ * "reportFrequency": "weekly", // "weekly"|"biweekly"|"monthly"
17
+ * "reportDay": 1, // día de la semana: 0=Dom, 1=Lun … 6=Sáb
18
+ * "reportTime": "09:00" // hora HH:MM en que se genera el informe
19
+ * }
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.readConfig = readConfig;
26
+ exports.writeConfig = writeConfig;
27
+ exports.validateConfig = validateConfig;
28
+ exports.getWarnLevel = getWarnLevel;
29
+ const fs_1 = __importDefault(require("fs"));
30
+ const path_1 = __importDefault(require("path"));
31
+ const paths_1 = require("./paths");
32
+ const CONFIG_PATH = path_1.default.join((0, paths_1.getClaudestatDir)(), 'config.json');
33
+ const DEFAULTS = {
34
+ killSwitchEnabled: false,
35
+ killSwitchThreshold: 95,
36
+ warnThresholds: [70, 85, 95],
37
+ plan: null,
38
+ reportsEnabled: false,
39
+ reportFrequency: 'weekly',
40
+ reportDay: 1,
41
+ reportTime: '09:00',
42
+ alertsEnabled: true,
43
+ };
44
+ /** Lee la config del disco. Valores ausentes se rellenan con defaults. */
45
+ function readConfig() {
46
+ try {
47
+ const raw = fs_1.default.readFileSync(CONFIG_PATH, 'utf8');
48
+ const parsed = JSON.parse(raw);
49
+ return { ...DEFAULTS, ...parsed };
50
+ }
51
+ catch {
52
+ return { ...DEFAULTS };
53
+ }
54
+ }
55
+ /** Escribe la config en disco. Crea el directorio si no existe. */
56
+ function writeConfig(cfg) {
57
+ fs_1.default.mkdirSync(path_1.default.dirname(CONFIG_PATH), { recursive: true });
58
+ fs_1.default.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
59
+ }
60
+ const VALID_PLANS = new Set(['free', 'pro', 'max5', 'max20', null]);
61
+ /**
62
+ * Valida y sanitiza los campos de una config recibida por la API.
63
+ * Devuelve un string de error si algo es inválido, o null si está bien.
64
+ */
65
+ function validateConfig(raw) {
66
+ if (typeof raw !== 'object' || raw === null)
67
+ return 'Body debe ser un objeto JSON';
68
+ const cfg = raw;
69
+ if ('killSwitchEnabled' in cfg && typeof cfg.killSwitchEnabled !== 'boolean')
70
+ return 'killSwitchEnabled debe ser boolean';
71
+ if ('killSwitchThreshold' in cfg) {
72
+ const v = cfg.killSwitchThreshold;
73
+ if (typeof v !== 'number' || isNaN(v) || v < 1 || v > 100)
74
+ return 'killSwitchThreshold debe ser un número entre 1 y 100';
75
+ }
76
+ if ('warnThresholds' in cfg) {
77
+ const v = cfg.warnThresholds;
78
+ if (!Array.isArray(v) || v.length !== 3 || v.some(n => typeof n !== 'number' || isNaN(n) || n < 1 || n > 100))
79
+ return 'warnThresholds debe ser un array de 3 números entre 1 y 100';
80
+ }
81
+ if ('plan' in cfg && !VALID_PLANS.has(cfg.plan))
82
+ return `plan debe ser uno de: free, pro, max5, max20 o null`;
83
+ if ('alertsEnabled' in cfg && typeof cfg.alertsEnabled !== 'boolean')
84
+ return 'alertsEnabled debe ser boolean';
85
+ if ('reportsEnabled' in cfg && typeof cfg.reportsEnabled !== 'boolean')
86
+ return 'reportsEnabled debe ser boolean';
87
+ if ('reportFrequency' in cfg && !['weekly', 'biweekly', 'monthly'].includes(cfg.reportFrequency))
88
+ return 'reportFrequency debe ser weekly, biweekly o monthly';
89
+ if ('reportDay' in cfg) {
90
+ const v = cfg.reportDay;
91
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0 || v > 6)
92
+ return 'reportDay debe ser un entero entre 0 y 6';
93
+ }
94
+ if ('reportTime' in cfg) {
95
+ if (typeof cfg.reportTime !== 'string' || !/^\d{2}:\d{2}$/.test(cfg.reportTime))
96
+ return 'reportTime debe tener formato HH:MM';
97
+ }
98
+ return null;
99
+ }
100
+ /** Devuelve el nivel de warning para un % dado, o null si no alcanza ningún threshold. */
101
+ function getWarnLevel(pct, thresholds) {
102
+ const sorted = [...thresholds].sort((a, b) => a - b); // [70, 85, 95]
103
+ if (sorted.length >= 3 && pct >= sorted[2])
104
+ return 'red';
105
+ if (sorted.length >= 2 && pct >= sorted[1])
106
+ return 'orange';
107
+ if (sorted.length >= 1 && pct >= sorted[0])
108
+ return 'yellow';
109
+ return null;
110
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * daemon.ts — Servidor HTTP + SSE con inteligencia integrada
3
+ *
4
+ * Phase 2 agrega:
5
+ * - Enriquecimiento de coste desde JSONL (enricher)
6
+ * - Análisis de inteligencia (loops + eficiencia) al recibir cada cost update
7
+ * - Endpoint GET /intelligence/:sessionId
8
+ * - Endpoint GET /sessions para el dashboard futuro
9
+ *
10
+ * Phase 3 agrega:
11
+ * - Sirve el dashboard React desde dashboard/dist
12
+ * - Endpoint GET /meta-stats: KPIs de HANDOFF, Engram, config y alertas
13
+ * - Procesa JSONL al conectar nuevo cliente SSE (contexto inmediato)
14
+ */
15
+ export declare function startDaemon(): void;
package/dist/daemon.js ADDED
@@ -0,0 +1,247 @@
1
+ "use strict";
2
+ /**
3
+ * daemon.ts — Servidor HTTP + SSE con inteligencia integrada
4
+ *
5
+ * Phase 2 agrega:
6
+ * - Enriquecimiento de coste desde JSONL (enricher)
7
+ * - Análisis de inteligencia (loops + eficiencia) al recibir cada cost update
8
+ * - Endpoint GET /intelligence/:sessionId
9
+ * - Endpoint GET /sessions para el dashboard futuro
10
+ *
11
+ * Phase 3 agrega:
12
+ * - Sirve el dashboard React desde dashboard/dist
13
+ * - Endpoint GET /meta-stats: KPIs de HANDOFF, Engram, config y alertas
14
+ * - Procesa JSONL al conectar nuevo cliente SSE (contexto inmediato)
15
+ */
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.startDaemon = startDaemon;
21
+ const express_1 = __importDefault(require("express"));
22
+ const path_1 = __importDefault(require("path"));
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const db_1 = require("./db");
25
+ const enricher_1 = require("./enricher");
26
+ const config_1 = require("./config");
27
+ const quota_tracker_1 = require("./quota-tracker");
28
+ const notifier_1 = require("./notifier");
29
+ const events_1 = require("./routes/events");
30
+ const stream_1 = require("./routes/stream");
31
+ const projects_1 = require("./routes/projects");
32
+ const history_1 = require("./routes/history");
33
+ const misc_1 = require("./routes/misc");
34
+ const reports_1 = require("./routes/reports");
35
+ const top_1 = require("./routes/top");
36
+ const projects_cache_1 = require("./cache/projects-cache");
37
+ const rate_limiter_1 = require("./middleware/rate-limiter");
38
+ const summarizer_1 = require("./summarizer");
39
+ const paths_1 = require("./paths");
40
+ const PORT = 7337;
41
+ const app = (0, express_1.default)();
42
+ app.use(express_1.default.json());
43
+ // ─── Shutdown graceful (cross-platform, no depende de SIGTERM) ────────────────
44
+ let _server = null;
45
+ app.post('/shutdown', (_req, res) => {
46
+ res.json({ ok: true });
47
+ if (_server)
48
+ shutdown(_server);
49
+ process.exit(0);
50
+ });
51
+ // ─── Montar rutas ─────────────────────────────────────────────────────────────
52
+ app.use(events_1.eventsRouter);
53
+ app.use(stream_1.streamRouter);
54
+ app.use(projects_1.projectsRouter);
55
+ app.use(history_1.historyRouter);
56
+ app.use(misc_1.miscRouter);
57
+ app.use(reports_1.reportsRouter);
58
+ app.use(top_1.topRouter);
59
+ // ─── GET /health — necesita acceso al tamaño del pool SSE ─────────────────────
60
+ app.get('/health', (_req, res) => {
61
+ res.json({ status: 'ok', port: PORT, clients: (0, stream_1.getSseClientsSize)() });
62
+ });
63
+ // ─── Dashboard React (servir estáticos del build de Vite) ────────────────────
64
+ const DASHBOARD_DIST = path_1.default.join(__dirname, '..', 'dashboard', 'dist');
65
+ app.use(express_1.default.static(DASHBOARD_DIST, {
66
+ setHeaders(res, filePath) {
67
+ if (filePath.endsWith('.html')) {
68
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
69
+ }
70
+ },
71
+ }));
72
+ // SPA fallback: cualquier ruta no capturada sirve index.html
73
+ app.get('*', (_req, res) => {
74
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
75
+ res.sendFile(path_1.default.join(DASHBOARD_DIST, 'index.html'));
76
+ });
77
+ // ─── Migración de arranque: etiquetar sesiones históricas ────────────────────
78
+ function migrateSessionProjects() {
79
+ const sessions = db_1.dbOps.getAllSessions();
80
+ let tagged = 0;
81
+ for (const session of sessions) {
82
+ if (session.project_path)
83
+ continue;
84
+ const events = db_1.dbOps.getSessionEvents(session.id);
85
+ const projectCwd = (0, projects_1.inferProjectCwd)(events);
86
+ if (projectCwd) {
87
+ db_1.dbOps.updateSessionProject(session.id, projectCwd);
88
+ tagged++;
89
+ }
90
+ }
91
+ if (tagged > 0)
92
+ console.log(`[daemon] ${tagged} sessions tagged with project`);
93
+ }
94
+ /**
95
+ * Genera summaries IA para las últimas N sesiones que no tienen uno.
96
+ * Se ejecuta en background al arrancar el daemon — no bloquea el inicio.
97
+ */
98
+ async function migrateSessionSummaries(limit = 5) {
99
+ const sessions = db_1.dbOps.getAllSessions()
100
+ .filter(s => !s.ai_summary)
101
+ .slice(0, limit);
102
+ for (const s of sessions) {
103
+ try {
104
+ const events = db_1.dbOps.getSessionEvents(s.id);
105
+ const projectName = s.project_path ? path_1.default.basename(s.project_path) : undefined;
106
+ const summary = await (0, summarizer_1.summarizeSession)(events, s.total_cost_usd ?? 0, projectName);
107
+ if (summary) {
108
+ db_1.dbOps.updateSessionSummary(s.id, summary);
109
+ console.log(`[daemon] Summary generado para sesión ${s.id.slice(0, 8)}: "${summary}"`);
110
+ }
111
+ }
112
+ catch (err) {
113
+ console.error('[daemon] Error generating summary:', err);
114
+ }
115
+ }
116
+ }
117
+ // ─── Interval refs for cleanup ────────────────────────────────────────────────
118
+ let projectCacheInterval = null;
119
+ let reportInterval = null;
120
+ let alertInterval = null;
121
+ function shutdown(server) {
122
+ (0, enricher_1.stopEnricher)();
123
+ (0, rate_limiter_1.stopRateLimiter)();
124
+ if (projectCacheInterval) {
125
+ clearInterval(projectCacheInterval);
126
+ projectCacheInterval = null;
127
+ }
128
+ if (reportInterval) {
129
+ clearInterval(reportInterval);
130
+ reportInterval = null;
131
+ }
132
+ if (alertInterval) {
133
+ clearInterval(alertInterval);
134
+ alertInterval = null;
135
+ }
136
+ cleanPid();
137
+ server.close();
138
+ }
139
+ const LEVEL_RANK = { yellow: 1, orange: 2, red: 3 };
140
+ const LEVEL_COLOR = {
141
+ yellow: '\x1b[33m',
142
+ orange: '\x1b[33m',
143
+ red: '\x1b[31m',
144
+ };
145
+ let _lastAlertLevel = null;
146
+ function startAlertPolling() {
147
+ alertInterval = setInterval(() => {
148
+ try {
149
+ const cfg = (0, config_1.readConfig)();
150
+ if (!cfg.alertsEnabled)
151
+ return;
152
+ const data = (0, quota_tracker_1.computeQuota)(cfg.plan ?? undefined);
153
+ const level = (0, config_1.getWarnLevel)(data.cyclePct, cfg.warnThresholds);
154
+ if (!level) {
155
+ _lastAlertLevel = null;
156
+ return;
157
+ }
158
+ const prevRank = _lastAlertLevel ? LEVEL_RANK[_lastAlertLevel] ?? 0 : 0;
159
+ const currRank = LEVEL_RANK[level] ?? 0;
160
+ if (currRank > prevRank) {
161
+ const color = LEVEL_COLOR[level];
162
+ process.stderr.write(`${color}[claudestat] ⚠️ Rate limit alert: ${data.cyclePct}% of quota used (${data.cyclePrompts}/${data.cycleLimit} prompts)\x1b[0m\n`);
163
+ if (data.cyclePct >= cfg.killSwitchThreshold) {
164
+ (0, notifier_1.sendDesktopNotification)('claudestat — Rate limit warning', `${data.cyclePct}% of 5h quota used (${data.cyclePrompts}/${data.cycleLimit} prompts)`);
165
+ }
166
+ _lastAlertLevel = level;
167
+ }
168
+ }
169
+ catch {
170
+ // quota read failed — ignore
171
+ }
172
+ }, 60000);
173
+ }
174
+ // ─── Report scheduler ─────────────────────────────────────────────────────────
175
+ const PROJECTS_CACHE_TTL = 2 * 60000; // 2 minutos
176
+ const PID_FILE = (0, paths_1.getPidFile)();
177
+ function writePid() {
178
+ try {
179
+ const dir = (0, paths_1.getClaudestatDir)();
180
+ fs_1.default.mkdirSync(dir, { recursive: true });
181
+ fs_1.default.writeFileSync(PID_FILE, String(process.pid));
182
+ }
183
+ catch { }
184
+ }
185
+ function cleanPid() {
186
+ try {
187
+ fs_1.default.unlinkSync(PID_FILE);
188
+ }
189
+ catch { }
190
+ }
191
+ function startDaemon() {
192
+ _server = app.listen(PORT, '127.0.0.1', () => {
193
+ writePid();
194
+ process.on('exit', cleanPid);
195
+ process.on('SIGTERM', () => { shutdown(_server); process.exit(0); });
196
+ process.on('SIGINT', () => { shutdown(_server); process.exit(0); });
197
+ console.log(`\n● claudestat daemon → http://localhost:${PORT}`);
198
+ console.log(` Waiting for Claude Code events...\n`);
199
+ console.log(` In another terminal: \x1b[36mclaudestat watch\x1b[0m\n`);
200
+ // Etiquetar sesiones históricas que no tienen proyecto asignado
201
+ migrateSessionProjects();
202
+ // Pre-scan de proyectos al arrancar — garantiza respuesta inmediata en el tab
203
+ // Se ejecuta en background para no retrasar el inicio del servidor
204
+ setImmediate(() => {
205
+ const projects = (0, projects_cache_1.getProjectsCached)();
206
+ console.log(`[daemon] ${projects?.length ?? 0} projects scanned`);
207
+ });
208
+ // Refresh automático del cache de proyectos cada 2 minutos
209
+ // Recoge cambios en HANDOFF.md aunque el daemon lleve horas corriendo
210
+ projectCacheInterval = setInterval(() => {
211
+ (0, projects_cache_1.invalidateProjectsCache)();
212
+ (0, projects_cache_1.getProjectsCached)();
213
+ }, PROJECTS_CACHE_TTL);
214
+ // Iniciar el watcher de JSONL para enriquecimiento de coste
215
+ (0, enricher_1.startEnricher)(events_1.onCostUpdate, events_1.onCompactDetected);
216
+ // Scheduler de informes automáticos — corre cada minuto
217
+ reportInterval = setInterval(() => {
218
+ const cfg = (0, config_1.readConfig)();
219
+ if (!cfg.reportsEnabled)
220
+ return;
221
+ const dateLabel = (0, reports_1.getReportDateLabel)(new Date(), cfg);
222
+ if (!dateLabel)
223
+ return;
224
+ if (db_1.dbOps.getWeeklyReportByDate(dateLabel))
225
+ return; // ya existe
226
+ const markdown = (0, reports_1.generateReport)(dateLabel, cfg);
227
+ db_1.dbOps.insertWeeklyReport(dateLabel, markdown);
228
+ console.log(`[daemon] Report auto-generated: ${dateLabel}`);
229
+ }, 60000);
230
+ // Summaries IA solo si opt-in explícito (CLAUDESTAT_AI_SUMMARY=true)
231
+ if (process.env.CLAUDESTAT_AI_SUMMARY === 'true') {
232
+ migrateSessionSummaries(5).catch(() => { });
233
+ }
234
+ // Polling de alertas de rate limit cada 60s
235
+ startAlertPolling();
236
+ });
237
+ // Manejo de error de puerto ocupado — fuera del callback para capturar EADDRINUSE
238
+ _server.on('error', (err) => {
239
+ if (err.code === 'EADDRINUSE') {
240
+ console.error(`\n❌ Error: Port ${PORT} is already in use.`);
241
+ console.error(` Is claudestat already running? Check with: ${(0, paths_1.portCheckCmd)(PORT)}`);
242
+ console.error(` If so, you don't need to start it again.\n`);
243
+ process.exit(1);
244
+ }
245
+ throw err;
246
+ });
247
+ }