@zhangferry-dev/tokendash 1.6.0 → 1.6.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.
Files changed (60) hide show
  1. package/README.md +146 -83
  2. package/dist/client/assets/index-Bw503sNp.css +1 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/client/popover.html +4 -3
  5. package/dist/daemon.cjs +3306 -0
  6. package/dist/daemon.cjs.map +7 -0
  7. package/dist/electron-server.cjs +1043 -27
  8. package/dist/electron-server.cjs.map +4 -4
  9. package/dist/server/daemon.d.ts +12 -0
  10. package/dist/server/daemon.js +176 -0
  11. package/dist/server/index.js +39 -13
  12. package/dist/server/insightsCalculator.d.ts +15 -0
  13. package/dist/server/insightsCalculator.js +276 -0
  14. package/dist/server/quota/adapter.d.ts +47 -0
  15. package/dist/server/quota/adapter.js +41 -0
  16. package/dist/server/quota/adapters/claude.d.ts +2 -0
  17. package/dist/server/quota/adapters/claude.js +124 -0
  18. package/dist/server/quota/adapters/codex.d.ts +2 -0
  19. package/dist/server/quota/adapters/codex.js +188 -0
  20. package/dist/server/quota/adapters/glm.d.ts +2 -0
  21. package/dist/server/quota/adapters/glm.js +133 -0
  22. package/dist/server/quota/adapters/kimi.d.ts +2 -0
  23. package/dist/server/quota/adapters/kimi.js +184 -0
  24. package/dist/server/quota/adapters/minimax.d.ts +2 -0
  25. package/dist/server/quota/adapters/minimax.js +77 -0
  26. package/dist/server/quota/cache.d.ts +20 -0
  27. package/dist/server/quota/cache.js +44 -0
  28. package/dist/server/quota/credentialsFile.d.ts +13 -0
  29. package/dist/server/quota/credentialsFile.js +23 -0
  30. package/dist/server/quota/helpers.d.ts +39 -0
  31. package/dist/server/quota/helpers.js +93 -0
  32. package/dist/server/quota/index.d.ts +5 -0
  33. package/dist/server/quota/index.js +23 -0
  34. package/dist/server/quota/quotaService.d.ts +37 -0
  35. package/dist/server/quota/quotaService.js +141 -0
  36. package/dist/server/quota/schemas.d.ts +358 -0
  37. package/dist/server/quota/schemas.js +53 -0
  38. package/dist/server/quota/types.d.ts +65 -0
  39. package/dist/server/quota/types.js +10 -0
  40. package/dist/server/routes/api.d.ts +6 -1
  41. package/dist/server/routes/api.js +26 -1
  42. package/dist/server/routes/insights.d.ts +2 -0
  43. package/dist/server/routes/insights.js +155 -0
  44. package/package.json +6 -10
  45. package/resources/icon-1024.png +0 -0
  46. package/resources/icon.icns +0 -0
  47. package/resources/icon.png +0 -0
  48. package/resources/product_menu.png +0 -0
  49. package/resources/readme-hero.png +0 -0
  50. package/dist/client/assets/index-_yA9tOzZ.css +0 -1
  51. package/electron/main.cjs +0 -490
  52. package/electron/main.js +0 -291
  53. package/electron/preload.cjs +0 -36
  54. package/electron/trayBadge.cjs +0 -27
  55. package/electron/trayBadge.js +0 -30
  56. package/electron/trayHelper +0 -0
  57. package/electron/trayHelper.swift +0 -152
  58. package/electron/updateService.cjs +0 -148
  59. package/electron-builder.yml +0 -20
  60. /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
@@ -0,0 +1,12 @@
1
+ /**
2
+ * TokenDash Daemon — headless Node.js server for the Swift menu bar app.
3
+ *
4
+ * Usage: node dist/daemon.cjs [--port <number>]
5
+ *
6
+ * The Swift app spawns this process and manages its lifecycle.
7
+ * Communication happens via:
8
+ * - ~/.tokendash/daemon.pid — PID file (for process management)
9
+ * - ~/.tokendash/daemon.port — actual listening port (for Swift discovery)
10
+ * - localhost HTTP API — all existing /api/* routes
11
+ */
12
+ export {};
@@ -0,0 +1,176 @@
1
+ /**
2
+ * TokenDash Daemon — headless Node.js server for the Swift menu bar app.
3
+ *
4
+ * Usage: node dist/daemon.cjs [--port <number>]
5
+ *
6
+ * The Swift app spawns this process and manages its lifecycle.
7
+ * Communication happens via:
8
+ * - ~/.tokendash/daemon.pid — PID file (for process management)
9
+ * - ~/.tokendash/daemon.port — actual listening port (for Swift discovery)
10
+ * - localhost HTTP API — all existing /api/* routes
11
+ */
12
+ import { createApp } from './index.js';
13
+ import http from 'node:http';
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ // ---------------------------------------------------------------------------
18
+ // Paths
19
+ // ---------------------------------------------------------------------------
20
+ const DATA_DIR = join(homedir(), '.tokendash');
21
+ const PID_FILE = join(DATA_DIR, 'daemon.pid');
22
+ const PORT_FILE = join(DATA_DIR, 'daemon.port');
23
+ function ensureDataDir() {
24
+ if (!existsSync(DATA_DIR))
25
+ mkdirSync(DATA_DIR, { recursive: true });
26
+ }
27
+ function writePidFile() {
28
+ ensureDataDir();
29
+ writeFileSync(PID_FILE, String(process.pid), 'utf8');
30
+ }
31
+ function writePortFile(port) {
32
+ ensureDataDir();
33
+ writeFileSync(PORT_FILE, String(port), 'utf8');
34
+ }
35
+ function cleanupFiles() {
36
+ try {
37
+ if (existsSync(PID_FILE))
38
+ unlinkSync(PID_FILE);
39
+ }
40
+ catch (_) { }
41
+ try {
42
+ if (existsSync(PORT_FILE))
43
+ unlinkSync(PORT_FILE);
44
+ }
45
+ catch (_) { }
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Port fallback
49
+ // ---------------------------------------------------------------------------
50
+ function resolvePort() {
51
+ const args = process.argv.slice(2);
52
+ for (let i = 0; i < args.length; i++) {
53
+ if (args[i] === '--port' && i + 1 < args.length) {
54
+ const v = parseInt(args[i + 1], 10);
55
+ if (Number.isInteger(v) && v > 0)
56
+ return v;
57
+ }
58
+ }
59
+ const envPort = process.env.TOKENDASH_PORT ? parseInt(process.env.TOKENDASH_PORT, 10) : 0;
60
+ if (Number.isInteger(envPort) && envPort > 0)
61
+ return envPort;
62
+ return 3456;
63
+ }
64
+ function listen(app, port) {
65
+ return new Promise((resolve, reject) => {
66
+ const server = app.listen(port);
67
+ const onListen = () => { cleanup(); resolve(server); };
68
+ const onError = (err) => { cleanup(); reject(err); };
69
+ const cleanup = () => { server.off('listening', onListen); server.off('error', onError); };
70
+ server.once('listening', onListen);
71
+ server.once('error', onError);
72
+ });
73
+ }
74
+ async function listenWithFallback(app, preferredPort) {
75
+ let port = preferredPort;
76
+ for (let attempt = 0; attempt < 20; attempt++, port++) {
77
+ try {
78
+ const server = await listen(app, port);
79
+ return { server, port };
80
+ }
81
+ catch (error) {
82
+ const err = error;
83
+ if (err.code !== 'EADDRINUSE')
84
+ throw error;
85
+ }
86
+ }
87
+ throw new Error(`No available port starting from ${preferredPort}`);
88
+ }
89
+ // ---------------------------------------------------------------------------
90
+ // Stale daemon check
91
+ // ---------------------------------------------------------------------------
92
+ function killStaleDaemon() {
93
+ if (!existsSync(PID_FILE))
94
+ return false;
95
+ try {
96
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
97
+ if (!Number.isInteger(pid) || pid <= 0)
98
+ return false;
99
+ // Send SIGTERM to stale process (throws if PID doesn't exist)
100
+ process.kill(pid, 0); // check if alive
101
+ // Process exists — try to kill it
102
+ process.kill(pid, 'SIGTERM');
103
+ // Give it a moment
104
+ const deadline = Date.now() + 2000;
105
+ while (Date.now() < deadline) {
106
+ try {
107
+ process.kill(pid, 0);
108
+ }
109
+ catch {
110
+ break;
111
+ }
112
+ }
113
+ return true;
114
+ }
115
+ catch {
116
+ // Process doesn't exist — stale PID file
117
+ cleanupFiles();
118
+ return false;
119
+ }
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Main
123
+ // ---------------------------------------------------------------------------
124
+ async function main() {
125
+ // Kill any stale daemon
126
+ killStaleDaemon();
127
+ const preferredPort = resolvePort();
128
+ const distDir = join(import.meta.url.replace('file://', ''), '..');
129
+ const app = createApp(preferredPort, distDir);
130
+ const { server, port } = await listenWithFallback(app, preferredPort);
131
+ writePidFile();
132
+ writePortFile(port);
133
+ // Warm up cache: pre-parse JSONL for all agents so first Swift fetch is fast
134
+ try {
135
+ const agentsRes = await new Promise((resolve, reject) => {
136
+ http.get(`http://127.0.0.1:${port}/api/agents`, (res) => {
137
+ let body = '';
138
+ res.on('data', (chunk) => { body += chunk; });
139
+ res.on('end', () => { try {
140
+ resolve(JSON.parse(body));
141
+ }
142
+ catch (e) {
143
+ reject(e);
144
+ } });
145
+ }).on('error', reject);
146
+ });
147
+ const agents = agentsRes?.available ?? ['claude'];
148
+ await Promise.all(agents.map((agent) => new Promise((resolve) => {
149
+ http.get(`http://127.0.0.1:${port}/api/daily?agent=${agent}`, (res) => {
150
+ res.resume();
151
+ res.on('end', () => resolve());
152
+ }).on('error', () => resolve());
153
+ })));
154
+ }
155
+ catch (_) {
156
+ // Warm-up is best-effort; don't block startup
157
+ }
158
+ // Graceful shutdown
159
+ const shutdown = () => {
160
+ cleanupFiles();
161
+ server.close(() => process.exit(0));
162
+ // Force exit after 5s if server.close hangs
163
+ setTimeout(() => process.exit(0), 5000);
164
+ };
165
+ process.on('SIGTERM', shutdown);
166
+ process.on('SIGINT', shutdown);
167
+ process.on('uncaughtException', (err) => {
168
+ console.error('[tokendash-daemon] uncaught:', err);
169
+ shutdown();
170
+ });
171
+ }
172
+ main().catch((err) => {
173
+ console.error('[tokendash-daemon] fatal:', err);
174
+ cleanupFiles();
175
+ process.exit(1);
176
+ });
@@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { registerApiRoutes } from './routes/api.js';
6
6
  import { detectAvailableAgents } from './agentDetection.js';
7
- import open from 'open';
8
7
  const CLI_USAGE = [
9
8
  'Usage:',
10
9
  ' tokendash',
@@ -12,11 +11,22 @@ const CLI_USAGE = [
12
11
  ' tokendash --port <number> [--no-open]',
13
12
  ' tokendash --tray [--port <number>]',
14
13
  ].join('\n');
14
+ const PACKAGE_NAME = '@zhangferry-dev/tokendash';
15
15
  function getPackageVersion() {
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = dirname(__filename);
18
- const packageJson = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
19
- return packageJson.version ?? 'unknown';
18
+ const packageJsonPaths = [
19
+ join(__dirname, '..', '..', 'package.json'), // dist/server/index.js
20
+ join(__dirname, '..', 'package.json'), // bundled server entrypoint
21
+ ];
22
+ for (const packageJsonPath of packageJsonPaths) {
23
+ if (!existsSync(packageJsonPath))
24
+ continue;
25
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
26
+ if (packageJson.version)
27
+ return packageJson.version;
28
+ }
29
+ return 'unknown';
20
30
  }
21
31
  function exitWithCliError(message) {
22
32
  console.error(message);
@@ -127,7 +137,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
127
137
  // The CLI entrypoint runs from dist/server/index.js while the Vite assets are
128
138
  // emitted to dist/client. Resolve the production asset base to dist instead
129
139
  // of dist/server so / resolves to dist/client/index.html in installed npm
130
- // packages. Electron passes dist explicitly and is unaffected by this branch.
140
+ // packages. The native app passes dist explicitly and is unaffected by this branch.
131
141
  if (basename(moduleDir) === 'server') {
132
142
  return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
133
143
  }
@@ -137,7 +147,11 @@ export function createApp(_port, baseDir) {
137
147
  const app = express();
138
148
  const router = express.Router();
139
149
  // Register API routes
140
- registerApiRoutes(router);
150
+ registerApiRoutes(router, {
151
+ packageName: PACKAGE_NAME,
152
+ version: getPackageVersion(),
153
+ dashboardUrl: `http://localhost:${resolvePort(_port)}`,
154
+ });
141
155
  app.use('/api', router);
142
156
  const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
143
157
  const popoverPath = isProduction
@@ -171,21 +185,29 @@ async function main() {
171
185
  }
172
186
  const version = getPackageVersion();
173
187
  const preferredPort = resolvePort(args.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined));
174
- // --tray mode: launch Electron
188
+ // --tray mode: launch native Swift menu bar app
175
189
  if (args.tray) {
176
190
  if (process.platform !== 'darwin') {
177
191
  console.error('Error: --tray is only supported on macOS.');
178
192
  process.exit(1);
179
193
  }
180
194
  console.log(`Starting tokendash v${version} in tray mode...`);
181
- // @ts-ignore -- electron is installed separately for tray mode
182
- const { default: electronPath } = await import('electron');
183
195
  const { spawn } = await import('node:child_process');
184
- const child = spawn(electronPath, ['.'], {
196
+ const { resolve } = await import('node:path');
197
+ const { existsSync } = await import('node:fs');
198
+ // Find Swift binary: check packaged app first, then dev build
199
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
200
+ const packagedPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
201
+ const devPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
202
+ const swiftBin = existsSync(packagedPath) ? packagedPath : devPath;
203
+ if (!existsSync(swiftBin)) {
204
+ console.error('Error: TokenDash Swift binary not found. Run "npm run build:swift" first.');
205
+ process.exit(1);
206
+ }
207
+ const child = spawn(swiftBin, [], {
185
208
  env: {
186
209
  ...process.env,
187
210
  TOKENDASH_PORT: String(preferredPort),
188
- TOKENDASH_TRAY: '1',
189
211
  },
190
212
  stdio: 'inherit',
191
213
  });
@@ -217,11 +239,15 @@ async function main() {
217
239
  // Open browser if requested
218
240
  if (shouldOpenBrowser) {
219
241
  // Small delay to ensure server is ready
220
- setTimeout(() => {
242
+ setTimeout(async () => {
221
243
  console.log('Opening dashboard in your browser...');
222
- open(`http://localhost:${port}`).catch((err) => {
244
+ try {
245
+ const { default: open } = await import('open');
246
+ await open(`http://localhost:${port}`);
247
+ }
248
+ catch (err) {
223
249
  console.warn('Could not open browser:', err.message);
224
- });
250
+ }
225
251
  }, 100);
226
252
  }
227
253
  else {
@@ -0,0 +1,15 @@
1
+ import type { DailyEntry, DriverMetric, InsightsResponse, ProjectsResponse } from '../shared/types.js';
2
+ export declare function cacheHitRate(cacheReadTokens: number, inputTokens: number): number;
3
+ export declare function outputInputRatio(outputTokens: number, inputTokens: number): number;
4
+ export interface BuildInsightsOptions {
5
+ agent: string;
6
+ project?: string;
7
+ timezone?: string;
8
+ daily: DailyEntry[];
9
+ projects?: ProjectsResponse;
10
+ topAgent?: DriverMetric;
11
+ partialAgents?: string[];
12
+ now?: Date;
13
+ }
14
+ export declare function buildInsightsResponse(options: BuildInsightsOptions): InsightsResponse;
15
+ export declare function mergeDailyResponsesByDate(responses: DailyEntry[][]): DailyEntry[];
@@ -0,0 +1,276 @@
1
+ const MIN_BASELINE_ACTIVE_DAYS = 3;
2
+ const BASELINE_ACTIVE_DAYS = 7;
3
+ const MAX_INSIGHTS = 5;
4
+ export function cacheHitRate(cacheReadTokens, inputTokens) {
5
+ const totalInput = cacheReadTokens + inputTokens;
6
+ if (totalInput === 0)
7
+ return 0;
8
+ return (cacheReadTokens / totalInput) * 100;
9
+ }
10
+ export function outputInputRatio(outputTokens, inputTokens) {
11
+ if (inputTokens === 0)
12
+ return 0;
13
+ return outputTokens / inputTokens;
14
+ }
15
+ function localTodayKey(now = new Date()) {
16
+ return [
17
+ now.getFullYear(),
18
+ String(now.getMonth() + 1).padStart(2, '0'),
19
+ String(now.getDate()).padStart(2, '0'),
20
+ ].join('-');
21
+ }
22
+ function emptyDaily(date) {
23
+ return {
24
+ date,
25
+ inputTokens: 0,
26
+ outputTokens: 0,
27
+ cacheCreationTokens: 0,
28
+ cacheReadTokens: 0,
29
+ totalTokens: 0,
30
+ totalCost: 0,
31
+ modelsUsed: [],
32
+ modelBreakdowns: [],
33
+ };
34
+ }
35
+ function mergeEntries(date, entries) {
36
+ const merged = emptyDaily(date);
37
+ const models = new Map();
38
+ for (const entry of entries) {
39
+ merged.inputTokens += entry.inputTokens;
40
+ merged.outputTokens += entry.outputTokens;
41
+ merged.cacheCreationTokens += entry.cacheCreationTokens;
42
+ merged.cacheReadTokens += entry.cacheReadTokens;
43
+ merged.totalTokens += entry.totalTokens;
44
+ merged.totalCost += entry.totalCost;
45
+ for (const model of entry.modelBreakdowns) {
46
+ const existing = models.get(model.modelName);
47
+ if (existing) {
48
+ existing.inputTokens += model.inputTokens;
49
+ existing.outputTokens += model.outputTokens;
50
+ existing.cacheCreationTokens += model.cacheCreationTokens;
51
+ existing.cacheReadTokens += model.cacheReadTokens;
52
+ existing.cost += model.cost;
53
+ }
54
+ else {
55
+ models.set(model.modelName, { ...model });
56
+ }
57
+ }
58
+ }
59
+ merged.modelBreakdowns = [...models.values()];
60
+ merged.modelsUsed = [...new Set(merged.modelBreakdowns.map(model => model.modelName))];
61
+ return merged;
62
+ }
63
+ function activeBaselineEntries(daily, today) {
64
+ return daily
65
+ .filter(entry => entry.date < today && entry.totalTokens > 0)
66
+ .sort((a, b) => b.date.localeCompare(a.date))
67
+ .slice(0, BASELINE_ACTIVE_DAYS);
68
+ }
69
+ function average(values) {
70
+ if (values.length === 0)
71
+ return 0;
72
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
73
+ }
74
+ function baselineFrom(entries) {
75
+ return {
76
+ activeDays: entries.length,
77
+ avgTokens: average(entries.map(entry => entry.totalTokens)),
78
+ avgCost: average(entries.map(entry => entry.totalCost)),
79
+ avgCacheHitRate: average(entries.map(entry => cacheHitRate(entry.cacheReadTokens, entry.inputTokens))),
80
+ avgOutputInputRatio: average(entries.map(entry => outputInputRatio(entry.outputTokens, entry.inputTokens))),
81
+ insufficientData: entries.length < MIN_BASELINE_ACTIVE_DAYS,
82
+ };
83
+ }
84
+ function driverFromProject(today, projects) {
85
+ if (!projects)
86
+ return undefined;
87
+ const drivers = Object.entries(projects.projects)
88
+ .map(([name, entries]) => {
89
+ const todayEntry = mergeEntries(today, entries.filter(entry => entry.date === today));
90
+ return { name, tokens: todayEntry.totalTokens, cost: todayEntry.totalCost };
91
+ })
92
+ .filter(driver => driver.tokens > 0 || driver.cost > 0)
93
+ .sort((a, b) => b.tokens - a.tokens);
94
+ const totalTokens = drivers.reduce((sum, driver) => sum + driver.tokens, 0);
95
+ const top = drivers[0];
96
+ if (!top || totalTokens === 0)
97
+ return undefined;
98
+ return { ...top, share: top.tokens / totalTokens };
99
+ }
100
+ function driverFromModel(todayEntry) {
101
+ const totalTokens = todayEntry.totalTokens;
102
+ if (todayEntry.modelBreakdowns.length === 0 || totalTokens === 0)
103
+ return undefined;
104
+ const drivers = todayEntry.modelBreakdowns
105
+ .map(model => ({
106
+ name: model.modelName,
107
+ tokens: model.inputTokens + model.outputTokens + model.cacheCreationTokens + model.cacheReadTokens,
108
+ cost: model.cost,
109
+ }))
110
+ .sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
111
+ const top = drivers[0];
112
+ if (!top)
113
+ return undefined;
114
+ const totalCost = todayEntry.totalCost;
115
+ const share = totalCost > 0 ? top.cost / totalCost : top.tokens / totalTokens;
116
+ return { ...top, share };
117
+ }
118
+ function deltaPercent(current, baseline) {
119
+ if (baseline <= 0)
120
+ return undefined;
121
+ return ((current - baseline) / baseline) * 100;
122
+ }
123
+ function addInsight(insights, insight) {
124
+ if (insight)
125
+ insights.push(insight);
126
+ }
127
+ function severityRank(severity) {
128
+ if (severity === 'critical')
129
+ return 0;
130
+ if (severity === 'warning')
131
+ return 1;
132
+ return 2;
133
+ }
134
+ function sortInsights(insights) {
135
+ return insights
136
+ .sort((a, b) => {
137
+ const severity = severityRank(a.severity) - severityRank(b.severity);
138
+ if (severity !== 0)
139
+ return severity;
140
+ return Math.abs(b.deltaPercent ?? 0) - Math.abs(a.deltaPercent ?? 0);
141
+ })
142
+ .slice(0, MAX_INSIGHTS);
143
+ }
144
+ export function buildInsightsResponse(options) {
145
+ const today = localTodayKey(options.now);
146
+ const todayEntry = mergeEntries(today, options.daily.filter(entry => entry.date === today));
147
+ const baselineEntries = activeBaselineEntries(options.daily, today);
148
+ const baseline = baselineFrom(baselineEntries);
149
+ const todayCacheHitRate = cacheHitRate(todayEntry.cacheReadTokens, todayEntry.inputTokens);
150
+ const todayOutputInputRatio = outputInputRatio(todayEntry.outputTokens, todayEntry.inputTokens);
151
+ const topProject = options.project ? undefined : driverFromProject(today, options.projects);
152
+ const topModel = driverFromModel(todayEntry);
153
+ const topDrivers = {
154
+ ...(topProject ? { project: topProject } : {}),
155
+ ...(topModel ? { model: topModel } : {}),
156
+ ...(options.topAgent ? { agent: options.topAgent } : {}),
157
+ };
158
+ const insights = [];
159
+ const tokenDelta = deltaPercent(todayEntry.totalTokens, baseline.avgTokens);
160
+ const costDelta = deltaPercent(todayEntry.totalCost, baseline.avgCost);
161
+ const ratioDelta = deltaPercent(todayOutputInputRatio, baseline.avgOutputInputRatio);
162
+ const isNewHigh = todayEntry.totalTokens > 0 && options.daily
163
+ .filter(entry => entry.date <= today)
164
+ .sort((a, b) => b.totalTokens - a.totalTokens)[0]?.date === today;
165
+ if (!baseline.insufficientData) {
166
+ addInsight(insights, todayEntry.totalTokens >= baseline.avgTokens * 2 && todayEntry.totalTokens - baseline.avgTokens >= 100_000 && {
167
+ id: 'token-spike',
168
+ severity: 'warning',
169
+ title: `Today is ${Math.round(tokenDelta ?? 0)}% above your 7-day average`,
170
+ detail: 'Token usage is materially above your recent active-day baseline.',
171
+ metric: 'tokens',
172
+ currentValue: todayEntry.totalTokens,
173
+ baselineValue: baseline.avgTokens,
174
+ deltaPercent: tokenDelta,
175
+ });
176
+ addInsight(insights, todayEntry.totalCost >= baseline.avgCost * 2 && todayEntry.totalCost - baseline.avgCost >= 1 && {
177
+ id: 'cost-spike',
178
+ severity: 'warning',
179
+ title: `Today cost is ${Math.round(costDelta ?? 0)}% above baseline`,
180
+ detail: 'Estimated spend is materially above your recent active-day baseline.',
181
+ metric: 'cost',
182
+ currentValue: todayEntry.totalCost,
183
+ baselineValue: baseline.avgCost,
184
+ deltaPercent: costDelta,
185
+ });
186
+ addInsight(insights, baseline.avgCacheHitRate - todayCacheHitRate >= 20 && {
187
+ id: 'cache-drop',
188
+ severity: 'warning',
189
+ title: `Cache hit dropped to ${todayCacheHitRate.toFixed(1)}%`,
190
+ detail: `Recent baseline is ${baseline.avgCacheHitRate.toFixed(1)}%. Context reuse may be lower than usual.`,
191
+ metric: 'cache',
192
+ currentValue: todayCacheHitRate,
193
+ baselineValue: baseline.avgCacheHitRate,
194
+ deltaPercent: todayCacheHitRate - baseline.avgCacheHitRate,
195
+ });
196
+ addInsight(insights, todayOutputInputRatio >= baseline.avgOutputInputRatio * 2 && todayEntry.outputTokens >= 25_000 && {
197
+ id: 'output-heavy',
198
+ severity: 'info',
199
+ title: `Output/input ratio is ${todayOutputInputRatio.toFixed(1)}x`,
200
+ detail: 'Today is more output-heavy than your recent baseline.',
201
+ metric: 'ratio',
202
+ currentValue: todayOutputInputRatio,
203
+ baselineValue: baseline.avgOutputInputRatio,
204
+ deltaPercent: ratioDelta,
205
+ });
206
+ }
207
+ addInsight(insights, todayCacheHitRate < 50 && todayEntry.inputTokens >= 50_000 && {
208
+ id: 'low-cache',
209
+ severity: 'info',
210
+ title: `Cache hit is low at ${todayCacheHitRate.toFixed(1)}%`,
211
+ detail: 'Low cache reuse can raise cost when input volume is high.',
212
+ metric: 'cache',
213
+ currentValue: todayCacheHitRate,
214
+ baselineValue: baseline.insufficientData ? undefined : baseline.avgCacheHitRate,
215
+ });
216
+ addInsight(insights, topProject && topProject.share >= 0.5 && {
217
+ id: 'large-project-driver',
218
+ severity: 'info',
219
+ title: `${topProject.name} drives ${Math.round(topProject.share * 100)}% of today's tokens`,
220
+ detail: 'One project explains most of today’s scoped usage.',
221
+ metric: 'project',
222
+ currentValue: topProject.share * 100,
223
+ driver: topProject,
224
+ });
225
+ addInsight(insights, topModel && topModel.share >= 0.5 && {
226
+ id: 'large-model-driver',
227
+ severity: 'info',
228
+ title: `${topModel.name} drives ${Math.round(topModel.share * 100)}% of today’s ${todayEntry.totalCost > 0 ? 'cost' : 'tokens'}`,
229
+ detail: 'One model explains most of today’s scoped usage.',
230
+ metric: 'model',
231
+ currentValue: topModel.share * 100,
232
+ driver: topModel,
233
+ });
234
+ addInsight(insights, isNewHigh && {
235
+ id: 'new-high',
236
+ severity: !baseline.insufficientData && todayEntry.totalTokens >= baseline.avgTokens * 2 ? 'critical' : 'info',
237
+ title: 'Today is the highest day in the last 30 days',
238
+ detail: baseline.insufficientData ? 'There is not enough baseline data yet, but today is currently the highest observed day.' : 'Today is the highest observed day in the current local history window.',
239
+ metric: 'tokens',
240
+ currentValue: todayEntry.totalTokens,
241
+ baselineValue: baseline.insufficientData ? undefined : baseline.avgTokens,
242
+ deltaPercent: tokenDelta,
243
+ });
244
+ return {
245
+ generatedAt: new Date().toISOString(),
246
+ scope: {
247
+ agent: options.agent,
248
+ ...(options.project ? { project: options.project } : {}),
249
+ timezone: options.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
250
+ partialAgents: options.partialAgents ?? [],
251
+ },
252
+ today: {
253
+ date: today,
254
+ totalTokens: todayEntry.totalTokens,
255
+ totalCost: todayEntry.totalCost,
256
+ cacheHitRate: todayCacheHitRate,
257
+ outputInputRatio: todayOutputInputRatio,
258
+ },
259
+ baseline,
260
+ topDrivers,
261
+ insights: sortInsights(insights),
262
+ };
263
+ }
264
+ export function mergeDailyResponsesByDate(responses) {
265
+ const byDate = new Map();
266
+ for (const daily of responses) {
267
+ for (const entry of daily) {
268
+ const entries = byDate.get(entry.date) ?? [];
269
+ entries.push(entry);
270
+ byDate.set(entry.date, entries);
271
+ }
272
+ }
273
+ return [...byDate.entries()]
274
+ .map(([date, entries]) => mergeEntries(date, entries))
275
+ .sort((a, b) => a.date.localeCompare(b.date));
276
+ }
@@ -0,0 +1,47 @@
1
+ import type { QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
2
+ /**
3
+ * Structured quota error. Adapters throw this (not generic Error) so the
4
+ * service can classify the status without inspecting message strings.
5
+ * Messages must never contain secrets — they reach the API response.
6
+ */
7
+ export declare class QuotaError extends Error {
8
+ readonly status: QuotaProviderStatus;
9
+ constructor(status: QuotaProviderStatus);
10
+ }
11
+ /**
12
+ * One provider adapter. Responsible only for:
13
+ * 1. detecting whether the provider is configured (credentials present)
14
+ * 2. invoking the upstream interface
15
+ * 3. validating the response
16
+ * 4. converting to a normalized snapshot
17
+ *
18
+ * It does NOT cache, deduplicate, or time out — the service owns those.
19
+ * Detecting a provider as configured means it appears in the dashboard;
20
+ * fetch() may still fail with an auth/error status.
21
+ */
22
+ export interface QuotaAdapter {
23
+ readonly provider: QuotaProviderId;
24
+ readonly displayName: string;
25
+ /** True when credentials/config exist for this provider locally. Cheap, no network. */
26
+ isConfigured(): Promise<boolean>;
27
+ /**
28
+ * Fetch a fresh normalized snapshot. Throws QuotaError on any failure.
29
+ * Must NOT include secrets in any field of the returned snapshot.
30
+ */
31
+ fetch(): Promise<QuotaSnapshot>;
32
+ }
33
+ /**
34
+ * Registry of all known adapters, keyed by provider id.
35
+ * Adding a quota provider = ship one adapter + register it here.
36
+ */
37
+ export declare class QuotaAdapterRegistry {
38
+ private readonly adapters;
39
+ register(adapter: QuotaAdapter): void;
40
+ get(provider: QuotaProviderId): QuotaAdapter | undefined;
41
+ list(): QuotaAdapter[];
42
+ }
43
+ /** Build a baseline snapshot with shared fields filled in. */
44
+ export declare function baseSnapshot(provider: QuotaProviderId, displayName: string, opts?: {
45
+ planName?: string;
46
+ windows?: QuotaSnapshot['windows'];
47
+ }): Pick<QuotaSnapshot, 'provider' | 'displayName' | 'planName' | 'fetchedAt' | 'freshness' | 'windows'>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Structured quota error. Adapters throw this (not generic Error) so the
3
+ * service can classify the status without inspecting message strings.
4
+ * Messages must never contain secrets — they reach the API response.
5
+ */
6
+ export class QuotaError extends Error {
7
+ status;
8
+ constructor(status) {
9
+ const msg = 'message' in status && status.message ? status.message : '';
10
+ super(msg ? `${status.state}: ${msg}` : status.state);
11
+ this.name = 'QuotaError';
12
+ this.status = status;
13
+ }
14
+ }
15
+ /**
16
+ * Registry of all known adapters, keyed by provider id.
17
+ * Adding a quota provider = ship one adapter + register it here.
18
+ */
19
+ export class QuotaAdapterRegistry {
20
+ adapters = new Map();
21
+ register(adapter) {
22
+ this.adapters.set(adapter.provider, adapter);
23
+ }
24
+ get(provider) {
25
+ return this.adapters.get(provider);
26
+ }
27
+ list() {
28
+ return Array.from(this.adapters.values());
29
+ }
30
+ }
31
+ /** Build a baseline snapshot with shared fields filled in. */
32
+ export function baseSnapshot(provider, displayName, opts = {}) {
33
+ return {
34
+ provider,
35
+ displayName,
36
+ planName: opts.planName,
37
+ fetchedAt: new Date().toISOString(),
38
+ freshness: 'live',
39
+ windows: opts.windows ?? [],
40
+ };
41
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const claudeAdapter: QuotaAdapter;