@zhangferry-dev/tokendash 1.6.1 → 1.7.0

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 (61) hide show
  1. package/README.md +148 -84
  2. package/dist/client/assets/index-Bw503sNp.css +1 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/daemon.cjs +3411 -0
  5. package/dist/daemon.cjs.map +7 -0
  6. package/dist/electron-server.cjs +1124 -28
  7. package/dist/electron-server.cjs.map +4 -4
  8. package/dist/server/ccusage.d.ts +7 -0
  9. package/dist/server/ccusage.js +69 -0
  10. package/dist/server/daemon.d.ts +12 -0
  11. package/dist/server/daemon.js +176 -0
  12. package/dist/server/index.js +23 -11
  13. package/dist/server/insightsCalculator.d.ts +15 -0
  14. package/dist/server/insightsCalculator.js +276 -0
  15. package/dist/server/quota/adapter.d.ts +49 -0
  16. package/dist/server/quota/adapter.js +41 -0
  17. package/dist/server/quota/adapters/claude.d.ts +4 -0
  18. package/dist/server/quota/adapters/claude.js +152 -0
  19. package/dist/server/quota/adapters/codex.d.ts +16 -0
  20. package/dist/server/quota/adapters/codex.js +226 -0
  21. package/dist/server/quota/adapters/glm.d.ts +2 -0
  22. package/dist/server/quota/adapters/glm.js +139 -0
  23. package/dist/server/quota/adapters/kimi.d.ts +2 -0
  24. package/dist/server/quota/adapters/kimi.js +186 -0
  25. package/dist/server/quota/adapters/minimax.d.ts +2 -0
  26. package/dist/server/quota/adapters/minimax.js +82 -0
  27. package/dist/server/quota/cache.d.ts +20 -0
  28. package/dist/server/quota/cache.js +44 -0
  29. package/dist/server/quota/credentialsFile.d.ts +13 -0
  30. package/dist/server/quota/credentialsFile.js +23 -0
  31. package/dist/server/quota/helpers.d.ts +39 -0
  32. package/dist/server/quota/helpers.js +93 -0
  33. package/dist/server/quota/index.d.ts +5 -0
  34. package/dist/server/quota/index.js +23 -0
  35. package/dist/server/quota/quotaService.d.ts +43 -0
  36. package/dist/server/quota/quotaService.js +163 -0
  37. package/dist/server/quota/schemas.d.ts +358 -0
  38. package/dist/server/quota/schemas.js +53 -0
  39. package/dist/server/quota/types.d.ts +76 -0
  40. package/dist/server/quota/types.js +10 -0
  41. package/dist/server/routes/api.js +34 -0
  42. package/dist/server/routes/insights.d.ts +2 -0
  43. package/dist/server/routes/insights.js +155 -0
  44. package/package.json +9 -11
  45. package/resources/entitlements.mac.plist +10 -0
  46. package/resources/icon-1024.png +0 -0
  47. package/resources/icon.icns +0 -0
  48. package/resources/icon.png +0 -0
  49. package/resources/product_menu.png +0 -0
  50. package/resources/readme-hero.png +0 -0
  51. package/dist/client/assets/index-_yA9tOzZ.css +0 -1
  52. package/electron/main.cjs +0 -516
  53. package/electron/npmSync.cjs +0 -62
  54. package/electron/preload.cjs +0 -36
  55. package/electron/serverReuse.cjs +0 -59
  56. package/electron/trayBadge.cjs +0 -27
  57. package/electron/trayHelper +0 -0
  58. package/electron/trayHelper.swift +0 -152
  59. package/electron/updateService.cjs +0 -220
  60. package/electron-builder.yml +0 -20
  61. /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
@@ -0,0 +1,7 @@
1
+ export declare function runCcusage(args: string[], timeout?: number): Promise<string>;
2
+ export declare function ensureUsageToolsReady(): Promise<void>;
3
+ export declare function isClaudeCodeAvailable(): Promise<boolean>;
4
+ export declare function detectAvailableAgents(): Promise<{
5
+ claude: boolean;
6
+ codex: boolean;
7
+ }>;
@@ -0,0 +1,69 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { isSessionsDirAccessible } from './codexParser.js';
4
+ const execFileAsync = promisify(execFile);
5
+ function withJsonFlag(args, asJson) {
6
+ if (!asJson || args.includes('--json')) {
7
+ return args;
8
+ }
9
+ return [...args, '--json'];
10
+ }
11
+ async function runCommand(spec, timeout) {
12
+ const { stdout } = await execFileAsync(spec.command, spec.args, {
13
+ timeout,
14
+ maxBuffer: 10 * 1024 * 1024,
15
+ });
16
+ return stdout;
17
+ }
18
+ function isMissingCommand(error) {
19
+ return typeof error === 'object'
20
+ && error !== null
21
+ && 'code' in error
22
+ && error.code === 'ENOENT';
23
+ }
24
+ async function runCcusageCommand(args, timeout, asJson) {
25
+ const primary = {
26
+ command: 'ccusage',
27
+ args: withJsonFlag(args, asJson),
28
+ };
29
+ const fallback = {
30
+ command: 'npx',
31
+ args: ['--yes', 'ccusage@latest', ...withJsonFlag(args, asJson)],
32
+ };
33
+ try {
34
+ return await runCommand(primary, timeout);
35
+ }
36
+ catch (error) {
37
+ if (isMissingCommand(error)) {
38
+ return runCommand(fallback, timeout);
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ export async function runCcusage(args, timeout = 30_000) {
44
+ return runCcusageCommand(args, timeout, true);
45
+ }
46
+ export async function ensureUsageToolsReady() {
47
+ // Claude Code: check ccusage CLI
48
+ await runCcusageCommand(['--version'], 120_000, false);
49
+ // Codex: check local sessions directory (instant, no npm subprocess)
50
+ if (!isSessionsDirAccessible()) {
51
+ throw new Error('Codex sessions directory not found at ~/.codex/sessions/');
52
+ }
53
+ }
54
+ export async function isClaudeCodeAvailable() {
55
+ try {
56
+ await runCcusageCommand(['--version'], 120_000, false);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ export async function detectAvailableAgents() {
64
+ const [claude, codex] = await Promise.all([
65
+ isClaudeCodeAvailable(),
66
+ Promise.resolve(isSessionsDirAccessible()),
67
+ ]);
68
+ return { claude, codex };
69
+ }
@@ -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',
@@ -18,7 +17,7 @@ function getPackageVersion() {
18
17
  const __dirname = dirname(__filename);
19
18
  const packageJsonPaths = [
20
19
  join(__dirname, '..', '..', 'package.json'), // dist/server/index.js
21
- join(__dirname, '..', 'package.json'), // dist/electron-server.cjs
20
+ join(__dirname, '..', 'package.json'), // bundled server entrypoint
22
21
  ];
23
22
  for (const packageJsonPath of packageJsonPaths) {
24
23
  if (!existsSync(packageJsonPath))
@@ -138,7 +137,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
138
137
  // The CLI entrypoint runs from dist/server/index.js while the Vite assets are
139
138
  // emitted to dist/client. Resolve the production asset base to dist instead
140
139
  // of dist/server so / resolves to dist/client/index.html in installed npm
141
- // 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.
142
141
  if (basename(moduleDir) === 'server') {
143
142
  return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
144
143
  }
@@ -147,6 +146,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
147
146
  export function createApp(_port, baseDir) {
148
147
  const app = express();
149
148
  const router = express.Router();
149
+ app.use(express.json({ limit: '16kb' }));
150
150
  // Register API routes
151
151
  registerApiRoutes(router, {
152
152
  packageName: PACKAGE_NAME,
@@ -186,21 +186,29 @@ async function main() {
186
186
  }
187
187
  const version = getPackageVersion();
188
188
  const preferredPort = resolvePort(args.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined));
189
- // --tray mode: launch Electron
189
+ // --tray mode: launch native Swift menu bar app
190
190
  if (args.tray) {
191
191
  if (process.platform !== 'darwin') {
192
192
  console.error('Error: --tray is only supported on macOS.');
193
193
  process.exit(1);
194
194
  }
195
195
  console.log(`Starting tokendash v${version} in tray mode...`);
196
- // @ts-ignore -- electron is installed separately for tray mode
197
- const { default: electronPath } = await import('electron');
198
196
  const { spawn } = await import('node:child_process');
199
- const child = spawn(electronPath, ['.'], {
197
+ const { resolve } = await import('node:path');
198
+ const { existsSync } = await import('node:fs');
199
+ // Find Swift binary: check packaged app first, then dev build
200
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
201
+ const packagedPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
202
+ const devPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
203
+ const swiftBin = existsSync(packagedPath) ? packagedPath : devPath;
204
+ if (!existsSync(swiftBin)) {
205
+ console.error('Error: TokenDash Swift binary not found. Run "npm run build:swift" first.');
206
+ process.exit(1);
207
+ }
208
+ const child = spawn(swiftBin, [], {
200
209
  env: {
201
210
  ...process.env,
202
211
  TOKENDASH_PORT: String(preferredPort),
203
- TOKENDASH_TRAY: '1',
204
212
  },
205
213
  stdio: 'inherit',
206
214
  });
@@ -232,11 +240,15 @@ async function main() {
232
240
  // Open browser if requested
233
241
  if (shouldOpenBrowser) {
234
242
  // Small delay to ensure server is ready
235
- setTimeout(() => {
243
+ setTimeout(async () => {
236
244
  console.log('Opening dashboard in your browser...');
237
- open(`http://localhost:${port}`).catch((err) => {
245
+ try {
246
+ const { default: open } = await import('open');
247
+ await open(`http://localhost:${port}`);
248
+ }
249
+ catch (err) {
238
250
  console.warn('Could not open browser:', err.message);
239
- });
251
+ }
240
252
  }, 100);
241
253
  }
242
254
  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
+ }