@statforge/claudestat 1.2.0 → 1.2.2
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 +198 -86
- package/dist/claude-auth.d.ts +7 -0
- package/dist/claude-auth.js +12 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -0
- package/dist/daemon.js +32 -15
- package/dist/db.d.ts +18 -0
- package/dist/db.js +50 -0
- package/dist/doctor.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +164 -76
- package/dist/insights.d.ts +26 -0
- package/dist/insights.js +172 -20
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +148 -21
- package/dist/notifier.js +24 -6
- package/dist/pattern-analyzer.d.ts +1 -0
- package/dist/pattern-analyzer.js +13 -0
- package/dist/quota-tracker.d.ts +6 -0
- package/dist/quota-tracker.js +79 -32
- package/dist/roast.js +129 -40
- package/dist/routes/events.js +1 -1
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
4
|
* mcp-server.ts — MCP (Model Context Protocol) server for claudestat
|
|
@@ -50,9 +50,10 @@ const readline = __importStar(require("readline"));
|
|
|
50
50
|
const db_1 = require("./db");
|
|
51
51
|
const quota_tracker_1 = require("./quota-tracker");
|
|
52
52
|
const insights_1 = require("./insights");
|
|
53
|
+
const config_1 = require("./config");
|
|
53
54
|
const SERVER_NAME = 'claudestat';
|
|
54
|
-
const SERVER_VERSION = '1.2.
|
|
55
|
-
const PROTOCOL_VERSION = '2025-
|
|
55
|
+
const SERVER_VERSION = '1.2.2';
|
|
56
|
+
const PROTOCOL_VERSION = '2025-03-26';
|
|
56
57
|
const TOOLS = [
|
|
57
58
|
{
|
|
58
59
|
name: 'get_quota_status',
|
|
@@ -104,6 +105,34 @@ const TOOLS = [
|
|
|
104
105
|
required: []
|
|
105
106
|
}
|
|
106
107
|
},
|
|
108
|
+
{
|
|
109
|
+
name: 'get_usage_insights',
|
|
110
|
+
description: 'Get unique usage insights not available in /usage: cost per project, cache savings, output/input ratio, efficiency trend, and peak hours',
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
days: {
|
|
115
|
+
type: 'number',
|
|
116
|
+
description: 'Days to look back (default 7)'
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
required: []
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'get_model_breakdown',
|
|
124
|
+
description: 'Get cost and session count broken down by Claude model (Sonnet, Haiku, Opus) for the last N days',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
days: {
|
|
129
|
+
type: 'number',
|
|
130
|
+
description: 'Days to look back (default 7)'
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
required: []
|
|
134
|
+
}
|
|
135
|
+
},
|
|
107
136
|
{
|
|
108
137
|
name: 'get_weekly_insight',
|
|
109
138
|
description: 'Get the weekly usage summary with an actionable tip (same as claudestat weekly command)',
|
|
@@ -141,10 +170,13 @@ function toolGetQuotaStatus() {
|
|
|
141
170
|
: `${resetMin}m`;
|
|
142
171
|
const weeklyTotalHours = q.weeklyHoursSonnet + q.weeklyHoursOpus;
|
|
143
172
|
const weeklyLimitTotal = q.weeklyLimitSonnet + q.weeklyLimitOpus;
|
|
173
|
+
const planLabel = q.planSource === 'inferred'
|
|
174
|
+
? `${q.detectedPlan.toUpperCase()} plan (unverified — checking API...)`
|
|
175
|
+
: `${q.detectedPlan.toUpperCase()} plan`;
|
|
144
176
|
const parts = [
|
|
145
|
-
`Quota status — ${
|
|
177
|
+
`Quota status — ${planLabel}`,
|
|
146
178
|
``,
|
|
147
|
-
`5h cycle: ${q.cyclePrompts}/${q.cycleLimit} prompts (
|
|
179
|
+
`5h cycle: ${q.cyclePct}% · ${q.cyclePrompts > q.cycleLimit ? `${q.cyclePrompts}/${q.cycleLimit} prompts (OVER LIMIT)` : `${q.cyclePrompts}/${q.cycleLimit} prompts`} · resets in ${resetLabel}`,
|
|
148
180
|
`Weekly: ${weeklyTotalHours}h / ${weeklyLimitTotal}h (${q.weeklyPctAll}%)`,
|
|
149
181
|
];
|
|
150
182
|
if (q.weeklyLimitOpus > 0) {
|
|
@@ -154,6 +186,33 @@ function toolGetQuotaStatus() {
|
|
|
154
186
|
if (q.burnRateTokensPerMin > 0) {
|
|
155
187
|
parts.push(`Burn rate: ${q.burnRateTokensPerMin.toLocaleString()} tokens/min`);
|
|
156
188
|
}
|
|
189
|
+
// Active alerts — only shown when thresholds are crossed
|
|
190
|
+
const cfg = (0, config_1.readConfig)();
|
|
191
|
+
if (cfg.alertsEnabled) {
|
|
192
|
+
const alerts = [];
|
|
193
|
+
const cycleLevel = (0, config_1.getWarnLevel)(q.cyclePct, cfg.warnThresholds);
|
|
194
|
+
const weeklyLevel = (0, config_1.getWarnLevel)(q.weeklyPctAll, cfg.weeklyWarnThresholds);
|
|
195
|
+
if (cycleLevel === 'red')
|
|
196
|
+
alerts.push(`🔴 5h cycle at ${q.cyclePct}% — critical, limit imminent`);
|
|
197
|
+
else if (cycleLevel)
|
|
198
|
+
alerts.push(`⚠️ 5h cycle at ${q.cyclePct}% — approaching limit`);
|
|
199
|
+
if (weeklyLevel === 'red')
|
|
200
|
+
alerts.push(`🔴 Weekly at ${q.weeklyPctAll}% — critical`);
|
|
201
|
+
else if (weeklyLevel)
|
|
202
|
+
alerts.push(`⚠️ Weekly at ${q.weeklyPctAll}% — approaching weekly limit`);
|
|
203
|
+
if (q.cyclePrompts > q.cycleLimit) {
|
|
204
|
+
alerts.push(`⚠️ Prompt count (${q.cyclePrompts}) exceeds plan limit (${q.cycleLimit}) — plan may be mis-detected`);
|
|
205
|
+
}
|
|
206
|
+
const reminderMins = cfg.resetReminderMins ?? 10;
|
|
207
|
+
if (reminderMins > 0 && resetMin <= reminderMins && resetMin > 0) {
|
|
208
|
+
alerts.push(`⏰ Cycle resets in ${resetMin}m — good time to wrap up or start fresh`);
|
|
209
|
+
}
|
|
210
|
+
if (alerts.length > 0) {
|
|
211
|
+
parts.push(``);
|
|
212
|
+
parts.push(`─── ACTIVE ALERTS ───────────────────────`);
|
|
213
|
+
parts.push(...alerts);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
157
216
|
return parts.join('\n');
|
|
158
217
|
}
|
|
159
218
|
function toolGetCurrentSession() {
|
|
@@ -221,6 +280,71 @@ function toolGetTopTools(days, sortBy) {
|
|
|
221
280
|
}
|
|
222
281
|
return lines.join('\n');
|
|
223
282
|
}
|
|
283
|
+
function toolGetUsageInsights(days) {
|
|
284
|
+
const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
|
|
285
|
+
const i = (0, insights_1.getUsageInsights)(d);
|
|
286
|
+
if (i.total_sessions === 0)
|
|
287
|
+
return `No data for the last ${d} days.`;
|
|
288
|
+
const bar = (pct, width = 20) => '█'.repeat(Math.round(pct / 100 * width)) + '░'.repeat(width - Math.round(pct / 100 * width));
|
|
289
|
+
const lines = [];
|
|
290
|
+
lines.push(`💡 Usage insights — last ${d} days`);
|
|
291
|
+
lines.push('━'.repeat(44));
|
|
292
|
+
lines.push(``);
|
|
293
|
+
lines.push(` 💰 ${fmtDollar(i.avg_cost_per_session)}/session · ${i.total_sessions} sessions · ${fmtDollar(i.total_cost)} total`);
|
|
294
|
+
if (i.project_costs.length > 0) {
|
|
295
|
+
lines.push(``);
|
|
296
|
+
lines.push(` 🗂 Top projects`);
|
|
297
|
+
const topTotal = i.project_costs.reduce((s, p) => s + p.total_cost, 0);
|
|
298
|
+
for (const p of i.project_costs.slice(0, 4)) {
|
|
299
|
+
const pct = topTotal > 0 ? Math.round(p.total_cost / topTotal * 100) : 0;
|
|
300
|
+
const name = (p.project.split('/').pop() ?? p.project).slice(0, 14).padEnd(14);
|
|
301
|
+
lines.push(` ${name} ${bar(pct)} ${fmtDollar(p.total_cost)} ${pct}%`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
lines.push(``);
|
|
305
|
+
lines.push(` ⚡ Cache ~${fmtDollar(i.cache_savings_usd)} saved · ${i.cache_hit_pct}% hit rate`);
|
|
306
|
+
lines.push(``);
|
|
307
|
+
lines.push(` 📊 ${i.output_input_ratio}× output/input · ${i.ratio_label}`);
|
|
308
|
+
lines.push(``);
|
|
309
|
+
const effTrend = i.efficiency_delta !== -999
|
|
310
|
+
? ` ${i.efficiency_delta > 0 ? `↑ +${i.efficiency_delta}` : i.efficiency_delta < 0 ? `↓ ${i.efficiency_delta}` : '→ same'} vs prev period`
|
|
311
|
+
: '';
|
|
312
|
+
lines.push(` 📈 Efficiency ${i.avg_efficiency}/100${effTrend} · ${i.total_loops} loops`);
|
|
313
|
+
if (i.hour_ranges.length > 0) {
|
|
314
|
+
lines.push(``);
|
|
315
|
+
lines.push(` ⏰ Activity by time of day`);
|
|
316
|
+
const maxCount = Math.max(...i.hour_ranges.map(r => r.count));
|
|
317
|
+
for (let j = 0; j < i.hour_ranges.length; j++) {
|
|
318
|
+
const r = i.hour_ranges[j];
|
|
319
|
+
const pct = maxCount > 0 ? Math.round(r.count / maxCount * 100) : 0;
|
|
320
|
+
lines.push(` ${r.emoji} ${r.from}–${r.to} ${bar(pct)} ${r.count} sessions`);
|
|
321
|
+
if (j < i.hour_ranges.length - 1)
|
|
322
|
+
lines.push('');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
lines.push(``);
|
|
326
|
+
lines.push('━'.repeat(44));
|
|
327
|
+
return lines.join('\n');
|
|
328
|
+
}
|
|
329
|
+
function toolGetModelBreakdown(days) {
|
|
330
|
+
const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
|
|
331
|
+
const models = db_1.dbOps.getModelBreakdown(d);
|
|
332
|
+
if (models.length === 0)
|
|
333
|
+
return `No model data in the last ${d} days.`;
|
|
334
|
+
const totalCost = models.reduce((s, m) => s + m.total_cost, 0);
|
|
335
|
+
const lines = [
|
|
336
|
+
`Model breakdown — last ${d} days`,
|
|
337
|
+
'',
|
|
338
|
+
];
|
|
339
|
+
for (const m of models) {
|
|
340
|
+
const pct = totalCost > 0 ? Math.round(m.total_cost / totalCost * 100) : 0;
|
|
341
|
+
const rawName = (m.model ?? 'unknown').replace(/^<|>$/g, '');
|
|
342
|
+
const name = rawName.padEnd(30);
|
|
343
|
+
const cost = fmtDollar(m.total_cost).padEnd(10);
|
|
344
|
+
lines.push(` ${name}${cost}${pct}% ${m.session_count} sessions`);
|
|
345
|
+
}
|
|
346
|
+
return lines.join('\n');
|
|
347
|
+
}
|
|
224
348
|
function toolGetWeeklyInsight(days) {
|
|
225
349
|
const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
|
|
226
350
|
const data = (0, insights_1.getWeeklyInsightData)(d);
|
|
@@ -239,25 +363,25 @@ function toolGetWeeklyInsight(days) {
|
|
|
239
363
|
`Tip: ${(0, insights_1.generateTip)(data)}`,
|
|
240
364
|
].join('\n');
|
|
241
365
|
}
|
|
242
|
-
function handleToolCall(name, args) {
|
|
243
|
-
const days = typeof args.days === 'number' ? args.days : 7;
|
|
366
|
+
async function handleToolCall(name, args) {
|
|
244
367
|
const sortBy = typeof args.sort_by === 'string' ? args.sort_by : 'cost';
|
|
245
368
|
switch (name) {
|
|
246
|
-
case 'get_quota_status':
|
|
369
|
+
case 'get_quota_status':
|
|
370
|
+
await (0, quota_tracker_1.refreshFromApi)();
|
|
371
|
+
return toolGetQuotaStatus();
|
|
247
372
|
case 'get_current_session': return toolGetCurrentSession();
|
|
248
|
-
case 'get_session_stats': return toolGetSessionStats(days);
|
|
249
|
-
case 'get_top_tools': return toolGetTopTools(days, sortBy);
|
|
250
|
-
case '
|
|
373
|
+
case 'get_session_stats': return toolGetSessionStats(typeof args.days === 'number' ? args.days : 7);
|
|
374
|
+
case 'get_top_tools': return toolGetTopTools(typeof args.days === 'number' ? args.days : 30, sortBy);
|
|
375
|
+
case 'get_usage_insights': return toolGetUsageInsights(typeof args.days === 'number' ? args.days : 7);
|
|
376
|
+
case 'get_model_breakdown': return toolGetModelBreakdown(typeof args.days === 'number' ? args.days : 7);
|
|
377
|
+
case 'get_weekly_insight': return toolGetWeeklyInsight(typeof args.days === 'number' ? args.days : 7);
|
|
251
378
|
default: return `Unknown tool: ${name}`;
|
|
252
379
|
}
|
|
253
380
|
}
|
|
254
|
-
function handleRequest(msg) {
|
|
381
|
+
async function handleRequest(msg) {
|
|
255
382
|
const { id, method, params } = msg;
|
|
256
|
-
if (id === undefined)
|
|
257
|
-
if (method === 'notifications/initialized')
|
|
258
|
-
return null;
|
|
383
|
+
if (id === undefined)
|
|
259
384
|
return null;
|
|
260
|
-
}
|
|
261
385
|
try {
|
|
262
386
|
switch (method) {
|
|
263
387
|
case 'initialize':
|
|
@@ -274,7 +398,7 @@ function handleRequest(msg) {
|
|
|
274
398
|
case 'tools/call': {
|
|
275
399
|
const toolName = params?.name;
|
|
276
400
|
const toolArgs = (params?.arguments ?? {});
|
|
277
|
-
const text = handleToolCall(toolName, toolArgs);
|
|
401
|
+
const text = await handleToolCall(toolName, toolArgs);
|
|
278
402
|
return {
|
|
279
403
|
jsonrpc: '2.0', id,
|
|
280
404
|
result: { content: [{ type: 'text', text }], isError: false }
|
|
@@ -301,10 +425,12 @@ rl.on('line', (line) => {
|
|
|
301
425
|
return;
|
|
302
426
|
try {
|
|
303
427
|
const msg = JSON.parse(trimmed);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
428
|
+
handleRequest(msg).then(response => {
|
|
429
|
+
if (response)
|
|
430
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
431
|
+
}).catch((e) => {
|
|
432
|
+
process.stderr.write(`[claudestat-mcp] Handler error: ${e.message}\n`);
|
|
433
|
+
});
|
|
308
434
|
}
|
|
309
435
|
catch (e) {
|
|
310
436
|
process.stderr.write(`[claudestat-mcp] Parse error: ${e.message}\n`);
|
|
@@ -312,4 +438,5 @@ rl.on('line', (line) => {
|
|
|
312
438
|
});
|
|
313
439
|
process.on('SIGTERM', () => process.exit(0));
|
|
314
440
|
process.on('SIGINT', () => process.exit(0));
|
|
441
|
+
// API quota is refreshed on-demand per get_quota_status call (disk cache throttles to 1 call/5min)
|
|
315
442
|
process.stderr.write(`[claudestat-mcp] Server ready (stdio, protocol ${PROTOCOL_VERSION})\n`);
|
package/dist/notifier.js
CHANGED
|
@@ -8,15 +8,33 @@ const child_process_1 = require("child_process");
|
|
|
8
8
|
const os_1 = __importDefault(require("os"));
|
|
9
9
|
function sendDesktopNotification(title, body) {
|
|
10
10
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
(
|
|
11
|
+
const platform = os_1.default.platform();
|
|
12
|
+
if (platform === 'darwin') {
|
|
13
|
+
const t = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
14
|
+
const b = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
15
|
+
(0, child_process_1.execSync)(`osascript -e 'display notification "${b}" with title "${t}"'`, { stdio: 'ignore', timeout: 5000 });
|
|
14
16
|
}
|
|
15
|
-
else if (
|
|
16
|
-
(0, child_process_1.execSync)(`notify-send "${title}" "${body}"`, { stdio: 'ignore' });
|
|
17
|
+
else if (platform === 'linux') {
|
|
18
|
+
(0, child_process_1.execSync)(`notify-send "${title}" "${body}"`, { stdio: 'ignore', timeout: 5000 });
|
|
19
|
+
}
|
|
20
|
+
else if (platform === 'win32') {
|
|
21
|
+
// UTF-16LE base64-encoded PowerShell to handle any special characters safely
|
|
22
|
+
const script = [
|
|
23
|
+
`Add-Type -AssemblyName System.Windows.Forms`,
|
|
24
|
+
`$n = New-Object System.Windows.Forms.NotifyIcon`,
|
|
25
|
+
`$n.Icon = [System.Drawing.SystemIcons]::Information`,
|
|
26
|
+
`$n.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info`,
|
|
27
|
+
`$n.BalloonTipTitle = '${title.replace(/'/g, "''")}'`,
|
|
28
|
+
`$n.BalloonTipText = '${body.replace(/'/g, "''")}'`,
|
|
29
|
+
`$n.Visible = $true`,
|
|
30
|
+
`$n.ShowBalloonTip(5000)`,
|
|
31
|
+
`Start-Sleep -Milliseconds 200`,
|
|
32
|
+
].join('; ');
|
|
33
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
34
|
+
(0, child_process_1.execSync)(`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`, { stdio: 'ignore', timeout: 8000 });
|
|
17
35
|
}
|
|
18
36
|
}
|
|
19
37
|
catch {
|
|
20
|
-
// notification
|
|
38
|
+
// notification unavailable — silent fallback
|
|
21
39
|
}
|
|
22
40
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - High cache ratio → positive: great cost efficiency
|
|
13
13
|
* - High avg cost → consider Haiku for simpler tasks
|
|
14
14
|
* - Low efficiency → linked to loops
|
|
15
|
+
* - Write vs Edit → suggest Edit for incremental changes
|
|
15
16
|
*/
|
|
16
17
|
export type InsightLevel = 'tip' | 'warning' | 'positive';
|
|
17
18
|
export interface PatternInsight {
|
package/dist/pattern-analyzer.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - High cache ratio → positive: great cost efficiency
|
|
14
14
|
* - High avg cost → consider Haiku for simpler tasks
|
|
15
15
|
* - Low efficiency → linked to loops
|
|
16
|
+
* - Write vs Edit → suggest Edit for incremental changes
|
|
16
17
|
*/
|
|
17
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
19
|
exports.analyzePatterns = analyzePatterns;
|
|
@@ -52,6 +53,18 @@ function analyzePatterns(toolCounts, stats) {
|
|
|
52
53
|
metric: `${bashPct}% Bash (${bashCount}) vs ${readGrep} Read+Grep+Glob`,
|
|
53
54
|
});
|
|
54
55
|
}
|
|
56
|
+
// ── Heavy Write vs Edit ────────────────────────────────────────────────────
|
|
57
|
+
const writeCount = get('Write');
|
|
58
|
+
const editCount = get('Edit');
|
|
59
|
+
const writePct = Math.round(writeCount / totalTools * 100);
|
|
60
|
+
if (writeCount > editCount * 3 && writeCount >= 10) {
|
|
61
|
+
insights.push({
|
|
62
|
+
level: 'tip',
|
|
63
|
+
title: 'Heavy Write vs Edit usage',
|
|
64
|
+
description: 'More than 3× Write calls vs Edit calls. Editing existing files is cheaper in tokens than writing from scratch. Consider using Edit for incremental changes instead of rewriting entire files.',
|
|
65
|
+
metric: `${writePct}% Write (${writeCount} writes vs ${editCount} edits)`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
55
68
|
// ── High loop rate ────────────────────────────────────────────────────────
|
|
56
69
|
if (stats.avg_loops >= 1.5) {
|
|
57
70
|
insights.push({
|
package/dist/quota-tracker.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface QuotaData {
|
|
|
38
38
|
planSource: 'config' | 'keychain' | 'inferred';
|
|
39
39
|
computedAt: number;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Llama a la API de Anthropic para obtener los % de quota exactos que muestra claude.ai.
|
|
43
|
+
* Actualiza apiCache si la llamada tiene éxito; de lo contrario, no hace nada (silent fallback).
|
|
44
|
+
* Debe llamarse periódicamente desde el daemon y al inicio del MCP server.
|
|
45
|
+
*/
|
|
46
|
+
export declare function refreshFromApi(): Promise<void>;
|
|
41
47
|
/**
|
|
42
48
|
* Calcula y retorna QuotaData.
|
|
43
49
|
* Usa caché de 30s para no re-leer todos los JSONL en cada request del dashboard.
|
package/dist/quota-tracker.js
CHANGED
|
@@ -19,6 +19,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
19
19
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
20
|
};
|
|
21
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.refreshFromApi = refreshFromApi;
|
|
22
23
|
exports.computeQuota = computeQuota;
|
|
23
24
|
exports.invalidateQuotaCache = invalidateQuotaCache;
|
|
24
25
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -35,33 +36,6 @@ const PLAN_LIMITS = {
|
|
|
35
36
|
const CYCLE_MS = 5 * 60 * 60 * 1000; // 5 horas en ms
|
|
36
37
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
37
38
|
const WINDOW_5MIN = 5 * 60 * 1000; // ventana de 5 min para agrupar actividad por modelo
|
|
38
|
-
/**
|
|
39
|
-
* Calcula el timestamp de reset usando ventana rolling real.
|
|
40
|
-
*
|
|
41
|
-
* Claude Code NO usa floor(now/5h) desde epoch UTC — usa una ventana rolling
|
|
42
|
-
* que empieza desde el primer mensaje del ciclo actual.
|
|
43
|
-
*
|
|
44
|
-
* Enfoque: buscar el primer mensaje humano en los últimos 5h de actividad.
|
|
45
|
-
* resetAt = primerMensaje.ts + 5h
|
|
46
|
-
*
|
|
47
|
-
* Si no hay mensajes en las últimas 5h → el ciclo ya reseteó, el próximo
|
|
48
|
-
* reset es en 5h desde el primer mensaje futuro (mostramos ~5h).
|
|
49
|
-
*/
|
|
50
|
-
function computeResetAt(entries, now) {
|
|
51
|
-
const fiveHoursAgo = now - CYCLE_MS;
|
|
52
|
-
const recentHuman = entries
|
|
53
|
-
.filter(e => e.type === 'human' && e.ts >= fiveHoursAgo)
|
|
54
|
-
.sort((a, b) => a.ts - b.ts);
|
|
55
|
-
if (recentHuman.length > 0) {
|
|
56
|
-
// Usamos el PRIMER mensaje humano (más antiguo) + 5h
|
|
57
|
-
// = momento en que el primer prompt de la ventana actual expira → cuota empieza a liberarse
|
|
58
|
-
// Los mensajes tool_result de sub-agentes ya están filtrados en el caller
|
|
59
|
-
return recentHuman[0].ts + CYCLE_MS;
|
|
60
|
-
}
|
|
61
|
-
// Sin actividad en las últimas 5h → cuota ya libre, no hay reset pendiente
|
|
62
|
-
// Retornar `now` hace que cycleResetMs = 0, la UI puede mostrar "Disponible"
|
|
63
|
-
return now;
|
|
64
|
-
}
|
|
65
39
|
function getWeekStart(now) {
|
|
66
40
|
// Lunes 00:00 hora local
|
|
67
41
|
const d = new Date(now);
|
|
@@ -210,6 +184,63 @@ function readAllEntries(sinceTs) {
|
|
|
210
184
|
catch { /* PROJECTS_DIR inaccesible */ }
|
|
211
185
|
return all;
|
|
212
186
|
}
|
|
187
|
+
// ─── API de uso de Anthropic (datos exactos = claude.ai/settings/usage) ──────
|
|
188
|
+
const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
189
|
+
const USAGE_API_BETA = 'oauth-2025-04-20';
|
|
190
|
+
const API_CACHE_TTL = 5 * 60000; // 5 minutes
|
|
191
|
+
const API_DISK_CACHE = path_1.default.join((0, paths_1.getClaudestatDir)(), 'api-cache.json');
|
|
192
|
+
let apiCache = null;
|
|
193
|
+
/**
|
|
194
|
+
* Llama a la API de Anthropic para obtener los % de quota exactos que muestra claude.ai.
|
|
195
|
+
* Actualiza apiCache si la llamada tiene éxito; de lo contrario, no hace nada (silent fallback).
|
|
196
|
+
* Debe llamarse periódicamente desde el daemon y al inicio del MCP server.
|
|
197
|
+
*/
|
|
198
|
+
async function refreshFromApi() {
|
|
199
|
+
// Shared disk cache: all processes (daemon + MCP) read/write the same file.
|
|
200
|
+
// This ensures at most 1 API call per API_CACHE_TTL across all processes.
|
|
201
|
+
try {
|
|
202
|
+
const raw = fs_1.default.readFileSync(API_DISK_CACHE, 'utf8');
|
|
203
|
+
const disk = JSON.parse(raw);
|
|
204
|
+
if (disk && Date.now() - disk.ts < API_CACHE_TTL) {
|
|
205
|
+
apiCache = disk;
|
|
206
|
+
invalidateQuotaCache();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch { /* no disk cache yet — proceed to API */ }
|
|
211
|
+
const token = (0, claude_auth_1.getOAuthAccessToken)();
|
|
212
|
+
if (!token)
|
|
213
|
+
return;
|
|
214
|
+
try {
|
|
215
|
+
const ctrl = new AbortController();
|
|
216
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
217
|
+
const res = await fetch(USAGE_API_URL, {
|
|
218
|
+
headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': USAGE_API_BETA },
|
|
219
|
+
signal: ctrl.signal,
|
|
220
|
+
});
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
process.stderr.write(`[claudestat] API quota fetch failed: HTTP ${res.status}\n`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const data = await res.json();
|
|
227
|
+
const fh = data.five_hour;
|
|
228
|
+
if (fh == null)
|
|
229
|
+
return;
|
|
230
|
+
apiCache = {
|
|
231
|
+
cyclePct: Math.round(fh.utilization),
|
|
232
|
+
weeklyPctAll: data.seven_day != null ? Math.round(data.seven_day.utilization) : 0,
|
|
233
|
+
cycleResetAt: fh.resets_at ? new Date(fh.resets_at).getTime() : 0,
|
|
234
|
+
ts: Date.now(),
|
|
235
|
+
};
|
|
236
|
+
try {
|
|
237
|
+
fs_1.default.writeFileSync(API_DISK_CACHE, JSON.stringify(apiCache), 'utf8');
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
invalidateQuotaCache();
|
|
241
|
+
}
|
|
242
|
+
catch { /* no network or keychain — silent fallback to JSONL */ }
|
|
243
|
+
}
|
|
213
244
|
// ─── Caché de 30 segundos ─────────────────────────────────────────────────────
|
|
214
245
|
let cache = null;
|
|
215
246
|
const CACHE_TTL = 30000; // 30 segundos
|
|
@@ -225,6 +256,16 @@ function computeQuota(forcePlan) {
|
|
|
225
256
|
if (!forcePlan && cache && now - cache.ts < CACHE_TTL) {
|
|
226
257
|
return cache.data;
|
|
227
258
|
}
|
|
259
|
+
// Load API data from disk cache if in-memory is stale (shared across processes)
|
|
260
|
+
if (!apiCache || now - apiCache.ts >= API_CACHE_TTL) {
|
|
261
|
+
try {
|
|
262
|
+
const raw = fs_1.default.readFileSync(API_DISK_CACHE, 'utf8');
|
|
263
|
+
const disk = JSON.parse(raw);
|
|
264
|
+
if (disk && now - disk.ts < API_CACHE_TTL)
|
|
265
|
+
apiCache = disk;
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
}
|
|
228
269
|
const weekStart = getWeekStart(now);
|
|
229
270
|
const thirtyMinAgo = now - 30 * 60 * 1000;
|
|
230
271
|
// Leer entradas relevantes (última semana + un poco más para detección de plan)
|
|
@@ -251,7 +292,7 @@ function computeQuota(forcePlan) {
|
|
|
251
292
|
const limits = PLAN_LIMITS[plan];
|
|
252
293
|
// ─ Ciclo 5h: ventana deslizante [now-5h, now] ─
|
|
253
294
|
const fiveHAgo = now - CYCLE_MS;
|
|
254
|
-
const cycleResetAt =
|
|
295
|
+
const cycleResetAt = (Math.floor(now / CYCLE_MS) + 1) * CYCLE_MS; // epoch-aligned, matches claude.ai
|
|
255
296
|
const cycleStart = fiveHAgo; // inicio real de la ventana de conteo
|
|
256
297
|
// Basado en tokens (como claude.ai/settings/usage)
|
|
257
298
|
const cycleEntries = entries.filter(e => e.ts >= fiveHAgo);
|
|
@@ -297,14 +338,20 @@ function computeQuota(forcePlan) {
|
|
|
297
338
|
const weeklyPctAll = limits.weeklyHoursSonnet + limits.weeklyHoursOpus > 0
|
|
298
339
|
? Math.min(100, Math.round((weeklyHoursSonnet + weeklyHoursOpus) / (limits.weeklyHoursSonnet + limits.weeklyHoursOpus) * 100))
|
|
299
340
|
: 0;
|
|
341
|
+
// Override with API data if fresh — matches claude.ai exactly
|
|
342
|
+
const apiFresh = apiCache != null && Date.now() - apiCache.ts < API_CACHE_TTL;
|
|
343
|
+
const resolvedCyclePct = apiFresh ? apiCache.cyclePct : cyclePct;
|
|
344
|
+
const resolvedWeeklyPctAll = apiFresh ? apiCache.weeklyPctAll : weeklyPctAll;
|
|
345
|
+
const resolvedCycleResetAt = (apiFresh && apiCache.cycleResetAt > 0) ? apiCache.cycleResetAt : cycleResetAt;
|
|
346
|
+
const resolvedCycleResetMs = Math.max(0, resolvedCycleResetAt - now);
|
|
300
347
|
const data = {
|
|
301
348
|
cyclePrompts: cycleEntries.filter(e => e.type === 'human').length,
|
|
302
349
|
cycleLimit: limits.prompts5h,
|
|
303
|
-
cyclePct,
|
|
350
|
+
cyclePct: resolvedCyclePct,
|
|
304
351
|
cycleTokens,
|
|
305
352
|
cycleLimitTokens: limits.tokens5h,
|
|
306
|
-
cycleResetMs,
|
|
307
|
-
cycleResetAt,
|
|
353
|
+
cycleResetMs: resolvedCycleResetMs,
|
|
354
|
+
cycleResetAt: resolvedCycleResetAt,
|
|
308
355
|
cycleStartTs: cycleStart,
|
|
309
356
|
weeklyHoursSonnet,
|
|
310
357
|
weeklyHoursOpus,
|
|
@@ -314,7 +361,7 @@ function computeQuota(forcePlan) {
|
|
|
314
361
|
weeklyTokensHaiku,
|
|
315
362
|
weeklyLimitSonnet: limits.weeklyHoursSonnet,
|
|
316
363
|
weeklyLimitOpus: limits.weeklyHoursOpus,
|
|
317
|
-
weeklyPctAll,
|
|
364
|
+
weeklyPctAll: resolvedWeeklyPctAll,
|
|
318
365
|
burnRateTokensPerMin,
|
|
319
366
|
detectedPlan: plan,
|
|
320
367
|
planSource,
|