@statforge/claudestat 1.2.0 β†’ 1.2.3

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/dist/insights.js CHANGED
@@ -1,10 +1,16 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.getWeeklyInsightData = getWeeklyInsightData;
4
7
  exports.generateTip = generateTip;
8
+ exports.getUsageInsights = getUsageInsights;
9
+ exports.renderInsights = renderInsights;
5
10
  exports.shouldShowInsight = shouldShowInsight;
6
11
  exports.markInsightShown = markInsightShown;
7
12
  exports.renderWeeklyInsight = renderWeeklyInsight;
13
+ const path_1 = __importDefault(require("path"));
8
14
  const db_1 = require("./db");
9
15
  const WEEK_MS = 7 * 86400000;
10
16
  const META_KEY = 'last_insight_at';
@@ -58,6 +64,145 @@ function generateTip(d) {
58
64
  }
59
65
  return 'Enable quota alerts with "claudestat config --alerts true" to avoid surprise limits';
60
66
  }
67
+ function getUsageInsights(days = 7) {
68
+ const agg = db_1.dbOps.getWeeklyInsight(days);
69
+ const aggTotal = db_1.dbOps.getWeeklyInsight(days * 2);
70
+ const prevSessions = aggTotal.total_sessions - agg.total_sessions;
71
+ const effDelta = prevSessions > 2
72
+ ? Math.round(agg.avg_efficiency -
73
+ (aggTotal.avg_efficiency * aggTotal.total_sessions - agg.avg_efficiency * agg.total_sessions) / prevSessions)
74
+ : -999;
75
+ const totalInputWithCache = agg.input_tokens + agg.cache_read;
76
+ const cacheHitPct = totalInputWithCache > 0
77
+ ? Math.min(100, Math.round(agg.cache_read / totalInputWithCache * 100))
78
+ : 0;
79
+ const outputInputRatio = agg.input_tokens > 0
80
+ ? parseFloat((agg.output_tokens / agg.input_tokens).toFixed(1))
81
+ : 0;
82
+ const ratioLabel = outputInputRatio > 10 ? 'cache-heavy workload'
83
+ : outputInputRatio > 5 ? 'generation-heavy'
84
+ : outputInputRatio > 2 ? 'balanced'
85
+ : 'reading-heavy';
86
+ const CACHE_READ_PRICE = {
87
+ 'claude-haiku-4-5-20251001': 0.30 / 1000000,
88
+ 'claude-sonnet-4-6': 2.70 / 1000000,
89
+ 'claude-opus-4-6': 3.00 / 1000000,
90
+ };
91
+ const DEFAULT_CACHE_PRICE = 2.70 / 1000000;
92
+ const cacheByModel = db_1.dbOps.getCacheReadByModel(days);
93
+ const cacheSavings = cacheByModel.reduce((total, row) => {
94
+ const price = CACHE_READ_PRICE[row.model] ?? DEFAULT_CACHE_PRICE;
95
+ return total + row.cache_read * price;
96
+ }, 0);
97
+ return {
98
+ days,
99
+ total_sessions: agg.total_sessions,
100
+ total_cost: agg.total_cost,
101
+ avg_cost_per_session: agg.total_sessions > 0 ? agg.total_cost / agg.total_sessions : 0,
102
+ cache_savings_usd: cacheSavings,
103
+ cache_hit_pct: cacheHitPct,
104
+ output_input_ratio: outputInputRatio,
105
+ ratio_label: ratioLabel,
106
+ avg_efficiency: Math.round(agg.avg_efficiency),
107
+ efficiency_delta: effDelta,
108
+ total_loops: agg.total_loops,
109
+ project_costs: db_1.dbOps.getProjectCosts(days),
110
+ hour_ranges: (() => {
111
+ const hours = db_1.dbOps.getHourlyDistribution(days);
112
+ const dawn = hours.filter(h => h.hour >= 0 && h.hour <= 5).reduce((s, h) => s + h.session_count, 0);
113
+ const morn = hours.filter(h => h.hour >= 6 && h.hour <= 11).reduce((s, h) => s + h.session_count, 0);
114
+ const after = hours.filter(h => h.hour >= 12 && h.hour <= 17).reduce((s, h) => s + h.session_count, 0);
115
+ const night = hours.filter(h => h.hour >= 18 && h.hour <= 23).reduce((s, h) => s + h.session_count, 0);
116
+ return [
117
+ { emoji: 'πŸŒ™', from: '00:00', to: '05:59', count: dawn },
118
+ { emoji: 'πŸŒ…', from: '06:00', to: '11:59', count: morn },
119
+ { emoji: 'β˜€οΈ', from: '12:00', to: '17:59', count: after },
120
+ { emoji: 'πŸŒ†', from: '18:00', to: '23:59', count: night },
121
+ ].filter(r => r.count > 0);
122
+ })(),
123
+ };
124
+ }
125
+ function renderInsights(d) {
126
+ const R = '\x1b[0m';
127
+ const B = '\x1b[1m';
128
+ const D = '\x1b[2m';
129
+ const G = '\x1b[32m';
130
+ const Y = '\x1b[33m';
131
+ const C = '\x1b[36m';
132
+ const M = '\x1b[35m';
133
+ const bar = (pct, width = 20) => 'β–ˆ'.repeat(Math.round(pct / 100 * width)) + 'β–‘'.repeat(width - Math.round(pct / 100 * width));
134
+ const fmtDollar = (n) => n < 0.01 ? '< $0.01' : `$${n.toFixed(2)}`;
135
+ const lines = [];
136
+ lines.push(`\n${B}πŸ’‘ claudestat insights${R} ${D}last ${d.days} days${R}`);
137
+ lines.push('━'.repeat(44));
138
+ // Cost summary
139
+ lines.push(`\n πŸ’° ${B}${fmtDollar(d.avg_cost_per_session)}/session${R} Β· ${d.total_sessions} sessions Β· ${fmtDollar(d.total_cost)} total`);
140
+ // Top projects
141
+ if (d.project_costs.length > 0) {
142
+ lines.push(`\n πŸ—‚ Top projects`);
143
+ const topTotal = d.project_costs.reduce((s, p) => s + p.total_cost, 0);
144
+ const shown = d.project_costs.slice(0, 4);
145
+ const otherCost = d.total_cost - shown.reduce((s, p) => s + p.total_cost, 0);
146
+ for (let i = 0; i < shown.length; i++) {
147
+ const p = shown[i];
148
+ const pct = topTotal > 0 ? Math.round(p.total_cost / topTotal * 100) : 0;
149
+ const name = path_1.default.basename(p.project).slice(0, 14).padEnd(14);
150
+ lines.push(` ${C}${name}${R} ${bar(pct)} ${fmtDollar(p.total_cost)} ${D}${pct}%${R}`);
151
+ if (i < shown.length - 1 || (otherCost > 0.01 && d.project_costs.length > 4))
152
+ lines.push('');
153
+ }
154
+ if (otherCost > 0.01 && d.project_costs.length > 4) {
155
+ const pct = topTotal > 0 ? Math.round(otherCost / topTotal * 100) : 0;
156
+ lines.push(` ${'other'.padEnd(14)} ${bar(pct)} ${fmtDollar(otherCost)} ${D}${pct}%${R}`);
157
+ }
158
+ }
159
+ // Cache savings
160
+ const savingsLabel = d.cache_savings_usd >= 0.01
161
+ ? `${G}~${fmtDollar(d.cache_savings_usd)}${R} saved`
162
+ : 'no savings yet';
163
+ lines.push(`\n ⚑ Cache ${savingsLabel} · ${d.cache_hit_pct}% hit rate`);
164
+ // Output/input ratio
165
+ lines.push(`\n πŸ“Š ${B}${d.output_input_ratio}Γ—${R} output/input Β· ${D}${d.ratio_label}${R}`);
166
+ // Efficiency trend
167
+ let effTrend = '';
168
+ if (d.efficiency_delta !== -999) {
169
+ const arrow = d.efficiency_delta > 0
170
+ ? `${G}↑ +${d.efficiency_delta}${R}`
171
+ : d.efficiency_delta < 0 ? `${Y}↓ ${d.efficiency_delta}${R}` : 'β†’ same';
172
+ effTrend = ` ${arrow} vs prev period`;
173
+ }
174
+ const loopLabel = d.total_loops > 0 ? ` Β· ${Y}${d.total_loops} loops${R}` : '';
175
+ lines.push(`\n πŸ“ˆ Efficiency ${B}${d.avg_efficiency}/100${R}${effTrend}${loopLabel}`);
176
+ // Activity by time range
177
+ if (d.hour_ranges.length > 0) {
178
+ lines.push(`\n ⏰ Activity by time of day`);
179
+ const maxCount = Math.max(...d.hour_ranges.map(r => r.count));
180
+ for (let i = 0; i < d.hour_ranges.length; i++) {
181
+ const r = d.hour_ranges[i];
182
+ const pct = maxCount > 0 ? Math.round(r.count / maxCount * 100) : 0;
183
+ lines.push(` ${r.emoji} ${D}${r.from}–${r.to}${R} ${M}${bar(pct)}${R} ${D}${r.count} sessions${R}`);
184
+ if (i < d.hour_ranges.length - 1)
185
+ lines.push('');
186
+ }
187
+ }
188
+ // Model breakdown
189
+ const models = db_1.dbOps.getModelBreakdown(d.days);
190
+ const modelsWithCost = models.filter(m => m.total_cost > 0);
191
+ if (modelsWithCost.length >= 2) {
192
+ lines.push(`\n πŸ€– Models`);
193
+ for (let i = 0; i < modelsWithCost.length; i++) {
194
+ const m = modelsWithCost[i];
195
+ const pct = d.total_cost > 0 ? Math.round(m.total_cost / d.total_cost * 100) : 0;
196
+ const rawName = (m.model ?? 'unknown').replace(/^<|>$/g, '');
197
+ const name = rawName.slice(0, 28).padEnd(28);
198
+ lines.push(` ${C}${name}${R} ${bar(pct)} ${fmtDollar(m.total_cost)} ${D}${pct}% Β· ${m.session_count} sessions${R}`);
199
+ if (i < modelsWithCost.length - 1)
200
+ lines.push('');
201
+ }
202
+ }
203
+ lines.push('\n' + '━'.repeat(44) + '\n');
204
+ return lines.join('\n');
205
+ }
61
206
  function shouldShowInsight() {
62
207
  const last = db_1.dbOps.getMeta(META_KEY);
63
208
  if (!last)
@@ -75,31 +220,38 @@ function renderWeeklyInsight(d) {
75
220
  return `${Math.round(n / 1000)}K`;
76
221
  return n.toString();
77
222
  };
78
- const fmtCost = (n) => `$${n.toFixed(2)}`;
79
- const fmtDate = (ts) => {
80
- const d = new Date(ts);
81
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
82
- };
83
223
  const R = '\x1b[0m';
84
224
  const B = '\x1b[1m';
85
225
  const D = '\x1b[2m';
226
+ const G = '\x1b[32m';
227
+ const Y = '\x1b[33m';
86
228
  const C = '\x1b[36m';
229
+ const bar = (pct, width = 20) => {
230
+ const filled = Math.round(Math.min(pct, 100) / 100 * width);
231
+ const color = pct >= 90 ? '\x1b[31m' : pct >= 70 ? '\x1b[33m' : '\x1b[32m';
232
+ return `${color}${'β–ˆ'.repeat(filled)}${R}${D}${'β–‘'.repeat(width - filled)}${R}`;
233
+ };
234
+ const fmtDate = (ts) => {
235
+ const dt = new Date(ts);
236
+ return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
237
+ };
87
238
  const lines = [];
88
- lines.push(`\n${B}πŸ“Š claudestat weekly insight${R} ${D}(${fmtDate(d.week_start)} – ${fmtDate(d.week_end)})${R}`);
89
- lines.push(`${'─'.repeat(60)}`);
90
- lines.push(` Sessions: ${d.total_sessions} Β· Cost: ${fmtCost(d.total_cost)} Β· Loops: ${d.total_loops}`);
91
- lines.push(` Top tool: ${d.top_tool} (${d.top_tool_cost_pct}% of cost) Β· Efficiency: ${d.avg_efficiency}/100`);
92
- const cacheLabel = d.total_sessions > 0
93
- ? ` Β· Cache hit: ${d.cache_hit_pct}%`
94
- : '';
95
- const tokLabel = d.input_tokens + d.output_tokens > 0
96
- ? ` Β· Tokens: ${fmtTok(d.input_tokens)}+${fmtTok(d.output_tokens)}`
97
- : '';
98
- if (cacheLabel || tokLabel) {
99
- lines.push(` ${D}${tokLabel}${cacheLabel}${R}`);
100
- }
101
- lines.push(`${'─'.repeat(60)}`);
102
- lines.push(` ${C}⚑${R} Tip: ${generateTip(d)}`);
239
+ lines.push(`\n${B}πŸ“Š claudestat weekly${R} ${D}${fmtDate(d.week_start)} – ${fmtDate(d.week_end)}${R}`);
240
+ lines.push('━'.repeat(44));
241
+ lines.push('');
242
+ lines.push(` πŸ’° ${B}$${d.total_cost.toFixed(2)}${R} total Β· ${B}${d.total_sessions}${R} sessions Β· ${B}${d.total_loops}${R} loops`);
243
+ lines.push('');
244
+ lines.push(` πŸ”§ Top tool ${B}${d.top_tool}${R} ${D}${d.top_tool_cost_pct}% of cost${R}`);
245
+ lines.push('');
246
+ lines.push(` πŸ“ˆ Efficiency ${bar(d.avg_efficiency)} ${B}${d.avg_efficiency}/100${R}`);
247
+ lines.push('');
248
+ lines.push(` πŸ’Ύ Cache hit ${bar(d.cache_hit_pct)} ${B}${d.cache_hit_pct}%${R}`);
249
+ lines.push('');
250
+ lines.push(` πŸ“¦ Tokens ${D}${fmtTok(d.input_tokens)} in + ${fmtTok(d.output_tokens)} out${R}`);
251
+ lines.push('');
252
+ lines.push(` ${C}⚑ Tip:${R} ${generateTip(d)}`);
253
+ lines.push('');
254
+ lines.push('━'.repeat(44));
103
255
  lines.push('');
104
256
  return lines.join('\n');
105
257
  }
package/dist/install.js CHANGED
@@ -20,6 +20,7 @@ exports.uninstallHooks = uninstallHooks;
20
20
  const fs_1 = __importDefault(require("fs"));
21
21
  const path_1 = __importDefault(require("path"));
22
22
  const readline_1 = __importDefault(require("readline"));
23
+ const child_process_1 = require("child_process");
23
24
  const paths_1 = require("./paths");
24
25
  const config_1 = require("./config");
25
26
  const CLAUDESTAT_DIR = (0, paths_1.getClaudestatDir)();
@@ -98,7 +99,9 @@ async function runInstall() {
98
99
  }
99
100
  else {
100
101
  showInstallStatus();
102
+ installMcp();
101
103
  }
104
+ process.exit(0);
102
105
  }
103
106
  async function runWizard() {
104
107
  const nonInteractive = !process.stdin.isTTY;
@@ -150,6 +153,42 @@ async function runWizard() {
150
153
  }
151
154
  // Paso 5: instalar hooks
152
155
  installHooks();
156
+ // Paso 6: registrar MCP server en Claude Code
157
+ installMcp();
158
+ }
159
+ function installMcp() {
160
+ const nodeExec = process.execPath;
161
+ const mcpScript = path_1.default.join(__dirname, 'mcp-server.js');
162
+ const manualCmd = `claude mcp add claudestat -s user -- "${nodeExec}" --disable-warning=ExperimentalWarning "${mcpScript}"`;
163
+ try {
164
+ const result = (0, child_process_1.spawnSync)('claude', ['mcp', 'list'], { encoding: 'utf8', timeout: 15000 });
165
+ const list = (result.stdout ?? '') + (result.stderr ?? '');
166
+ const mcpLine = list.split('\n').find((l) => l.includes('claudestat'));
167
+ if (mcpLine && !mcpLine.includes('Failed') && !mcpLine.includes('βœ—')) {
168
+ console.log(' (already registered): MCP server');
169
+ return;
170
+ }
171
+ if (mcpLine) {
172
+ // Registered but failing β€” remove from both scopes and re-register
173
+ (0, child_process_1.spawnSync)('claude', ['mcp', 'remove', 'claudestat', '-s', 'user'], { encoding: 'utf8' });
174
+ (0, child_process_1.spawnSync)('claude', ['mcp', 'remove', 'claudestat', '-s', 'local'], { encoding: 'utf8' });
175
+ }
176
+ }
177
+ catch {
178
+ console.log('\n⚠ Could not reach "claude" CLI β€” skipping MCP setup.');
179
+ console.log(' To register manually:');
180
+ console.log(` ${manualCmd}\n`);
181
+ return;
182
+ }
183
+ try {
184
+ (0, child_process_1.execSync)(manualCmd, { stdio: 'pipe' });
185
+ console.log('βœ“ MCP server registered (user scope)\n');
186
+ }
187
+ catch (err) {
188
+ console.log('\n⚠ MCP registration failed.');
189
+ console.log(' To register manually:');
190
+ console.log(` ${manualCmd}\n`);
191
+ }
153
192
  }
154
193
  function showInstallStatus() {
155
194
  const cfg = (0, config_1.readConfig)();
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  /**
3
3
  * mcp-server.ts β€” MCP (Model Context Protocol) server for claudestat
4
4
  *
@@ -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.0';
55
- const PROTOCOL_VERSION = '2025-06-18';
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 β€” ${q.detectedPlan.toUpperCase()} plan`,
177
+ `Quota status β€” ${planLabel}`,
146
178
  ``,
147
- `5h cycle: ${q.cyclePrompts}/${q.cycleLimit} prompts (${q.cyclePct}%) Β· resets in ${resetLabel}`,
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': return toolGetQuotaStatus();
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 'get_weekly_insight': return toolGetWeeklyInsight(days);
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
- const response = handleRequest(msg);
305
- if (response) {
306
- process.stdout.write(JSON.stringify(response) + '\n');
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
- if (os_1.default.platform() === 'darwin') {
12
- const escaped = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
13
- (0, child_process_1.execSync)(`osascript -e 'display notification "${escaped}" with title "${title}"'`, { stdio: 'ignore' });
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 (os_1.default.platform() === 'linux') {
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 not available β€” already logged to console
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 {
@@ -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({
@@ -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.