@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,619 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* project-scanner.ts — Descubrimiento de proyectos desde ~/.claude/projects/
|
|
4
|
+
*
|
|
5
|
+
* Escanea los directorios de Claude Code, decodifica sus paths reales,
|
|
6
|
+
* lee los HANDOFF.md y extrae métricas de progreso.
|
|
7
|
+
*/
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.decodeProjectDir = decodeProjectDir;
|
|
13
|
+
exports.findRealPath = findRealPath;
|
|
14
|
+
exports.parseHandoffProgress = parseHandoffProgress;
|
|
15
|
+
exports.getJSONLStats = getJSONLStats;
|
|
16
|
+
exports.autoCreateHandoff = autoCreateHandoff;
|
|
17
|
+
exports.discoverProjects = discoverProjects;
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const os_1 = __importDefault(require("os"));
|
|
21
|
+
const paths_1 = require("./paths");
|
|
22
|
+
// ─── Decode ───────────────────────────────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Decodifica el nombre de directorio de Claude Code al path real.
|
|
25
|
+
* "-Users-db-Documents-GitHub-claudestat" → "/Users/db/Documents/GitHub/claudestat"
|
|
26
|
+
*
|
|
27
|
+
* Problema: directorios con '-' en el nombre (ej: "gmail-ai-agent") se confunden
|
|
28
|
+
* con separadores de path. Solución: búsqueda greedy recursiva por el filesystem.
|
|
29
|
+
*/
|
|
30
|
+
function decodeProjectDir(encodedName) {
|
|
31
|
+
const homeDir = os_1.default.homedir();
|
|
32
|
+
const encodedHome = (0, paths_1.encodeClaudePath)(homeDir);
|
|
33
|
+
if (!encodedName.startsWith(encodedHome))
|
|
34
|
+
return null;
|
|
35
|
+
const rest = encodedName.slice(encodedHome.length); // "-Documents-GitHub-gmail-ai-agent"
|
|
36
|
+
if (!rest || rest === '')
|
|
37
|
+
return null;
|
|
38
|
+
// Búsqueda greedy: prueba cada posición de '-' como posible '/'
|
|
39
|
+
// Esto resuelve ambigüedad con nombres de directorio que contienen '-'
|
|
40
|
+
const found = findRealPath(homeDir, rest.slice(1)); // quitar el '-' inicial
|
|
41
|
+
return found === homeDir ? null : found;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Dado un directorio base y un string con segmentos separados por '-',
|
|
45
|
+
* encuentra el path real en disco probando cada combinación posible.
|
|
46
|
+
* Ejemplo: base="/Users/db/Documents/GitHub", remaining="gmail-ai-agent"
|
|
47
|
+
* → prueba "gmail" (no existe), "gmail-ai" (no existe), "gmail-ai-agent" (✓)
|
|
48
|
+
*/
|
|
49
|
+
function findRealPath(base, remaining, depth = 0) {
|
|
50
|
+
if (!remaining)
|
|
51
|
+
return fs_1.default.existsSync(base) ? base : null;
|
|
52
|
+
if (depth > 10)
|
|
53
|
+
return null;
|
|
54
|
+
const parts = remaining.split('-');
|
|
55
|
+
let segment = '';
|
|
56
|
+
for (let i = 0; i < parts.length; i++) {
|
|
57
|
+
segment = segment ? segment + '-' + parts[i] : parts[i];
|
|
58
|
+
const candidate = path_1.default.join(base, segment);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs_1.default.statSync(candidate).isDirectory())
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const leftover = parts.slice(i + 1).join('-');
|
|
67
|
+
if (!leftover)
|
|
68
|
+
return candidate; // consumido todo → éxito
|
|
69
|
+
const deeper = findRealPath(candidate, leftover, depth + 1);
|
|
70
|
+
if (deeper)
|
|
71
|
+
return deeper;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
// ─── HANDOFF parser ───────────────────────────────────────────────────────────
|
|
76
|
+
/** Secciones que indican tareas pendientes (case-insensitive) */
|
|
77
|
+
const PENDING_SECTION = /pending|task|feature|todo|por hacer|pendiente/i;
|
|
78
|
+
/** Secciones que indican tareas completadas */
|
|
79
|
+
const DONE_SECTION = /done|completed|finished|completado|hecho/i;
|
|
80
|
+
function parseHandoffProgress(content) {
|
|
81
|
+
let done = 0, pending = 0;
|
|
82
|
+
let nextTask = null;
|
|
83
|
+
// Parsear sección por sección para entender el contexto de cada ítem
|
|
84
|
+
const lines = content.split('\n');
|
|
85
|
+
let inPending = false; // estamos dentro de una sección "Pending"
|
|
86
|
+
let inDone = false; // estamos dentro de una sección "Done"
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
// Detectar cambio de sección (heading ##)
|
|
89
|
+
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
|
90
|
+
if (heading) {
|
|
91
|
+
const h = heading[1];
|
|
92
|
+
inPending = PENDING_SECTION.test(h);
|
|
93
|
+
inDone = DONE_SECTION.test(h);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// ── Formato checkbox: - [x] / - [ ] — válido en cualquier sección ──────
|
|
97
|
+
if (/- \[x\]/i.test(line)) {
|
|
98
|
+
done++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (/- \[ \]/.test(line)) {
|
|
102
|
+
pending++;
|
|
103
|
+
if (!nextTask) {
|
|
104
|
+
const m = line.match(/^[\s]*- \[ \]\s*(.+)$/);
|
|
105
|
+
if (m)
|
|
106
|
+
nextTask = m[1].trim().replace(/^\*\*|\*\*$/g, '');
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// ── Formato emoji: 1. ✅ / 1. 🟡 — válido en cualquier sección ──────────
|
|
111
|
+
if (/^[\s]*\d+\.\s+✅/.test(line)) {
|
|
112
|
+
done++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (/^[\s]*\d+\.\s+(?:🟡|⬜|☐|🔲)/.test(line)) {
|
|
116
|
+
pending++;
|
|
117
|
+
if (!nextTask) {
|
|
118
|
+
const m = line.match(/^[\s]*\d+\.\s+(?:🟡|⬜|☐|🔲)\s+\*?\*?(.+?)(?:\*\*)?$/);
|
|
119
|
+
if (m)
|
|
120
|
+
nextTask = m[1].trim().replace(/^\*\*|\*\*$/g, '').replace(/\s*—.*$/, '');
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// ── Lista simple (- texto sin checkbox): solo si estamos en sección Pending ──
|
|
125
|
+
// Esto cubre el formato de CatcherAuto, EvolutFit, etc.
|
|
126
|
+
if (inPending && /^[\s]*-\s+\S/.test(line) && !/^[\s]*-\s*\[/.test(line)) {
|
|
127
|
+
pending++;
|
|
128
|
+
if (!nextTask) {
|
|
129
|
+
const m = line.match(/^[\s]*-\s+(.+)$/);
|
|
130
|
+
if (m)
|
|
131
|
+
nextTask = m[1].trim().replace(/^\*\*|\*\*$/g, '');
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (inDone && /^[\s]*-\s+\S/.test(line) && !/^[\s]*-\s*\[/.test(line)) {
|
|
136
|
+
done++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const total = done + pending;
|
|
141
|
+
const pct = total > 0 ? Math.round(done / total * 100) : 0;
|
|
142
|
+
return { done, total, pct, nextTask };
|
|
143
|
+
}
|
|
144
|
+
// ─── JSONL stats (datos históricos sin daemon) ────────────────────────────────
|
|
145
|
+
const PROJECTS_DIR = path_1.default.join((0, paths_1.getClaudeDir)(), 'projects');
|
|
146
|
+
// Precios en USD por millón de tokens (misma tabla que enricher.ts)
|
|
147
|
+
const PRICING = {
|
|
148
|
+
'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.50, cacheCreate: 18.75 },
|
|
149
|
+
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.30, cacheCreate: 3.75 },
|
|
150
|
+
'claude-haiku-4-5': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreate: 1.00 },
|
|
151
|
+
'claude-haiku-4-5-20251001': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreate: 1.00 },
|
|
152
|
+
};
|
|
153
|
+
const DEFAULT_PRICING = PRICING['claude-sonnet-4-6'];
|
|
154
|
+
function calcCost(model, usage) {
|
|
155
|
+
const p = PRICING[model] ?? DEFAULT_PRICING;
|
|
156
|
+
const M = 1000000;
|
|
157
|
+
return ((usage.input_tokens * p.input) / M +
|
|
158
|
+
(usage.output_tokens * p.output) / M +
|
|
159
|
+
(usage.cache_read_input_tokens * p.cacheRead) / M +
|
|
160
|
+
(usage.cache_creation_input_tokens * p.cacheCreate) / M);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Lee todos los JSONL del directorio codificado de un proyecto y acumula
|
|
164
|
+
* tokens y coste. No requiere que el daemon haya estado corriendo.
|
|
165
|
+
*/
|
|
166
|
+
function getJSONLStats(encodedDir) {
|
|
167
|
+
const dirPath = path_1.default.join(PROJECTS_DIR, encodedDir);
|
|
168
|
+
let sessionCount = 0, totalCost = 0, totalTokens = 0, lastActive = null;
|
|
169
|
+
const modelUsage = { opusTokens: 0, sonnetTokens: 0, haikuTokens: 0 };
|
|
170
|
+
try {
|
|
171
|
+
const files = fs_1.default.readdirSync(dirPath);
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
if (!file.endsWith('.jsonl'))
|
|
174
|
+
continue;
|
|
175
|
+
const sessionId = file.slice(0, -6);
|
|
176
|
+
if (!sessionId.includes('-') || sessionId.length < 10)
|
|
177
|
+
continue;
|
|
178
|
+
const filePath = path_1.default.join(dirPath, file);
|
|
179
|
+
try {
|
|
180
|
+
const stat = fs_1.default.statSync(filePath);
|
|
181
|
+
if (!lastActive || stat.mtimeMs > lastActive)
|
|
182
|
+
lastActive = stat.mtimeMs;
|
|
183
|
+
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
184
|
+
let hasAssistant = false;
|
|
185
|
+
for (const raw of content.split('\n')) {
|
|
186
|
+
const line = raw.trim();
|
|
187
|
+
if (!line)
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
const obj = JSON.parse(line);
|
|
191
|
+
if (obj.type !== 'assistant')
|
|
192
|
+
continue;
|
|
193
|
+
const usage = obj.message?.usage;
|
|
194
|
+
const model = obj.message?.model ?? 'claude-sonnet-4-6';
|
|
195
|
+
if (!usage)
|
|
196
|
+
continue;
|
|
197
|
+
hasAssistant = true;
|
|
198
|
+
const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
199
|
+
totalCost += calcCost(model, usage);
|
|
200
|
+
totalTokens += tokens;
|
|
201
|
+
if (model.includes('opus'))
|
|
202
|
+
modelUsage.opusTokens += tokens;
|
|
203
|
+
else if (model.includes('haiku'))
|
|
204
|
+
modelUsage.haikuTokens += tokens;
|
|
205
|
+
else
|
|
206
|
+
modelUsage.sonnetTokens += tokens;
|
|
207
|
+
}
|
|
208
|
+
catch { /* línea malformada */ }
|
|
209
|
+
}
|
|
210
|
+
if (hasAssistant)
|
|
211
|
+
sessionCount++;
|
|
212
|
+
}
|
|
213
|
+
catch { /* archivo inaccesible */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch { /* directorio no encontrado */ }
|
|
217
|
+
return { session_count: sessionCount, total_cost_usd: totalCost, total_tokens: totalTokens, last_active: lastActive, modelUsage };
|
|
218
|
+
}
|
|
219
|
+
// ─── Inferencia de raíz de proyecto desde JSONL ───────────────────────────────
|
|
220
|
+
/**
|
|
221
|
+
* Marcadores de raíz de proyecto, en orden de prioridad.
|
|
222
|
+
* El primer marcador encontrado al subir el árbol determina la raíz.
|
|
223
|
+
*/
|
|
224
|
+
const PROJECT_MARKERS = [
|
|
225
|
+
'HANDOFF.md', // claudestat — más específico
|
|
226
|
+
'.git', // git repo — universal
|
|
227
|
+
'package.json', // Node.js
|
|
228
|
+
'pyproject.toml', // Python moderno
|
|
229
|
+
'requirements.txt', // Python clásico
|
|
230
|
+
'go.mod', // Go
|
|
231
|
+
'Cargo.toml', // Rust
|
|
232
|
+
'pom.xml', // Java/Maven
|
|
233
|
+
'build.gradle', // Java/Gradle
|
|
234
|
+
];
|
|
235
|
+
/**
|
|
236
|
+
* Sube el árbol de directorios desde un file_path hasta encontrar
|
|
237
|
+
* el primer marcador de proyecto conocido. Retorna ese directorio.
|
|
238
|
+
*/
|
|
239
|
+
function findProjectRoot(filePath) {
|
|
240
|
+
let dir = path_1.default.dirname(filePath);
|
|
241
|
+
for (let i = 0; i < 10; i++) {
|
|
242
|
+
for (const marker of PROJECT_MARKERS) {
|
|
243
|
+
if (fs_1.default.existsSync(path_1.default.join(dir, marker)))
|
|
244
|
+
return dir;
|
|
245
|
+
}
|
|
246
|
+
const parent = path_1.default.dirname(dir);
|
|
247
|
+
if (parent === dir)
|
|
248
|
+
break; // llegamos a '/'
|
|
249
|
+
dir = parent;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Para directorios con múltiples proyectos (ej: home dir con sesiones variadas),
|
|
255
|
+
* lee cada JSONL individualmente, infiere su proyecto y calcula sus stats.
|
|
256
|
+
*
|
|
257
|
+
* Retorna un Map de projectRoot → JSONLStats con datos precisos por proyecto.
|
|
258
|
+
*/
|
|
259
|
+
function getJSONLStatsByProject(dirPath) {
|
|
260
|
+
const result = new Map();
|
|
261
|
+
const INFER_LINES = 150; // líneas para inferir el proyecto
|
|
262
|
+
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'MultiEdit']);
|
|
263
|
+
let jsonlFiles;
|
|
264
|
+
try {
|
|
265
|
+
jsonlFiles = fs_1.default.readdirSync(dirPath)
|
|
266
|
+
.filter(f => f.endsWith('.jsonl') && f.includes('-') && f.length > 10);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
for (const file of jsonlFiles) {
|
|
272
|
+
const filePath = path_1.default.join(dirPath, file);
|
|
273
|
+
try {
|
|
274
|
+
const stat = fs_1.default.statSync(filePath);
|
|
275
|
+
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
276
|
+
const lines = content.split('\n');
|
|
277
|
+
// ── Inferir proyecto desde los primeros INFER_LINES ──────────────────
|
|
278
|
+
const rootCounts = new Map();
|
|
279
|
+
for (const raw of lines.slice(0, INFER_LINES)) {
|
|
280
|
+
try {
|
|
281
|
+
const obj = JSON.parse(raw.trim());
|
|
282
|
+
if (obj.type !== 'assistant')
|
|
283
|
+
continue;
|
|
284
|
+
const blocks = obj.message?.content;
|
|
285
|
+
if (!Array.isArray(blocks))
|
|
286
|
+
continue;
|
|
287
|
+
for (const block of blocks) {
|
|
288
|
+
if (block.type !== 'tool_use' || !FILE_TOOLS.has(block.name))
|
|
289
|
+
continue;
|
|
290
|
+
const fp = (block.input?.file_path || block.input?.path);
|
|
291
|
+
if (!fp || !path_1.default.isAbsolute(fp))
|
|
292
|
+
continue;
|
|
293
|
+
const root = findProjectRoot(fp);
|
|
294
|
+
if (root)
|
|
295
|
+
rootCounts.set(root, (rootCounts.get(root) ?? 0) + 1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch { /* ignorar */ }
|
|
299
|
+
}
|
|
300
|
+
if (rootCounts.size === 0)
|
|
301
|
+
continue;
|
|
302
|
+
const projectRoot = [...rootCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
303
|
+
// ── Calcular coste y tokens de este JSONL completo ───────────────────
|
|
304
|
+
let cost = 0, tokens = 0, hasAssistant = false;
|
|
305
|
+
const mu = { opusTokens: 0, sonnetTokens: 0, haikuTokens: 0 };
|
|
306
|
+
for (const raw of lines) {
|
|
307
|
+
try {
|
|
308
|
+
const obj = JSON.parse(raw.trim());
|
|
309
|
+
if (obj.type !== 'assistant')
|
|
310
|
+
continue;
|
|
311
|
+
const usage = obj.message?.usage;
|
|
312
|
+
const model = obj.message?.model ?? 'claude-sonnet-4-6';
|
|
313
|
+
if (!usage)
|
|
314
|
+
continue;
|
|
315
|
+
hasAssistant = true;
|
|
316
|
+
const t = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
317
|
+
cost += calcCost(model, usage);
|
|
318
|
+
tokens += t;
|
|
319
|
+
if (model.includes('opus'))
|
|
320
|
+
mu.opusTokens += t;
|
|
321
|
+
else if (model.includes('haiku'))
|
|
322
|
+
mu.haikuTokens += t;
|
|
323
|
+
else
|
|
324
|
+
mu.sonnetTokens += t;
|
|
325
|
+
}
|
|
326
|
+
catch { /* ignorar */ }
|
|
327
|
+
}
|
|
328
|
+
if (!hasAssistant)
|
|
329
|
+
continue;
|
|
330
|
+
// ── Acumular en el mapa de resultados ────────────────────────────────
|
|
331
|
+
const existing = result.get(projectRoot);
|
|
332
|
+
if (existing) {
|
|
333
|
+
existing.session_count++;
|
|
334
|
+
existing.total_cost_usd += cost;
|
|
335
|
+
existing.total_tokens += tokens;
|
|
336
|
+
existing.modelUsage.opusTokens += mu.opusTokens;
|
|
337
|
+
existing.modelUsage.sonnetTokens += mu.sonnetTokens;
|
|
338
|
+
existing.modelUsage.haikuTokens += mu.haikuTokens;
|
|
339
|
+
if (stat.mtimeMs > (existing.last_active ?? 0))
|
|
340
|
+
existing.last_active = stat.mtimeMs;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
result.set(projectRoot, {
|
|
344
|
+
session_count: 1, total_cost_usd: cost,
|
|
345
|
+
total_tokens: tokens, last_active: stat.mtimeMs, modelUsage: mu,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch { /* archivo inaccesible */ }
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Encuentra la mejor raíz de proyecto para un directorio dado.
|
|
355
|
+
* Prioridad: HANDOFF.md (subiendo hasta 4 niveles) → .git → cualquier marker → el dir mismo.
|
|
356
|
+
*
|
|
357
|
+
* HANDOFF.md tiene prioridad sobre .git y package.json porque es el marker
|
|
358
|
+
* que el usuario mantiene conscientemente para claudestat.
|
|
359
|
+
*/
|
|
360
|
+
function findBestProjectRoot(dir) {
|
|
361
|
+
// 1. Buscar HANDOFF.md subiendo el árbol (hasta 4 niveles)
|
|
362
|
+
let current = dir;
|
|
363
|
+
for (let i = 0; i < 4; i++) {
|
|
364
|
+
if (fs_1.default.existsSync(path_1.default.join(current, 'HANDOFF.md')))
|
|
365
|
+
return current;
|
|
366
|
+
const parent = path_1.default.dirname(current);
|
|
367
|
+
if (parent === current)
|
|
368
|
+
break;
|
|
369
|
+
current = parent;
|
|
370
|
+
}
|
|
371
|
+
// 2. Buscar cualquier otro marker de proyecto
|
|
372
|
+
const byMarker = findProjectRoot(dir);
|
|
373
|
+
if (byMarker)
|
|
374
|
+
return byMarker;
|
|
375
|
+
// 3. Fallback: usar el dir tal cual
|
|
376
|
+
return dir;
|
|
377
|
+
}
|
|
378
|
+
// ─── Auto-crear HANDOFF para proyectos sin uno ───────────────────────────────
|
|
379
|
+
/**
|
|
380
|
+
* Detecta el stack tecnológico de un proyecto desde los archivos presentes.
|
|
381
|
+
* Retorna una lista de strings legibles como "Node.js", "Python", etc.
|
|
382
|
+
*/
|
|
383
|
+
function detectStack(projectPath) {
|
|
384
|
+
const markers = [
|
|
385
|
+
['package.json', 'Node.js'],
|
|
386
|
+
['pyproject.toml', 'Python'],
|
|
387
|
+
['requirements.txt', 'Python'],
|
|
388
|
+
['go.mod', 'Go'],
|
|
389
|
+
['Cargo.toml', 'Rust'],
|
|
390
|
+
['pom.xml', 'Java / Maven'],
|
|
391
|
+
['build.gradle', 'Java / Gradle'],
|
|
392
|
+
['Gemfile', 'Ruby'],
|
|
393
|
+
['pubspec.yaml', 'Flutter / Dart'],
|
|
394
|
+
['build.gradle.kts', 'Kotlin'],
|
|
395
|
+
['AndroidManifest.xml', 'Android'],
|
|
396
|
+
['Info.plist', 'iOS / macOS'],
|
|
397
|
+
['*.sln', '.NET'],
|
|
398
|
+
];
|
|
399
|
+
const detected = [];
|
|
400
|
+
for (const [file, label] of markers) {
|
|
401
|
+
if (file.includes('*')) {
|
|
402
|
+
try {
|
|
403
|
+
const ext = file.replace('*.', '.');
|
|
404
|
+
if (fs_1.default.readdirSync(projectPath).some(f => f.endsWith(ext)))
|
|
405
|
+
detected.push(label);
|
|
406
|
+
}
|
|
407
|
+
catch { /* ignorar */ }
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
if (fs_1.default.existsSync(path_1.default.join(projectPath, file)))
|
|
411
|
+
detected.push(label);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return [...new Set(detected)]; // deduplicar
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Genera y escribe un HANDOFF.md mínimo para proyectos que no tienen uno.
|
|
418
|
+
* El archivo se marca como auto-generado para que el usuario lo complete.
|
|
419
|
+
* No sobreescribe si ya existe.
|
|
420
|
+
*/
|
|
421
|
+
function autoCreateHandoff(projectPath, stats) {
|
|
422
|
+
const handoffPath = path_1.default.join(projectPath, 'HANDOFF.md');
|
|
423
|
+
if (fs_1.default.existsSync(handoffPath))
|
|
424
|
+
return; // ya existe, no tocar
|
|
425
|
+
const name = path_1.default.basename(projectPath);
|
|
426
|
+
const stack = detectStack(projectPath);
|
|
427
|
+
const stackStr = stack.length > 0 ? stack.join(', ') : 'no detectado';
|
|
428
|
+
const cost = stats.total_cost_usd.toFixed(2);
|
|
429
|
+
const tokens = stats.total_tokens >= 1000000
|
|
430
|
+
? `${(stats.total_tokens / 1000000).toFixed(1)}M`
|
|
431
|
+
: stats.total_tokens >= 1000
|
|
432
|
+
? `${Math.round(stats.total_tokens / 1000)}K`
|
|
433
|
+
: String(stats.total_tokens);
|
|
434
|
+
const content = `# HANDOFF — ${name}
|
|
435
|
+
<!-- Auto-generado por claudestat. Completá las secciones marcadas con TODO. -->
|
|
436
|
+
|
|
437
|
+
## Current Status
|
|
438
|
+
- Branch: \`TODO — indicar rama principal\`
|
|
439
|
+
- Last: auto-generado por claudestat (${stats.session_count} sesión${stats.session_count !== 1 ? 'es' : ''} · $${cost} · ${tokens} tokens)
|
|
440
|
+
- State: stack detectado: ${stackStr}
|
|
441
|
+
|
|
442
|
+
## Pending Tasks
|
|
443
|
+
- [ ] TODO — describir la próxima tarea concreta
|
|
444
|
+
- [ ] TODO — agregar objetivos del proyecto
|
|
445
|
+
|
|
446
|
+
## Completed
|
|
447
|
+
- [x] Proyecto detectado y registrado por claudestat
|
|
448
|
+
|
|
449
|
+
## Gotchas
|
|
450
|
+
- TODO — anotar decisiones importantes, bugs conocidos, restricciones
|
|
451
|
+
|
|
452
|
+
## Session Log
|
|
453
|
+
- **${new Date().toISOString().slice(0, 10)}** — HANDOFF auto-generado por claudestat con ${stats.session_count} sesión${stats.session_count !== 1 ? 'es' : ''} y $${cost} de coste acumulado
|
|
454
|
+
`;
|
|
455
|
+
try {
|
|
456
|
+
fs_1.default.writeFileSync(handoffPath, content, 'utf8');
|
|
457
|
+
}
|
|
458
|
+
catch { /* sin permisos de escritura — ignorar silenciosamente */ }
|
|
459
|
+
}
|
|
460
|
+
// ─── Scanner principal ────────────────────────────────────────────────────────
|
|
461
|
+
/**
|
|
462
|
+
* Descubre todos los proyectos en los que se ha trabajado con Claude Code.
|
|
463
|
+
*
|
|
464
|
+
* Estrategia (Opción A):
|
|
465
|
+
* 1. Para cada directorio en ~/.claude/projects/:
|
|
466
|
+
* a. Intenta decodificar el nombre (rápido) → busca mejor raíz subiendo árbol
|
|
467
|
+
* b. Si falla → infiere desde file paths del JSONL
|
|
468
|
+
* 2. Agrupa por raíz de proyecto (varios dirs Claude Code → mismo repo)
|
|
469
|
+
* 3. Para cada raíz única: lee HANDOFF, calcula progreso, suma stats JSONL
|
|
470
|
+
*/
|
|
471
|
+
function discoverProjects() {
|
|
472
|
+
// Mapa: inodeKey → { bestPath (canónico), encodedDirs[], jsonlStats acumulados }
|
|
473
|
+
const projectMap = new Map();
|
|
474
|
+
let dirs;
|
|
475
|
+
try {
|
|
476
|
+
dirs = fs_1.default.readdirSync(PROJECTS_DIR);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
481
|
+
for (const encodedDir of dirs) {
|
|
482
|
+
const dirPath = path_1.default.join(PROJECTS_DIR, encodedDir);
|
|
483
|
+
try {
|
|
484
|
+
if (!fs_1.default.statSync(dirPath).isDirectory())
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
// ── Paso 1: encontrar la raíz real del proyecto ──────────────────────────
|
|
491
|
+
let projectRoot = null;
|
|
492
|
+
// Intento rápido: decodificar el nombre del directorio
|
|
493
|
+
const decoded = decodeProjectDir(encodedDir);
|
|
494
|
+
if (decoded && fs_1.default.existsSync(decoded)) {
|
|
495
|
+
// Subir el árbol buscando HANDOFF.md primero, luego otros markers
|
|
496
|
+
projectRoot = findBestProjectRoot(decoded);
|
|
497
|
+
}
|
|
498
|
+
// ── Paso 2: acumular stats en el mapa ────────────────────────────────────
|
|
499
|
+
// Clave única por directorio: inode resuelve case en macOS y symlinks en Linux
|
|
500
|
+
const inodeKey = (p) => {
|
|
501
|
+
try {
|
|
502
|
+
const s = fs_1.default.statSync(p);
|
|
503
|
+
return `${s.dev}:${s.ino}`;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return p;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
// Helper para registrar un projectRoot con sus stats
|
|
510
|
+
const registerRoot = (root, stats) => {
|
|
511
|
+
if (root === os_1.default.homedir())
|
|
512
|
+
return; // nunca registrar el home dir
|
|
513
|
+
const best = findBestProjectRoot(root);
|
|
514
|
+
const bestKey = inodeKey(best);
|
|
515
|
+
const homeKey = inodeKey(os_1.default.homedir());
|
|
516
|
+
if (bestKey === homeKey)
|
|
517
|
+
return;
|
|
518
|
+
const existing = projectMap.get(bestKey);
|
|
519
|
+
if (existing) {
|
|
520
|
+
existing.encodedDirs.push(encodedDir);
|
|
521
|
+
existing.jsonlStats.session_count += stats.session_count;
|
|
522
|
+
existing.jsonlStats.total_cost_usd += stats.total_cost_usd;
|
|
523
|
+
existing.jsonlStats.total_tokens += stats.total_tokens;
|
|
524
|
+
existing.jsonlStats.modelUsage.opusTokens += stats.modelUsage.opusTokens;
|
|
525
|
+
existing.jsonlStats.modelUsage.sonnetTokens += stats.modelUsage.sonnetTokens;
|
|
526
|
+
existing.jsonlStats.modelUsage.haikuTokens += stats.modelUsage.haikuTokens;
|
|
527
|
+
if (stats.last_active && (!existing.jsonlStats.last_active || stats.last_active > existing.jsonlStats.last_active)) {
|
|
528
|
+
existing.jsonlStats.last_active = stats.last_active;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
projectMap.set(bestKey, {
|
|
533
|
+
encodedDirs: [encodedDir],
|
|
534
|
+
jsonlStats: { ...stats, modelUsage: { ...stats.modelUsage } },
|
|
535
|
+
bestPath: best,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
if (projectRoot) {
|
|
540
|
+
registerRoot(projectRoot, getJSONLStats(encodedDir));
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// Fallback multi-proyecto: stats precisas por JSONL individual
|
|
544
|
+
const byProject = getJSONLStatsByProject(dirPath);
|
|
545
|
+
for (const [root, stats] of byProject) {
|
|
546
|
+
if (fs_1.default.existsSync(root))
|
|
547
|
+
registerRoot(root, stats);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// ── Paso 3: construir resultados con HANDOFF y progreso ───────────────────
|
|
552
|
+
const raw = [];
|
|
553
|
+
for (const [, { bestPath: rootPath, encodedDirs, jsonlStats }] of projectMap) {
|
|
554
|
+
const handoffPath = path_1.default.join(rootPath, 'HANDOFF.md');
|
|
555
|
+
// Auto-crear HANDOFF si el proyecto no tiene uno
|
|
556
|
+
if (!fs_1.default.existsSync(handoffPath)) {
|
|
557
|
+
autoCreateHandoff(rootPath, jsonlStats);
|
|
558
|
+
}
|
|
559
|
+
const hasHandoff = fs_1.default.existsSync(handoffPath);
|
|
560
|
+
const handoffContent = hasHandoff ? (() => { try {
|
|
561
|
+
return fs_1.default.readFileSync(handoffPath, 'utf8');
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return '';
|
|
565
|
+
} })() : '';
|
|
566
|
+
const autoHandoff = hasHandoff && handoffContent.includes('<!-- Auto-generado por claudestat');
|
|
567
|
+
const progress = hasHandoff
|
|
568
|
+
? parseHandoffProgress(handoffContent)
|
|
569
|
+
: { done: 0, total: 0, pct: 0, nextTask: null };
|
|
570
|
+
// last_active = max(JSONL mtime, HANDOFF.md mtime)
|
|
571
|
+
if (hasHandoff) {
|
|
572
|
+
try {
|
|
573
|
+
const handoffMtime = fs_1.default.statSync(handoffPath).mtimeMs;
|
|
574
|
+
if (handoffMtime > (jsonlStats.last_active ?? 0)) {
|
|
575
|
+
jsonlStats.last_active = handoffMtime;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch { /* ignorar */ }
|
|
579
|
+
}
|
|
580
|
+
raw.push({
|
|
581
|
+
path: rootPath,
|
|
582
|
+
name: path_1.default.basename(rootPath),
|
|
583
|
+
encodedDir: encodedDirs[0],
|
|
584
|
+
hasHandoff,
|
|
585
|
+
autoHandoff,
|
|
586
|
+
progress,
|
|
587
|
+
jsonlStats,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
// Deduplicar por inode: identifica el mismo directorio independientemente del case o symlinks
|
|
591
|
+
const inodeKey = (p) => {
|
|
592
|
+
try {
|
|
593
|
+
const s = fs_1.default.statSync(p);
|
|
594
|
+
return `${s.dev}:${s.ino}`;
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return p;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
const dedup = new Map();
|
|
601
|
+
for (const r of raw) {
|
|
602
|
+
const key = inodeKey(r.path);
|
|
603
|
+
const existing = dedup.get(key);
|
|
604
|
+
if (existing) {
|
|
605
|
+
existing.jsonlStats.session_count += r.jsonlStats.session_count;
|
|
606
|
+
existing.jsonlStats.total_cost_usd += r.jsonlStats.total_cost_usd;
|
|
607
|
+
existing.jsonlStats.total_tokens += r.jsonlStats.total_tokens;
|
|
608
|
+
existing.jsonlStats.modelUsage.opusTokens += r.jsonlStats.modelUsage.opusTokens;
|
|
609
|
+
existing.jsonlStats.modelUsage.sonnetTokens += r.jsonlStats.modelUsage.sonnetTokens;
|
|
610
|
+
existing.jsonlStats.modelUsage.haikuTokens += r.jsonlStats.modelUsage.haikuTokens;
|
|
611
|
+
if ((r.jsonlStats.last_active ?? 0) > (existing.jsonlStats.last_active ?? 0))
|
|
612
|
+
existing.jsonlStats.last_active = r.jsonlStats.last_active;
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
dedup.set(key, r);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return [...dedup.values()];
|
|
619
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quota-tracker.ts — Seguimiento de cuota de uso de Claude Code
|
|
3
|
+
*
|
|
4
|
+
* Calcula en base a los JSONL de ~/.claude/projects/:
|
|
5
|
+
* - Prompts reales en el ciclo actual de 5 horas (ventana deslizante desde epoch UTC)
|
|
6
|
+
* - Tiempo hasta el reset del ciclo
|
|
7
|
+
* - Horas de sesión semanales por modelo (Sonnet vs Opus)
|
|
8
|
+
* - Burn rate: tokens/min promedio de los últimos 30 minutos
|
|
9
|
+
* - Plan detectado automáticamente desde el máximo histórico
|
|
10
|
+
*
|
|
11
|
+
* Por qué 5h desde epoch UTC (no desde una hora fija):
|
|
12
|
+
* Claude Code usa ventanas deslizantes de exactamente 5 horas desde el epoch Unix.
|
|
13
|
+
* Ciclo actual = floor(now / 5h) * 5h. El reset ocurre cuando cruza ese límite.
|
|
14
|
+
*
|
|
15
|
+
* Caché de 30 segundos para no re-leer todos los JSONL en cada request del dashboard.
|
|
16
|
+
*/
|
|
17
|
+
export type ClaudePlan = 'free' | 'pro' | 'max5' | 'max20';
|
|
18
|
+
export interface QuotaData {
|
|
19
|
+
cyclePrompts: number;
|
|
20
|
+
cycleLimit: number;
|
|
21
|
+
cyclePct: number;
|
|
22
|
+
cycleResetMs: number;
|
|
23
|
+
cycleResetAt: number;
|
|
24
|
+
cycleStartTs: number;
|
|
25
|
+
weeklyHoursSonnet: number;
|
|
26
|
+
weeklyHoursOpus: number;
|
|
27
|
+
weeklyHoursHaiku: number;
|
|
28
|
+
weeklyTokensSonnet: number;
|
|
29
|
+
weeklyTokensOpus: number;
|
|
30
|
+
weeklyTokensHaiku: number;
|
|
31
|
+
weeklyLimitSonnet: number;
|
|
32
|
+
weeklyLimitOpus: number;
|
|
33
|
+
burnRateTokensPerMin: number;
|
|
34
|
+
detectedPlan: ClaudePlan;
|
|
35
|
+
planSource: 'config' | 'keychain' | 'inferred';
|
|
36
|
+
computedAt: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Calcula y retorna QuotaData.
|
|
40
|
+
* Usa caché de 30s para no re-leer todos los JSONL en cada request del dashboard.
|
|
41
|
+
* Pasar `forcePlan` para fijar el plan manualmente (por config del usuario).
|
|
42
|
+
*/
|
|
43
|
+
export declare function computeQuota(forcePlan?: ClaudePlan): QuotaData;
|
|
44
|
+
/** Invalida la caché (llamar cuando llega un nuevo evento para que el siguiente /quota sea fresco) */
|
|
45
|
+
export declare function invalidateQuotaCache(): void;
|