@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.
- package/README.md +437 -0
- package/dashboard/dist/assets/AnalyticsView-BApcOGsD.js +8 -0
- package/dashboard/dist/assets/HistoryView-B331k5oL.js +1 -0
- package/dashboard/dist/assets/ProjectsView-DUleaXsP.js +6 -0
- package/dashboard/dist/assets/SystemView-BGe__vl1.js +1 -0
- package/dashboard/dist/assets/TopView-CXggyydU.js +1 -0
- package/dashboard/dist/assets/index-CB01c5lb.js +84 -0
- package/dashboard/dist/assets/vendor-lucide-Cym0q5l_.js +344 -0
- package/dashboard/dist/assets/vendor-react-B_Jzs0gY.js +24 -0
- package/dashboard/dist/index.html +21 -0
- package/dist/cache/projects-cache.d.ts +9 -0
- package/dist/cache/projects-cache.js +51 -0
- package/dist/claude-auth.d.ts +38 -0
- package/dist/claude-auth.js +133 -0
- package/dist/claude-stats.d.ts +32 -0
- package/dist/claude-stats.js +98 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +110 -0
- package/dist/daemon.d.ts +15 -0
- package/dist/daemon.js +247 -0
- package/dist/db.d.ts +134 -0
- package/dist/db.js +546 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +191 -0
- package/dist/enricher.d.ts +34 -0
- package/dist/enricher.js +394 -0
- package/dist/export.d.ts +8 -0
- package/dist/export.js +82 -0
- package/dist/git.d.ts +22 -0
- package/dist/git.js +57 -0
- package/dist/github.d.ts +27 -0
- package/dist/github.js +62 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +319 -0
- package/dist/install.d.ts +14 -0
- package/dist/install.js +202 -0
- package/dist/intelligence.d.ts +45 -0
- package/dist/intelligence.js +105 -0
- package/dist/meta-stats.d.ts +28 -0
- package/dist/meta-stats.js +137 -0
- package/dist/middleware/rate-limiter.d.ts +2 -0
- package/dist/middleware/rate-limiter.js +30 -0
- package/dist/notifier.d.ts +1 -0
- package/dist/notifier.js +22 -0
- package/dist/paths.d.ts +79 -0
- package/dist/paths.js +134 -0
- package/dist/pattern-analyzer.d.ts +35 -0
- package/dist/pattern-analyzer.js +123 -0
- package/dist/project-scanner.d.ts +71 -0
- package/dist/project-scanner.js +619 -0
- package/dist/quota-tracker.d.ts +45 -0
- package/dist/quota-tracker.js +320 -0
- package/dist/render.d.ts +55 -0
- package/dist/render.js +229 -0
- package/dist/routes/events.d.ts +18 -0
- package/dist/routes/events.js +272 -0
- package/dist/routes/history.d.ts +1 -0
- package/dist/routes/history.js +65 -0
- package/dist/routes/misc.d.ts +1 -0
- package/dist/routes/misc.js +280 -0
- package/dist/routes/projects.d.ts +15 -0
- package/dist/routes/projects.js +153 -0
- package/dist/routes/reports.d.ts +11 -0
- package/dist/routes/reports.js +205 -0
- package/dist/routes/stream.d.ts +8 -0
- package/dist/routes/stream.js +70 -0
- package/dist/routes/top.d.ts +1 -0
- package/dist/routes/top.js +30 -0
- package/dist/session-state.d.ts +35 -0
- package/dist/session-state.js +50 -0
- package/dist/summarizer.d.ts +18 -0
- package/dist/summarizer.js +137 -0
- package/dist/watch.d.ts +8 -0
- package/dist/watch.js +157 -0
- package/dist/watchdog.d.ts +11 -0
- package/dist/watchdog.js +75 -0
- package/dist/weekly.d.ts +13 -0
- package/dist/weekly.js +39 -0
- package/hooks/event.js +80 -0
- package/package.json +78 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── GET /projects — listado de proyectos con stats ──────────────────────────
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.projectsRouter = void 0;
|
|
8
|
+
exports.findProjectCwdForFile = findProjectCwdForFile;
|
|
9
|
+
exports.inferProjectCwd = inferProjectCwd;
|
|
10
|
+
exports.inferActiveProjectByMajority = inferActiveProjectByMajority;
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const express_1 = require("express");
|
|
14
|
+
const db_1 = require("../db");
|
|
15
|
+
const projects_cache_1 = require("../cache/projects-cache");
|
|
16
|
+
const pattern_analyzer_1 = require("../pattern-analyzer");
|
|
17
|
+
exports.projectsRouter = (0, express_1.Router)();
|
|
18
|
+
/** Sube el árbol desde un file_path hasta encontrar HANDOFF.md → directorio del proyecto */
|
|
19
|
+
function findProjectCwdForFile(filePath) {
|
|
20
|
+
let dir = path_1.default.dirname(filePath);
|
|
21
|
+
for (let i = 0; i < 6; i++) {
|
|
22
|
+
if (fs_1.default.existsSync(path_1.default.join(dir, 'HANDOFF.md')))
|
|
23
|
+
return dir;
|
|
24
|
+
const parent = path_1.default.dirname(dir);
|
|
25
|
+
if (parent === dir)
|
|
26
|
+
break;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Infiere el proyecto activo mirando los eventos de archivo de una sesión */
|
|
32
|
+
function inferProjectCwd(events) {
|
|
33
|
+
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
|
34
|
+
for (const ev of [...events].reverse()) {
|
|
35
|
+
if (!FILE_TOOLS.has(ev.tool_name || ''))
|
|
36
|
+
continue;
|
|
37
|
+
if (!ev.tool_input)
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const inp = JSON.parse(ev.tool_input);
|
|
41
|
+
const filePath = (inp.file_path || inp.path);
|
|
42
|
+
if (!filePath?.startsWith('/'))
|
|
43
|
+
continue;
|
|
44
|
+
const cwd = findProjectCwdForFile(filePath);
|
|
45
|
+
if (cwd)
|
|
46
|
+
return cwd;
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignorar */ }
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Determina el proyecto activo por mayoría de operaciones de archivo
|
|
54
|
+
* en una ventana de tiempo reciente. Evita que un único archivo tocado
|
|
55
|
+
* de otro proyecto cambie el badge.
|
|
56
|
+
*
|
|
57
|
+
* - Mínimo 2 hits en la ventana para declarar un proyecto como activo.
|
|
58
|
+
* - Si hay empate, gana el que tuvo actividad más reciente.
|
|
59
|
+
*/
|
|
60
|
+
function inferActiveProjectByMajority(events, windowMs) {
|
|
61
|
+
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
|
62
|
+
const cutoff = Date.now() - windowMs;
|
|
63
|
+
const hits = new Map();
|
|
64
|
+
for (const ev of events) {
|
|
65
|
+
if ((ev.ts ?? 0) < cutoff)
|
|
66
|
+
continue;
|
|
67
|
+
if (!FILE_TOOLS.has(ev.tool_name || ''))
|
|
68
|
+
continue;
|
|
69
|
+
if (!ev.tool_input)
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const inp = JSON.parse(ev.tool_input);
|
|
73
|
+
const filePath = (inp.file_path || inp.path);
|
|
74
|
+
if (!filePath?.startsWith('/'))
|
|
75
|
+
continue;
|
|
76
|
+
const project = findProjectCwdForFile(filePath);
|
|
77
|
+
if (!project)
|
|
78
|
+
continue;
|
|
79
|
+
const entry = hits.get(project) ?? { count: 0, lastTs: 0 };
|
|
80
|
+
hits.set(project, { count: entry.count + 1, lastTs: Math.max(entry.lastTs, ev.ts ?? 0) });
|
|
81
|
+
}
|
|
82
|
+
catch { /* ignorar */ }
|
|
83
|
+
}
|
|
84
|
+
if (hits.size === 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
// Ordenar por hits desc, luego por timestamp desc en caso de empate
|
|
87
|
+
const sorted = [...hits.entries()].sort(([, a], [, b]) => b.count !== a.count ? b.count - a.count : b.lastTs - a.lastTs);
|
|
88
|
+
const [topProject, topStats] = sorted[0];
|
|
89
|
+
return topStats.count >= 2 ? topProject : undefined;
|
|
90
|
+
}
|
|
91
|
+
exports.projectsRouter.get('/projects', (_req, res) => {
|
|
92
|
+
// Proyectos del DB (ya etiquetados)
|
|
93
|
+
const dbAggregates = db_1.dbOps.getProjectAggregates();
|
|
94
|
+
// Proyectos descubiertos del filesystem (cacheados — pre-computados al arrancar)
|
|
95
|
+
const scanned = (0, projects_cache_1.getProjectsCached)();
|
|
96
|
+
// Obtener proyecto activo — mayoría en ventana de 10 min, luego fallbacks
|
|
97
|
+
const latestSession = db_1.dbOps.getLatestSession();
|
|
98
|
+
const latestEvents = latestSession ? db_1.dbOps.getSessionEvents(latestSession.id) : [];
|
|
99
|
+
const activeProject = inferActiveProjectByMajority(latestEvents, 10 * 60000)
|
|
100
|
+
?? latestSession?.project_path
|
|
101
|
+
?? inferProjectCwd(latestEvents)
|
|
102
|
+
?? null;
|
|
103
|
+
// Merge: DB stats + filesystem scan
|
|
104
|
+
const projectMap = new Map();
|
|
105
|
+
for (const agg of dbAggregates) {
|
|
106
|
+
projectMap.set(agg.project_path, {
|
|
107
|
+
path: agg.project_path,
|
|
108
|
+
name: path_1.default.basename(agg.project_path),
|
|
109
|
+
session_count: agg.session_count,
|
|
110
|
+
total_cost_usd: agg.total_cost_usd,
|
|
111
|
+
total_tokens: (agg.total_input_tokens ?? 0) + (agg.total_output_tokens ?? 0) + (agg.total_cache_read ?? 0),
|
|
112
|
+
last_active: agg.last_active,
|
|
113
|
+
avg_efficiency: agg.avg_efficiency ? Math.round(agg.avg_efficiency) : null,
|
|
114
|
+
progress: { done: 0, total: 0, pct: 0, nextTask: null },
|
|
115
|
+
has_handoff: false,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const scan of scanned) {
|
|
119
|
+
const dbEntry = projectMap.get(scan.path);
|
|
120
|
+
const useJSONL = !dbEntry || dbEntry.session_count === 0;
|
|
121
|
+
const jStats = scan.jsonlStats;
|
|
122
|
+
const base = dbEntry ?? {
|
|
123
|
+
path: scan.path, name: scan.name,
|
|
124
|
+
session_count: 0, total_cost_usd: 0, total_tokens: 0,
|
|
125
|
+
last_active: null, avg_efficiency: null,
|
|
126
|
+
};
|
|
127
|
+
projectMap.set(scan.path, {
|
|
128
|
+
...base,
|
|
129
|
+
// session_count and last_active: max of DB (live) and JSONL (full history before claudestat install)
|
|
130
|
+
session_count: Math.max(jStats.session_count, base.session_count),
|
|
131
|
+
last_active: Math.max(jStats.last_active ?? 0, base.last_active ?? 0) || null,
|
|
132
|
+
// cost and tokens: always from JSONL — covers full history, not just since claudestat install
|
|
133
|
+
total_cost_usd: jStats.total_cost_usd,
|
|
134
|
+
total_tokens: jStats.total_tokens,
|
|
135
|
+
has_handoff: scan.hasHandoff,
|
|
136
|
+
auto_handoff: scan.autoHandoff,
|
|
137
|
+
progress: scan.progress,
|
|
138
|
+
model_usage: jStats.modelUsage,
|
|
139
|
+
jsonl_source: useJSONL,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Attach pattern insights per project (only if DB has enough data)
|
|
143
|
+
const projects = [...projectMap.values()].map(p => {
|
|
144
|
+
const toolCounts = db_1.dbOps.getProjectToolCounts(p.path);
|
|
145
|
+
const sessionStats = db_1.dbOps.getProjectSessionStats(p.path);
|
|
146
|
+
const insights = (sessionStats && sessionStats.session_count >= 2)
|
|
147
|
+
? (0, pattern_analyzer_1.analyzePatterns)(toolCounts, sessionStats)
|
|
148
|
+
: [];
|
|
149
|
+
return { ...p, insights };
|
|
150
|
+
})
|
|
151
|
+
.sort((a, b) => (b.last_active ?? 0) - (a.last_active ?? 0));
|
|
152
|
+
res.json({ projects, active_project: activeProject });
|
|
153
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ClaudestatConfig } from '../config';
|
|
2
|
+
export declare const reportsRouter: import("express-serve-static-core").Router;
|
|
3
|
+
/** Número de semana ISO para lógica quincenal (semanas pares = informe). */
|
|
4
|
+
export declare function getISOWeek(date: Date): number;
|
|
5
|
+
/**
|
|
6
|
+
* Devuelve el label YYYY-MM-DD si ahora es el momento de generar un informe,
|
|
7
|
+
* o null si no corresponde todavía.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getReportDateLabel(now: Date, cfg: ClaudestatConfig): string | null;
|
|
10
|
+
/** Genera el markdown del informe para el período dado. */
|
|
11
|
+
export declare function generateReport(dateLabel: string, cfg: ClaudestatConfig): string;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── Rutas de reportes: /api/weekly-reports, /api/analytics, /api/quota-stats
|
|
3
|
+
// + helpers del scheduler (getReportDateLabel, generateReport, getISOWeek) ──
|
|
4
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
5
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
6
|
+
};
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.reportsRouter = void 0;
|
|
9
|
+
exports.getISOWeek = getISOWeek;
|
|
10
|
+
exports.getReportDateLabel = getReportDateLabel;
|
|
11
|
+
exports.generateReport = generateReport;
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const express_1 = require("express");
|
|
15
|
+
const db_1 = require("../db");
|
|
16
|
+
const config_1 = require("../config");
|
|
17
|
+
const paths_1 = require("../paths");
|
|
18
|
+
exports.reportsRouter = (0, express_1.Router)();
|
|
19
|
+
// ─── POST /api/weekly-reports — guardar reporte generado por weekly-review.sh ─
|
|
20
|
+
exports.reportsRouter.post('/api/weekly-reports', (req, res) => {
|
|
21
|
+
const { date, content } = req.body;
|
|
22
|
+
if (!date || !content) {
|
|
23
|
+
res.status(400).json({ error: 'date y content son requeridos' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
db_1.dbOps.insertWeeklyReport(date, content);
|
|
28
|
+
res.json({ ok: true });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
res.status(500).json({ error: String(e) });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// ─── GET /api/analytics — datos diarios agrupados para la vista Analytics ────
|
|
35
|
+
exports.reportsRouter.get('/api/analytics', (req, res) => {
|
|
36
|
+
const days = Math.min(parseInt(String(req.query.days ?? '30'), 10) || 30, 90);
|
|
37
|
+
const projectDays = Math.min(parseInt(String(req.query.project_days ?? String(days)), 10) || days, 90);
|
|
38
|
+
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
39
|
+
const projectSince = Date.now() - projectDays * 24 * 60 * 60 * 1000;
|
|
40
|
+
const daily = db_1.dbOps.getAnalyticsDaily(since);
|
|
41
|
+
const byModel = db_1.dbOps.getAnalyticsByModel(since);
|
|
42
|
+
const projectHours = db_1.dbOps.getProjectHours(projectSince);
|
|
43
|
+
// KPIs (siempre sobre últimos 7d y 30d, independiente del período pedido)
|
|
44
|
+
const now7 = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const now30 = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
46
|
+
const week = daily.filter(d => new Date(d.date + 'T12:00:00').getTime() >= now7);
|
|
47
|
+
const month = daily.filter(d => new Date(d.date + 'T12:00:00').getTime() >= now30);
|
|
48
|
+
const sum = (arr, k) => arr.reduce((a, d) => a + d[k], 0);
|
|
49
|
+
const avg = (arr, k) => arr.length ? sum(arr, k) / arr.length : 0;
|
|
50
|
+
res.json({
|
|
51
|
+
daily,
|
|
52
|
+
by_model: byModel,
|
|
53
|
+
project_hours: projectHours,
|
|
54
|
+
kpis: {
|
|
55
|
+
week_cost: sum(week, 'cost'),
|
|
56
|
+
month_cost: sum(month, 'cost'),
|
|
57
|
+
week_sessions: sum(week, 'sessions'),
|
|
58
|
+
month_sessions: sum(month, 'sessions'),
|
|
59
|
+
week_loops: sum(week, 'loops'),
|
|
60
|
+
avg_efficiency: Math.round(avg(week, 'avg_efficiency')),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
// ─── POST /api/weekly-reports/generate-now — generar informe inmediatamente ───
|
|
65
|
+
exports.reportsRouter.post('/api/weekly-reports/generate-now', (_req, res) => {
|
|
66
|
+
const cfg = (0, config_1.readConfig)();
|
|
67
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
68
|
+
if (db_1.dbOps.getWeeklyReportByDate(dateLabel)) {
|
|
69
|
+
res.json({ skipped: true, date: dateLabel });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const markdown = generateReport(dateLabel, cfg);
|
|
73
|
+
db_1.dbOps.insertWeeklyReport(dateLabel, markdown);
|
|
74
|
+
console.log(`[daemon] Informe generado manualmente: ${dateLabel}`);
|
|
75
|
+
res.json({ ok: true, date: dateLabel });
|
|
76
|
+
});
|
|
77
|
+
// ─── POST /api/weekly-reports/import-local — importar .md desde ~/.claude/reports ─
|
|
78
|
+
exports.reportsRouter.post('/api/weekly-reports/import-local', (_req, res) => {
|
|
79
|
+
const reportsDir = path_1.default.join((0, paths_1.getClaudeDir)(), 'reports');
|
|
80
|
+
if (!fs_1.default.existsSync(reportsDir)) {
|
|
81
|
+
res.json({ imported: 0, skipped: 0 });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const files = fs_1.default.readdirSync(reportsDir).filter(f => /^weekly-\d{4}-\d{2}-\d{2}\.md$/.test(f));
|
|
85
|
+
let imported = 0, skipped = 0;
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const date = file.replace('weekly-', '').replace('.md', ''); // YYYY-MM-DD
|
|
88
|
+
const existing = db_1.dbOps.getWeeklyReportByDate(date);
|
|
89
|
+
if (existing) {
|
|
90
|
+
skipped++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const content = fs_1.default.readFileSync(path_1.default.join(reportsDir, file), 'utf8');
|
|
94
|
+
db_1.dbOps.insertWeeklyReport(date, content);
|
|
95
|
+
imported++;
|
|
96
|
+
}
|
|
97
|
+
res.json({ imported, skipped });
|
|
98
|
+
});
|
|
99
|
+
// ─── GET /api/weekly-reports — lista de reportes (id, date, preview) ──────────
|
|
100
|
+
exports.reportsRouter.get('/api/weekly-reports', (_req, res) => {
|
|
101
|
+
res.json(db_1.dbOps.listWeeklyReports());
|
|
102
|
+
});
|
|
103
|
+
// ─── GET /api/weekly-reports/:date — reporte completo de una fecha ────────────
|
|
104
|
+
exports.reportsRouter.get('/api/weekly-reports/:date', (req, res) => {
|
|
105
|
+
const report = db_1.dbOps.getWeeklyReportByDate(req.params.date);
|
|
106
|
+
if (!report) {
|
|
107
|
+
res.status(404).json({ error: 'not found' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
res.json(report);
|
|
111
|
+
});
|
|
112
|
+
// ─── DELETE /api/weekly-reports/:date — eliminar un reporte ──────────────────
|
|
113
|
+
exports.reportsRouter.delete('/api/weekly-reports/:date', (req, res) => {
|
|
114
|
+
const report = db_1.dbOps.getWeeklyReportByDate(req.params.date);
|
|
115
|
+
if (!report) {
|
|
116
|
+
res.status(404).json({ error: 'not found' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
db_1.dbOps.deleteWeeklyReport(req.params.date);
|
|
120
|
+
res.json({ ok: true });
|
|
121
|
+
});
|
|
122
|
+
// ─── GET /api/quota-stats — P90 de tokens y coste (últimos 30 días) ──────────
|
|
123
|
+
exports.reportsRouter.get('/api/quota-stats', (_req, res) => {
|
|
124
|
+
const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
125
|
+
const rows = db_1.dbOps.getQuotaStats(since);
|
|
126
|
+
if (rows.length === 0) {
|
|
127
|
+
res.json({ p90Tokens: 0, p90Cost: 0, sessionCount: 0 });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const idx = Math.floor(rows.length * 0.9);
|
|
131
|
+
const p90Row = rows[Math.min(idx, rows.length - 1)];
|
|
132
|
+
const sortedByCost = [...rows].sort((a, b) => a.total_cost_usd - b.total_cost_usd);
|
|
133
|
+
const p90CostRow = sortedByCost[Math.min(idx, sortedByCost.length - 1)];
|
|
134
|
+
res.json({ p90Tokens: p90Row.total_tokens, p90Cost: p90CostRow.total_cost_usd, sessionCount: rows.length });
|
|
135
|
+
});
|
|
136
|
+
// ─── Report scheduler helpers (exportados para daemon.ts) ────────────────────
|
|
137
|
+
/** Número de semana ISO para lógica quincenal (semanas pares = informe). */
|
|
138
|
+
function getISOWeek(date) {
|
|
139
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
140
|
+
const day = d.getUTCDay() || 7;
|
|
141
|
+
d.setUTCDate(d.getUTCDate() + 4 - day);
|
|
142
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
143
|
+
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Devuelve el label YYYY-MM-DD si ahora es el momento de generar un informe,
|
|
147
|
+
* o null si no corresponde todavía.
|
|
148
|
+
*/
|
|
149
|
+
function getReportDateLabel(now, cfg) {
|
|
150
|
+
const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
151
|
+
if (hhmm !== cfg.reportTime)
|
|
152
|
+
return null;
|
|
153
|
+
if (now.getDay() !== cfg.reportDay)
|
|
154
|
+
return null;
|
|
155
|
+
if (cfg.reportFrequency === 'biweekly' && getISOWeek(now) % 2 !== 0)
|
|
156
|
+
return null;
|
|
157
|
+
if (cfg.reportFrequency === 'monthly' && now.getDate() > 7)
|
|
158
|
+
return null;
|
|
159
|
+
return now.toISOString().slice(0, 10);
|
|
160
|
+
}
|
|
161
|
+
/** Genera el markdown del informe para el período dado. */
|
|
162
|
+
function generateReport(dateLabel, cfg) {
|
|
163
|
+
const periodDays = cfg.reportFrequency === 'monthly' ? 30 : cfg.reportFrequency === 'biweekly' ? 14 : 7;
|
|
164
|
+
const endMs = Date.now();
|
|
165
|
+
const startMs = endMs - periodDays * 24 * 60 * 60 * 1000;
|
|
166
|
+
const fromDate = new Date(startMs).toISOString().slice(0, 10);
|
|
167
|
+
const sessions = db_1.dbOps.getAllSessions().filter(s => s.started_at >= startMs && s.started_at <= endMs);
|
|
168
|
+
const totalCost = sessions.reduce((a, s) => a + (s.total_cost_usd ?? 0), 0);
|
|
169
|
+
const totalInput = sessions.reduce((a, s) => a + (s.total_input_tokens ?? 0), 0);
|
|
170
|
+
const totalOutput = sessions.reduce((a, s) => a + (s.total_output_tokens ?? 0), 0);
|
|
171
|
+
const totalLoops = sessions.reduce((a, s) => a + (s.loops_detected ?? 0), 0);
|
|
172
|
+
const avgEff = sessions.length > 0
|
|
173
|
+
? Math.round(sessions.reduce((a, s) => a + (s.efficiency_score ?? 100), 0) / sessions.length)
|
|
174
|
+
: 100;
|
|
175
|
+
const byProject = new Map();
|
|
176
|
+
for (const s of sessions) {
|
|
177
|
+
const key = s.project_path ? path_1.default.basename(s.project_path) : 'Sin proyecto';
|
|
178
|
+
const cur = byProject.get(key) ?? { sessions: 0, cost: 0 };
|
|
179
|
+
byProject.set(key, { sessions: cur.sessions + 1, cost: cur.cost + (s.total_cost_usd ?? 0) });
|
|
180
|
+
}
|
|
181
|
+
const topProjects = [...byProject.entries()]
|
|
182
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
183
|
+
.slice(0, 5);
|
|
184
|
+
const periodLabel = cfg.reportFrequency === 'monthly' ? 'mensual' : cfg.reportFrequency === 'biweekly' ? 'quincenal' : 'semanal';
|
|
185
|
+
let md = `# Informe ${periodLabel} — ${dateLabel}\n\n`;
|
|
186
|
+
md += `> Período: ${fromDate} → ${dateLabel}\n\n`;
|
|
187
|
+
md += `## Resumen\n\n`;
|
|
188
|
+
md += `- **Sesiones**: ${sessions.length}\n`;
|
|
189
|
+
md += `- **Costo total**: $${totalCost.toFixed(4)}\n`;
|
|
190
|
+
md += `- **Tokens entrada**: ${(totalInput / 1000000).toFixed(2)}M\n`;
|
|
191
|
+
md += `- **Tokens salida**: ${(totalOutput / 1000000).toFixed(2)}M\n`;
|
|
192
|
+
md += `- **Eficiencia promedio**: ${avgEff}%\n`;
|
|
193
|
+
md += `- **Loops detectados**: ${totalLoops}\n\n`;
|
|
194
|
+
if (topProjects.length > 0) {
|
|
195
|
+
md += `## Proyectos más activos\n\n`;
|
|
196
|
+
for (const [name, stats] of topProjects) {
|
|
197
|
+
md += `- **${name}**: ${stats.sessions} sesión${stats.sessions !== 1 ? 'es' : ''} · $${stats.cost.toFixed(4)}\n`;
|
|
198
|
+
}
|
|
199
|
+
md += '\n';
|
|
200
|
+
}
|
|
201
|
+
if (sessions.length === 0) {
|
|
202
|
+
md += `> Sin actividad en este período.\n`;
|
|
203
|
+
}
|
|
204
|
+
return md;
|
|
205
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const streamRouter: import("express-serve-static-core").Router;
|
|
2
|
+
export declare const sessionLastEvent: Map<string, {
|
|
3
|
+
type: string;
|
|
4
|
+
ts: number;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function broadcast(msg: object): void;
|
|
7
|
+
export declare function getSseClientsSize(): number;
|
|
8
|
+
export declare function setOnCostUpdateRef(cb: (sessionId: string, cost: any) => void): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── GET /stream — SSE para claudestat watch ─────────────────────────────────
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.sessionLastEvent = exports.streamRouter = void 0;
|
|
5
|
+
exports.broadcast = broadcast;
|
|
6
|
+
exports.getSseClientsSize = getSseClientsSize;
|
|
7
|
+
exports.setOnCostUpdateRef = setOnCostUpdateRef;
|
|
8
|
+
const express_1 = require("express");
|
|
9
|
+
const db_1 = require("../db");
|
|
10
|
+
const enricher_1 = require("../enricher");
|
|
11
|
+
const session_state_1 = require("../session-state");
|
|
12
|
+
exports.streamRouter = (0, express_1.Router)();
|
|
13
|
+
const SSE_INIT_EVENT_LIMIT = 200;
|
|
14
|
+
const sseClients = new Map();
|
|
15
|
+
exports.sessionLastEvent = new Map();
|
|
16
|
+
function broadcast(msg) {
|
|
17
|
+
let data;
|
|
18
|
+
try {
|
|
19
|
+
data = `data: ${JSON.stringify(msg)}\n\n`;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const dead = [];
|
|
25
|
+
sseClients.forEach((client, id) => {
|
|
26
|
+
try {
|
|
27
|
+
client.write(data);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
dead.push(id);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
dead.forEach(id => sseClients.delete(id));
|
|
34
|
+
}
|
|
35
|
+
function getSseClientsSize() {
|
|
36
|
+
return sseClients.size;
|
|
37
|
+
}
|
|
38
|
+
let _onCostUpdateRef = null;
|
|
39
|
+
function setOnCostUpdateRef(cb) {
|
|
40
|
+
_onCostUpdateRef = cb;
|
|
41
|
+
}
|
|
42
|
+
exports.streamRouter.get('/stream', (req, res) => {
|
|
43
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
44
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
45
|
+
res.setHeader('Connection', 'keep-alive');
|
|
46
|
+
res.flushHeaders();
|
|
47
|
+
const clientId = Math.random().toString(36).slice(2);
|
|
48
|
+
sseClients.set(clientId, res);
|
|
49
|
+
const latestSession = db_1.dbOps.getLatestSession();
|
|
50
|
+
if (latestSession) {
|
|
51
|
+
const allEvents = db_1.dbOps.getSessionEvents(latestSession.id);
|
|
52
|
+
const events = allEvents.length > SSE_INIT_EVENT_LIMIT
|
|
53
|
+
? allEvents.slice(-SSE_INIT_EVENT_LIMIT)
|
|
54
|
+
: allEvents;
|
|
55
|
+
const lastEvt = exports.sessionLastEvent.get(latestSession.id);
|
|
56
|
+
const state = (0, session_state_1.deriveSessionState)(lastEvt?.type, lastEvt?.ts ?? latestSession.last_event_at ?? latestSession.started_at);
|
|
57
|
+
(0, enricher_1.getAllBlockCostsForSession)(latestSession.id).then(blockCosts => {
|
|
58
|
+
const subAgentSessions = db_1.dbOps.getChildSessions(latestSession.id);
|
|
59
|
+
res.write(`data: ${JSON.stringify({ type: 'init', session: { ...latestSession, state }, events, blockCosts, subAgentSessions })}\n\n`);
|
|
60
|
+
if (_onCostUpdateRef) {
|
|
61
|
+
(0, enricher_1.processLatestForSession)(latestSession.id, _onCostUpdateRef).catch(err => console.error('[stream] Error processing latest session:', err));
|
|
62
|
+
}
|
|
63
|
+
}).catch(err => {
|
|
64
|
+
console.error('[stream] Error loading block costs:', err);
|
|
65
|
+
const subAgentSessions = db_1.dbOps.getChildSessions(latestSession.id);
|
|
66
|
+
res.write(`data: ${JSON.stringify({ type: 'init', session: { ...latestSession, state }, events, blockCosts: [], subAgentSessions })}\n\n`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
req.on('close', () => sseClients.delete(clientId));
|
|
70
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const topRouter: import("express-serve-static-core").Router;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.topRouter = void 0;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const db_1 = require("../db");
|
|
6
|
+
exports.topRouter = (0, express_1.Router)();
|
|
7
|
+
exports.topRouter.get('/api/top', (req, res) => {
|
|
8
|
+
const by = req.query.by ?? 'cost';
|
|
9
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
10
|
+
const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
|
|
11
|
+
if (!['cost', 'count', 'duration'].includes(by)) {
|
|
12
|
+
res.status(400).json({ error: 'Invalid "by" parameter. Use: cost, count, duration' });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const tools = db_1.dbOps.getTopTools(days, by, limit);
|
|
16
|
+
const totalCost = tools.reduce((s, t) => s + t.total_cost_usd, 0);
|
|
17
|
+
const totalCount = tools.filter(t => t.tool_name !== 'Other').reduce((s, t) => s + t.count, 0);
|
|
18
|
+
res.json({
|
|
19
|
+
by,
|
|
20
|
+
days,
|
|
21
|
+
tools: tools.map(t => ({
|
|
22
|
+
tool: t.tool_name,
|
|
23
|
+
count: t.count,
|
|
24
|
+
totalDurationMs: t.total_duration_ms,
|
|
25
|
+
estimatedCostUsd: t.total_cost_usd,
|
|
26
|
+
pctCost: totalCost > 0 ? Math.round(t.total_cost_usd / totalCost * 100) : 0,
|
|
27
|
+
pctCount: totalCount > 0 ? Math.round(t.count / totalCount * 100) : 0,
|
|
28
|
+
})),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-state.ts — State machine para estado de sesión en tiempo real
|
|
3
|
+
*
|
|
4
|
+
* Estados posibles:
|
|
5
|
+
* working — Claude está ejecutando un tool (PreToolUse recibido)
|
|
6
|
+
* waiting_for_input — Claude terminó su turno (Stop recibido) o sesión recién iniciada
|
|
7
|
+
* idle — Sin actividad por > IDLE_THRESHOLD_MS
|
|
8
|
+
*
|
|
9
|
+
* Diseño: módulo puro sin efectos secundarios ni acceso a DB.
|
|
10
|
+
* El estado se DERIVA a demanda desde el último evento y su timestamp.
|
|
11
|
+
* No se persiste en DB — se recalcula en cada broadcast y endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Por qué no persistir:
|
|
14
|
+
* - El estado decae automáticamente a "idle" por tiempo, sin necesidad de evento
|
|
15
|
+
* - Simplifica migraciones y evita estados "atascados" en DB
|
|
16
|
+
*/
|
|
17
|
+
export type SessionState = 'working' | 'waiting_for_input' | 'idle';
|
|
18
|
+
/**
|
|
19
|
+
* Deriva el estado de sesión a partir del último evento y su timestamp.
|
|
20
|
+
*
|
|
21
|
+
* Reglas (en orden de prioridad):
|
|
22
|
+
* 1. Si han pasado > IDLE_THRESHOLD_MS desde el último evento → idle
|
|
23
|
+
* 2. PreToolUse o PostToolUse → working (Claude está ejecutando o procesando resultado)
|
|
24
|
+
* 3. Stop o SessionStart → waiting_for_input
|
|
25
|
+
* 4. Cualquier otro tipo → waiting_for_input (estado conservador)
|
|
26
|
+
*/
|
|
27
|
+
export declare function deriveSessionState(lastEventType: string | undefined, lastEventTs: number, now?: number): SessionState;
|
|
28
|
+
/**
|
|
29
|
+
* Metadatos visuales para cada estado — usados en dashboard y CLI.
|
|
30
|
+
*/
|
|
31
|
+
export declare const STATE_META: Record<SessionState, {
|
|
32
|
+
label: string;
|
|
33
|
+
color: string;
|
|
34
|
+
pulse: boolean;
|
|
35
|
+
}>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* session-state.ts — State machine para estado de sesión en tiempo real
|
|
4
|
+
*
|
|
5
|
+
* Estados posibles:
|
|
6
|
+
* working — Claude está ejecutando un tool (PreToolUse recibido)
|
|
7
|
+
* waiting_for_input — Claude terminó su turno (Stop recibido) o sesión recién iniciada
|
|
8
|
+
* idle — Sin actividad por > IDLE_THRESHOLD_MS
|
|
9
|
+
*
|
|
10
|
+
* Diseño: módulo puro sin efectos secundarios ni acceso a DB.
|
|
11
|
+
* El estado se DERIVA a demanda desde el último evento y su timestamp.
|
|
12
|
+
* No se persiste en DB — se recalcula en cada broadcast y endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Por qué no persistir:
|
|
15
|
+
* - El estado decae automáticamente a "idle" por tiempo, sin necesidad de evento
|
|
16
|
+
* - Simplifica migraciones y evita estados "atascados" en DB
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.STATE_META = void 0;
|
|
20
|
+
exports.deriveSessionState = deriveSessionState;
|
|
21
|
+
// 5 minutos sin actividad → idle automático
|
|
22
|
+
const IDLE_THRESHOLD_MS = 5 * 60 * 1000;
|
|
23
|
+
/**
|
|
24
|
+
* Deriva el estado de sesión a partir del último evento y su timestamp.
|
|
25
|
+
*
|
|
26
|
+
* Reglas (en orden de prioridad):
|
|
27
|
+
* 1. Si han pasado > IDLE_THRESHOLD_MS desde el último evento → idle
|
|
28
|
+
* 2. PreToolUse o PostToolUse → working (Claude está ejecutando o procesando resultado)
|
|
29
|
+
* 3. Stop o SessionStart → waiting_for_input
|
|
30
|
+
* 4. Cualquier otro tipo → waiting_for_input (estado conservador)
|
|
31
|
+
*/
|
|
32
|
+
function deriveSessionState(lastEventType, lastEventTs, now = Date.now()) {
|
|
33
|
+
if (now - lastEventTs > IDLE_THRESHOLD_MS)
|
|
34
|
+
return 'idle';
|
|
35
|
+
switch (lastEventType) {
|
|
36
|
+
case 'PreToolUse': return 'working';
|
|
37
|
+
case 'PostToolUse': return 'working'; // tool terminó, Claude aún procesa la respuesta
|
|
38
|
+
case 'Stop': return 'waiting_for_input';
|
|
39
|
+
case 'SessionStart': return 'waiting_for_input';
|
|
40
|
+
default: return 'waiting_for_input';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Metadatos visuales para cada estado — usados en dashboard y CLI.
|
|
45
|
+
*/
|
|
46
|
+
exports.STATE_META = {
|
|
47
|
+
working: { label: 'working', color: '#3fb950', pulse: true },
|
|
48
|
+
waiting_for_input: { label: 'waiting', color: '#58a6ff', pulse: false },
|
|
49
|
+
idle: { label: 'idle', color: '#7d8590', pulse: false },
|
|
50
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* summarizer.ts — Resumen de sesión con IA (opcional)
|
|
3
|
+
*
|
|
4
|
+
* Solo se activa si ANTHROPIC_API_KEY está disponible en el entorno.
|
|
5
|
+
* Si no hay key → retorna null silenciosamente, sin errores ni warnings.
|
|
6
|
+
*
|
|
7
|
+
* Usa claude-haiku-4-5 (el modelo más económico) para minimizar coste.
|
|
8
|
+
* Un resumen de sesión consume ~200 tokens → ~$0.0002 por resumen.
|
|
9
|
+
*
|
|
10
|
+
* El cliente de Anthropic se carga con dynamic import para que el daemon
|
|
11
|
+
* no falle si @anthropic-ai/sdk no está instalado o la key no existe.
|
|
12
|
+
*/
|
|
13
|
+
import type { EventRow } from './db';
|
|
14
|
+
/**
|
|
15
|
+
* Genera un resumen de 10-15 palabras de lo que hizo Claude en la sesión.
|
|
16
|
+
* Retorna null si no hay API key o falla la llamada.
|
|
17
|
+
*/
|
|
18
|
+
export declare function summarizeSession(events: EventRow[], costUsd: number, projectName?: string): Promise<string | null>;
|