letmecode 0.1.1 → 0.1.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 CHANGED
@@ -1,18 +1,44 @@
1
- # letmecode
1
+ # letmecode - Discover your detailed agent ussage Codex | Claude
2
2
 
3
- Minimal `npx`-first CLI package.
4
-
5
- ## Local development
3
+ Ussage:
6
4
 
7
5
  ```bash
8
- pnpm install
9
- pnpm start
6
+ npx -y letmecode
10
7
  ```
11
8
 
12
- The Ink app source lives in `ink-app/`.
9
+ <img width="2308" height="1491" alt="image" src="https://github.com/user-attachments/assets/f3f52d79-00e3-4ff5-bf2f-65f8be632aaa" />
10
+
11
+ ## Controls
12
+
13
+ | Key | Action |
14
+ | --- | --- |
15
+ | `[` / `]` | Switch providers |
16
+ | `Tab` / `Shift+Tab` | Switch providers when supported by the terminal |
17
+ | `Up` / `Down` or `k` / `j` | Switch dashboard sections |
18
+ | `Left` / `Right` | Select the previous or next table row |
19
+ | `Enter` | Run the selected provider action |
20
+ | `1` | Select the Copilot VS Code setup action |
21
+ | `h` / `l` | Select a Copilot setup action |
22
+ | `q` or `Esc` | Quit |
23
+
24
+ ## Copilot
13
25
 
14
- ## Usage
26
+ Copilot CLI usage is read from `~/.copilot/session-state`.
27
+
28
+ VS Code extension usage needs file OTEL logging first. Select the `Copilot` provider, choose `Start logging VS Code` with `1` or `h` / `l`, then press `Enter`; letmecode will update the current user's VS Code settings with:
29
+
30
+ ```json
31
+ {
32
+ "github.copilot.chat.otel.enabled": true,
33
+ "github.copilot.chat.otel.exporterType": "file",
34
+ "github.copilot.chat.otel.outfile": "~/.copilot/otel/vscode.jsonl",
35
+ "github.copilot.chat.otel.captureContent": false
36
+ }
37
+ ```
38
+
39
+ ## Local development
15
40
 
16
41
  ```bash
17
- npx letmecode
42
+ pnpm install
43
+ pnpm start
18
44
  ```
@@ -1,13 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useEffect, useState } from "react";
3
3
  import { Box, Text, useApp, useInput, render } from "ink";
4
- import { createProviders } from "./providers/index.js";
4
+ import { configureCopilotVsCodeLogging, createProviders } from "./providers/index.js";
5
5
  const VERTICAL_TABS = [
6
6
  { id: "limit-windows", label: "Limits" },
7
7
  { id: "summary", label: "Summary" },
8
+ { id: "day-to-day-analyses", label: "day to day" },
8
9
  { id: "usage-by-model", label: "by model" }
9
10
  ];
10
11
  const CODEX_CREDIT_COST_USD = 0.01;
12
+ const VERTICAL_TAB_WIDTH = 12;
11
13
  const LIMIT_WINDOW_COLUMNS = {
12
14
  plan: 8,
13
15
  window: 8,
@@ -24,27 +26,46 @@ const MODEL_USAGE_COLUMNS = {
24
26
  credits: 12,
25
27
  value: 12
26
28
  };
27
- function App() {
29
+ const DAY_USAGE_COLUMNS = {
30
+ day: 11,
31
+ events: 6,
32
+ input: 11,
33
+ output: 10,
34
+ value: 10
35
+ };
36
+ const COPILOT_ACTIONS = [
37
+ { id: "vscode", label: "Start logging VS Code", enabled: true }
38
+ ];
39
+ function App(props) {
28
40
  const { exit } = useApp();
29
41
  const providers = React.useState(() => createProviders())[0];
30
42
  const [providerStates, setProviderStates] = useState(providers.map((provider) => ({ provider, status: "loading" })));
31
- const [selectedProviderIndex, setSelectedProviderIndex] = useState(0);
43
+ const [selectedProviderId, setSelectedProviderId] = useState(providers[0]?.id ?? "");
44
+ const [hasUserSelectedProvider, setHasUserSelectedProvider] = useState(false);
32
45
  const [selectedVerticalTabIndex, setSelectedVerticalTabIndex] = useState(0);
33
46
  const [selectedLimitRowIndex, setSelectedLimitRowIndex] = useState(0);
47
+ const [selectedDayRowIndex, setSelectedDayRowIndex] = useState(0);
34
48
  const [selectedModelRowIndex, setSelectedModelRowIndex] = useState(0);
35
- const selectedProvider = providerStates[selectedProviderIndex];
49
+ const [selectedCopilotActionIndex, setSelectedCopilotActionIndex] = useState(0);
50
+ const [copilotActionMessage, setCopilotActionMessage] = useState();
51
+ const sortedProviderStates = React.useMemo(() => sortProviderStatesByUsage(providerStates), [providerStates]);
52
+ const selectedProviderIndex = Math.max(0, sortedProviderStates.findIndex((state) => state.provider.id === selectedProviderId));
53
+ const selectedProvider = sortedProviderStates[selectedProviderIndex];
36
54
  const selectedVerticalTab = VERTICAL_TABS[selectedVerticalTabIndex];
37
55
  const limitRows = getLimitRows(selectedProvider);
56
+ const dayRows = getDayRows(selectedProvider);
38
57
  const modelRows = getModelRows(selectedProvider);
39
58
  const activeLimitRowIndex = clampSelectionIndex(selectedLimitRowIndex, limitRows.length);
59
+ const activeDayRowIndex = clampSelectionIndex(selectedDayRowIndex, dayRows.length);
40
60
  const activeModelRowIndex = clampSelectionIndex(selectedModelRowIndex, modelRows.length);
41
61
  const selectedLimitRow = activeLimitRowIndex >= 0 ? limitRows[activeLimitRowIndex] : undefined;
62
+ const selectedDayRow = activeDayRowIndex >= 0 ? dayRows[activeDayRowIndex] : undefined;
42
63
  const selectedModelRow = activeModelRowIndex >= 0 ? modelRows[activeModelRowIndex] : undefined;
43
64
  useEffect(() => {
44
65
  let cancelled = false;
45
66
  for (const provider of providers) {
46
67
  void provider
47
- .getStats()
68
+ .getStats(props.statsOptions)
48
69
  .then((stats) => {
49
70
  if (cancelled) {
50
71
  return;
@@ -66,13 +87,39 @@ function App() {
66
87
  return () => {
67
88
  cancelled = true;
68
89
  };
69
- }, [providers]);
90
+ }, [props.statsOptions, providers]);
91
+ useEffect(() => {
92
+ if (hasUserSelectedProvider || providerStates.some((state) => state.status === "loading")) {
93
+ return;
94
+ }
95
+ const topProvider = sortedProviderStates[0];
96
+ if (providerUsageScore(topProvider) <= 0) {
97
+ return;
98
+ }
99
+ setSelectedProviderId(topProvider.provider.id);
100
+ }, [hasUserSelectedProvider, providerStates, sortedProviderStates]);
70
101
  useInput((input, key) => {
71
102
  if (input === "q" || key.escape) {
72
103
  exit();
73
104
  return;
74
105
  }
75
- if ((key.ctrl || key.shift) && key.downArrow) {
106
+ if (selectedProvider.provider.id === "copilot" && input >= "1" && input <= String(COPILOT_ACTIONS.length)) {
107
+ setSelectedCopilotActionIndex(Number(input) - 1);
108
+ return;
109
+ }
110
+ if (selectedProvider.provider.id === "copilot" && key.return) {
111
+ runCopilotAction(COPILOT_ACTIONS[selectedCopilotActionIndex].id, setCopilotActionMessage);
112
+ return;
113
+ }
114
+ if (selectedProvider.provider.id === "copilot" && input === "l") {
115
+ setSelectedCopilotActionIndex((current) => (current + 1) % COPILOT_ACTIONS.length);
116
+ return;
117
+ }
118
+ if (selectedProvider.provider.id === "copilot" && input === "h") {
119
+ setSelectedCopilotActionIndex((current) => (current - 1 + COPILOT_ACTIONS.length) % COPILOT_ACTIONS.length);
120
+ return;
121
+ }
122
+ if (key.rightArrow) {
76
123
  if (selectedVerticalTab.id === "limit-windows") {
77
124
  setSelectedLimitRowIndex(clampSelectionIndex(activeLimitRowIndex + 1, limitRows.length));
78
125
  return;
@@ -81,8 +128,12 @@ function App() {
81
128
  setSelectedModelRowIndex(clampSelectionIndex(activeModelRowIndex + 1, modelRows.length));
82
129
  return;
83
130
  }
131
+ if (selectedVerticalTab.id === "day-to-day-analyses") {
132
+ setSelectedDayRowIndex(clampSelectionIndex(activeDayRowIndex + 1, dayRows.length));
133
+ return;
134
+ }
84
135
  }
85
- if ((key.ctrl || key.shift) && key.upArrow) {
136
+ if (key.leftArrow) {
86
137
  if (selectedVerticalTab.id === "limit-windows") {
87
138
  setSelectedLimitRowIndex(clampSelectionIndex(activeLimitRowIndex - 1, limitRows.length));
88
139
  return;
@@ -91,13 +142,20 @@ function App() {
91
142
  setSelectedModelRowIndex(clampSelectionIndex(activeModelRowIndex - 1, modelRows.length));
92
143
  return;
93
144
  }
145
+ if (selectedVerticalTab.id === "day-to-day-analyses") {
146
+ setSelectedDayRowIndex(clampSelectionIndex(activeDayRowIndex - 1, dayRows.length));
147
+ return;
148
+ }
94
149
  }
95
- if ((input === "\t" && !key.shift) || key.rightArrow) {
96
- setSelectedProviderIndex((current) => (current + 1) % providerStates.length);
150
+ if ((key.tab && !key.shift) || input === "]") {
151
+ setSelectedProviderId(sortedProviderStates[(selectedProviderIndex + 1) % sortedProviderStates.length].provider.id);
152
+ setHasUserSelectedProvider(true);
97
153
  return;
98
154
  }
99
- if ((input === "\t" && key.shift) || key.leftArrow) {
100
- setSelectedProviderIndex((current) => (current - 1 + providerStates.length) % providerStates.length);
155
+ if ((key.tab && key.shift) || input === "[") {
156
+ setSelectedProviderId(sortedProviderStates[(selectedProviderIndex - 1 + sortedProviderStates.length) % sortedProviderStates.length]
157
+ .provider.id);
158
+ setHasUserSelectedProvider(true);
101
159
  return;
102
160
  }
103
161
  if (key.downArrow || input === "j") {
@@ -108,7 +166,39 @@ function App() {
108
166
  setSelectedVerticalTabIndex((current) => (current - 1 + VERTICAL_TABS.length) % VERTICAL_TABS.length);
109
167
  }
110
168
  });
111
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "letmecode usage dashboard" }), _jsx(Text, { color: "gray", children: "tab/shift+tab or left/right to switch providers, j/k or up/down for details, ctrl+up/down or shift+up/down to select a row, q to quit" }), _jsx(Box, { marginTop: 1, children: providerStates.map((state, index) => (_jsx(ProviderTab, { label: state.provider.label, active: index === selectedProviderIndex, status: state.status }, state.provider.id))) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { flexDirection: "column", width: 12, marginRight: 2, children: VERTICAL_TABS.map((tab, index) => (_jsx(VerticalTab, { label: tab.label, active: index === selectedVerticalTabIndex }, tab.id))) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(ContentPanel, { providerState: selectedProvider, tabId: selectedVerticalTab.id, selectedLimitRowKey: selectedLimitRow ? getLimitRowKey(selectedLimitRow) : undefined, selectedModelId: selectedModelRow?.modelId }) })] }), _jsx(SelectionDetailsPanel, { providerState: selectedProvider, tabId: selectedVerticalTab.id, selectedLimitRow: selectedLimitRow, selectedModelRow: selectedModelRow }), selectedProvider.status === "ready" && selectedProvider.stats.warnings.length > 0 ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Warnings" }), selectedProvider.stats.warnings.map((warning) => (_jsx(Text, { children: warning }, warning)))] })) : null] }));
169
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "letmecode usage dashboard" }), _jsx(Text, { color: "gray", children: "[/]/tab to switch providers, j/k or up/down for details, left/right to select a row, enter for actions, q to quit" }), _jsx(Box, { marginTop: 1, children: sortedProviderStates.map((state) => (_jsx(ProviderTab, { label: state.provider.label, active: state.provider.id === selectedProvider.provider.id, status: state.status }, state.provider.id))) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { flexDirection: "column", width: VERTICAL_TAB_WIDTH, marginRight: 2, children: VERTICAL_TABS.map((tab, index) => (_jsx(VerticalTab, { label: tab.label, active: index === selectedVerticalTabIndex }, tab.id))) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(ContentPanel, { providerState: selectedProvider, tabId: selectedVerticalTab.id, selectedLimitRowKey: selectedLimitRow ? getLimitRowKey(selectedLimitRow) : undefined, selectedDayKey: selectedDayRow?.dayKey, selectedModelId: selectedModelRow?.modelId }) })] }), _jsx(SelectionDetailsPanel, { providerState: selectedProvider, tabId: selectedVerticalTab.id, selectedLimitRow: selectedLimitRow, selectedDayRow: selectedDayRow, selectedModelRow: selectedModelRow }), _jsx(CopilotActionsPanel, { providerState: selectedProvider, actionMessage: copilotActionMessage, selectedActionIndex: selectedCopilotActionIndex }), selectedProvider.status === "ready" && selectedProvider.stats.warnings.length > 0 ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Warnings" }), selectedProvider.stats.warnings.map((warning) => (_jsx(Text, { children: warning }, warning)))] })) : null] }));
170
+ }
171
+ function CopilotActionsPanel(props) {
172
+ if (props.providerState.provider.id !== "copilot") {
173
+ return null;
174
+ }
175
+ const hasNoUsage = props.providerState.status === "ready" && props.providerState.stats.summary.tokenEvents === 0;
176
+ const accentColor = hasNoUsage ? "red" : "cyan";
177
+ return (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: accentColor, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: accentColor, children: "Copilot setup" }), _jsx(Box, { children: COPILOT_ACTIONS.map((action, index) => (_jsx(Box, { marginRight: 1, children: _jsx(Text, { inverse: index === (props.selectedActionIndex ?? 0), bold: hasNoUsage && action.id === "vscode", color: action.enabled ? (hasNoUsage && action.id === "vscode" ? accentColor : undefined) : "gray", children: `${index + 1} ${action.label}` }) }, action.id))) }), _jsx(Text, { color: hasNoUsage ? accentColor : "gray", children: "Press 1 or h/l to select an action, enter to run selected." }), props.actionMessage ? _jsx(Text, { children: props.actionMessage }) : null] }));
178
+ }
179
+ function runCopilotAction(actionId, setCopilotActionMessage) {
180
+ setCopilotActionMessage("Updating VS Code settings...");
181
+ void configureCopilotVsCodeLogging()
182
+ .then((result) => {
183
+ setCopilotActionMessage(formatCopilotLoggingResult(result));
184
+ })
185
+ .catch((error) => {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ setCopilotActionMessage(`Failed to update VS Code settings: ${message}`);
188
+ });
189
+ }
190
+ function formatCopilotLoggingResult(result) {
191
+ const status = result.changed ? "VS Code logging enabled" : "VS Code logging already enabled";
192
+ return [
193
+ `${status}: ${result.outfile}`,
194
+ `Settings written to: ${result.settingsPath}`,
195
+ 'Open "Preferences: Open User Settings (JSON)" in VS Code and verify that this is the active file.',
196
+ "Expected settings:",
197
+ '"github.copilot.chat.otel.enabled": true',
198
+ '"github.copilot.chat.otel.exporterType": "file"',
199
+ '"github.copilot.chat.otel.captureContent": false',
200
+ `"github.copilot.chat.otel.outfile": "${result.outfile}"`
201
+ ].join("\n");
112
202
  }
113
203
  function ProviderTab(props) {
114
204
  const statusColor = props.status === "error" ? "red" : props.status === "loading" ? "yellow" : "green";
@@ -116,12 +206,12 @@ function ProviderTab(props) {
116
206
  return (_jsx(Box, { marginRight: 1, children: _jsx(Text, { inverse: props.active, color: statusColor, children: tabLabel }) }));
117
207
  }
118
208
  function VerticalTab(props) {
119
- return (_jsx(Text, { inverse: props.active, children: props.active ? ` ${props.label} ` : ` ${props.label}` }));
209
+ return (_jsx(Box, { width: VERTICAL_TAB_WIDTH, children: _jsx(Text, { wrap: "truncate-end", inverse: props.active, children: props.active ? ` ${props.label} ` : ` ${props.label}` }) }));
120
210
  }
121
211
  function SummaryPanel(props) {
122
212
  const { summary } = props.stats;
123
213
  const inputPerOutput = formatInputPerOutput(summary.totals);
124
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: props.stats.providerLabel }), _jsxs(Text, { children: ["root: ", summary.rootLabel, " (", summary.rootPath, ")"] }), _jsxs(Text, { children: ["files: ", formatInteger(summary.filesScanned), " lines: ", formatInteger(summary.linesRead), " token events: ", formatInteger(summary.tokenEvents)] }), _jsxs(Text, { children: ["input: ", formatInteger(summary.totals.inputTokens), " cached: ", formatInteger(summary.totals.cachedInputTokens), " non-cached: ", formatInteger(summary.totals.nonCachedInputTokens)] }), _jsxs(Text, { children: ["output: ", formatInteger(summary.totals.outputTokens), " reasoning: ", formatInteger(summary.totals.reasoningOutputTokens), " total: ", formatInteger(summary.totals.totalTokens)] }), _jsxs(Text, { children: ["estimated credits: ", formatCredits(summary.totals.estimatedCredits)] }), _jsxs(Text, { children: ["IpO: ", inputPerOutput.cached, ":", inputPerOutput.nonCached, ":", inputPerOutput.output] }), _jsxs(Text, { children: ["models: ", summary.distinctModels.join(", ") || "none"] }), _jsxs(Text, { children: ["plans: ", summary.distinctPlanTypes.join(", ") || "none"] })] }));
214
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: props.stats.providerLabel }), _jsxs(Text, { children: ["root: ", summary.rootLabel, " (", summary.rootPath, ")"] }), _jsxs(Text, { children: ["files: ", formatInteger(summary.filesScanned), " lines: ", formatInteger(summary.linesRead), " token events: ", formatInteger(summary.tokenEvents)] }), _jsxs(Text, { children: ["input: ", formatInteger(summary.totals.inputTokens), " cached: ", formatCacheTokens(summary.totals, "cached"), " non-cached: ", formatCacheTokens(summary.totals, "non-cached")] }), _jsxs(Text, { children: ["output: ", formatInteger(summary.totals.outputTokens), " reasoning: ", formatInteger(summary.totals.reasoningOutputTokens), " total: ", formatInteger(summary.totals.totalTokens)] }), _jsxs(Text, { children: ["estimated credits: ", formatUsageCredits(summary.totals)] }), _jsxs(Text, { children: ["IpO: ", inputPerOutput.cached, ":", inputPerOutput.nonCached, ":", inputPerOutput.output] }), _jsxs(Text, { children: ["models: ", summary.distinctModels.join(", ") || "none"] }), _jsxs(Text, { children: ["plans: ", summary.distinctPlanTypes.join(", ") || "none"] })] }));
125
215
  }
126
216
  function ContentPanel(props) {
127
217
  if (props.providerState.status === "loading") {
@@ -136,10 +226,13 @@ function ContentPanel(props) {
136
226
  if (props.tabId === "summary") {
137
227
  return _jsx(SummaryPanel, { stats: props.providerState.stats });
138
228
  }
229
+ if (props.tabId === "day-to-day-analyses") {
230
+ return _jsx(DayToDayPanel, { stats: props.providerState.stats, selectedDayKey: props.selectedDayKey });
231
+ }
139
232
  return _jsx(UsageByModelPanel, { stats: props.providerState.stats, selectedModelId: props.selectedModelId });
140
233
  }
141
234
  function LimitWindowsPanel(props) {
142
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Primary" }), _jsx(LimitWindowSection, { windows: props.stats.primaryLimitWindows, selectedRowKey: props.selectedRowKey }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: "Secondary" }), _jsx(LimitWindowSection, { windows: props.stats.secondaryLimitWindows, selectedRowKey: props.selectedRowKey })] }));
235
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Primary Limit Windows" }), _jsx(LimitWindowSection, { windows: props.stats.primaryLimitWindows, selectedRowKey: props.selectedRowKey }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: "Secondary Limit Windows" }), _jsx(LimitWindowSection, { windows: props.stats.secondaryLimitWindows, selectedRowKey: props.selectedRowKey })] }));
143
236
  }
144
237
  function LimitWindowSection(props) {
145
238
  if (props.windows.length === 0) {
@@ -159,8 +252,17 @@ function UsageByModelPanel(props) {
159
252
  const totals = props.stats.summary.totals;
160
253
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [pad("model", MODEL_USAGE_COLUMNS.model), " ", pad("input", MODEL_USAGE_COLUMNS.input), " ", pad("cached", MODEL_USAGE_COLUMNS.cached), " ", pad("non-cached", MODEL_USAGE_COLUMNS.nonCached), " ", pad("output", MODEL_USAGE_COLUMNS.output), " ", pad("credits", MODEL_USAGE_COLUMNS.credits), " value"] }), props.stats.modelUsage.map((row) => {
161
254
  const isSelected = props.selectedModelId === row.modelId;
162
- return (_jsxs(Text, { inverse: isSelected, color: isSelected ? "cyan" : undefined, children: [pad(row.modelId, MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(row.totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(formatInteger(row.totals.cachedInputTokens), MODEL_USAGE_COLUMNS.cached), " ", pad(formatInteger(row.totals.nonCachedInputTokens), MODEL_USAGE_COLUMNS.nonCached), " ", pad(formatInteger(row.totals.outputTokens), MODEL_USAGE_COLUMNS.output), " ", pad(formatCredits(row.totals.estimatedCredits), MODEL_USAGE_COLUMNS.credits), " ", pad(formatUsd(row.totals.estimatedCredits * CODEX_CREDIT_COST_USD), MODEL_USAGE_COLUMNS.value)] }, row.modelId));
163
- }), _jsxs(Text, { color: "cyan", children: [pad("TOTAL", MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(formatInteger(totals.cachedInputTokens), MODEL_USAGE_COLUMNS.cached), " ", pad(formatInteger(totals.nonCachedInputTokens), MODEL_USAGE_COLUMNS.nonCached), " ", pad(formatInteger(totals.outputTokens), MODEL_USAGE_COLUMNS.output), " ", pad(formatCredits(totals.estimatedCredits), MODEL_USAGE_COLUMNS.credits), " ", pad(formatUsd(totals.estimatedCredits * CODEX_CREDIT_COST_USD), MODEL_USAGE_COLUMNS.value)] })] }));
255
+ return (_jsxs(Text, { inverse: isSelected, color: isSelected ? "cyan" : undefined, children: [pad(row.modelId, MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(row.totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(formatCacheTokens(row.totals, "cached"), MODEL_USAGE_COLUMNS.cached), " ", pad(formatCacheTokens(row.totals, "non-cached"), MODEL_USAGE_COLUMNS.nonCached), " ", pad(formatInteger(row.totals.outputTokens), MODEL_USAGE_COLUMNS.output), " ", pad(formatUsageCredits(row.totals), MODEL_USAGE_COLUMNS.credits), " ", pad(formatUsageUsd(row.totals), MODEL_USAGE_COLUMNS.value)] }, row.modelId));
256
+ }), _jsxs(Text, { color: "cyan", children: [pad("TOTAL", MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(formatCacheTokens(totals, "cached"), MODEL_USAGE_COLUMNS.cached), " ", pad(formatCacheTokens(totals, "non-cached"), MODEL_USAGE_COLUMNS.nonCached), " ", pad(formatInteger(totals.outputTokens), MODEL_USAGE_COLUMNS.output), " ", pad(formatUsageCredits(totals), MODEL_USAGE_COLUMNS.credits), " ", pad(formatUsageUsd(totals), MODEL_USAGE_COLUMNS.value)] })] }));
257
+ }
258
+ function DayToDayPanel(props) {
259
+ if (props.stats.dayUsage.length === 0) {
260
+ return _jsx(Text, { color: "gray", children: "No day-by-day usage found." });
261
+ }
262
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [pad("day", DAY_USAGE_COLUMNS.day), " ", pad("events", DAY_USAGE_COLUMNS.events), " ", pad("input", DAY_USAGE_COLUMNS.input), " ", pad("output", DAY_USAGE_COLUMNS.output), " value"] }), props.stats.dayUsage.map((row) => {
263
+ const isSelected = props.selectedDayKey === row.dayKey;
264
+ return (_jsxs(Text, { inverse: isSelected, color: isSelected ? "cyan" : undefined, children: [pad(formatUtcDay(row.dayKey), DAY_USAGE_COLUMNS.day), " ", pad(formatInteger(row.totals.eventCount), DAY_USAGE_COLUMNS.events), " ", pad(formatInteger(row.totals.inputTokens), DAY_USAGE_COLUMNS.input), " ", pad(formatInteger(row.totals.outputTokens), DAY_USAGE_COLUMNS.output), " ", pad(formatUsageUsd(row.totals), DAY_USAGE_COLUMNS.value)] }, row.dayKey));
265
+ })] }));
164
266
  }
165
267
  function SelectionDetailsPanel(props) {
166
268
  if (props.providerState.status !== "ready") {
@@ -170,6 +272,10 @@ function SelectionDetailsPanel(props) {
170
272
  const row = props.selectedLimitRow;
171
273
  return (_jsxs(Box, { marginTop: 1, borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Limit details" }), _jsxs(Text, { children: [row.scope, " plan: ", row.planType, " window: ", formatWindowMinutes(row.windowMinutes), " used: ", row.minUsedPercent, "%", "->", row.maxUsedPercent, "% limit: ", row.limitId] }), _jsxs(Text, { children: ["range: ", formatLocalDateTime(row.startTimeUtcIso), " ", "->", " ", formatLocalDateTime(row.endTimeUtcIso), " events: ", formatInteger(row.eventCount)] }), _jsx(UsageTotalsDetails, { totals: row.totals })] }));
172
274
  }
275
+ if (props.tabId === "day-to-day-analyses" && props.selectedDayRow) {
276
+ const row = props.selectedDayRow;
277
+ return (_jsxs(Box, { marginTop: 1, borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Day details" }), _jsxs(Text, { children: ["day: ", formatUtcDay(row.dayKey), " events: ", formatInteger(row.totals.eventCount), " models: ", formatInteger(row.distinctModels.length), " plans: ", formatInteger(row.distinctPlanTypes.length)] }), _jsxs(Text, { children: ["range: ", formatEventRange(row.firstEventUtcIso, row.lastEventUtcIso)] }), _jsxs(Text, { children: ["input: ", formatInteger(row.totals.inputTokens), " cached: ", formatCacheTokens(row.totals, "cached")] }), _jsxs(Text, { children: ["non-cached: ", formatCacheTokens(row.totals, "non-cached"), " output: ", formatInteger(row.totals.outputTokens)] }), _jsxs(Text, { children: ["models: ", row.distinctModels.join(", ") || "none"] }), _jsxs(Text, { children: ["plans: ", row.distinctPlanTypes.join(", ") || "none"] }), _jsx(UsageTotalsDetails, { totals: row.totals })] }));
278
+ }
173
279
  if (props.tabId === "usage-by-model" && props.selectedModelRow) {
174
280
  return (_jsxs(Box, { marginTop: 1, borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Model details" }), _jsxs(Text, { children: ["model: ", props.selectedModelRow.modelId, " events: ", formatInteger(props.selectedModelRow.totals.eventCount)] }), _jsx(UsageTotalsDetails, { totals: props.selectedModelRow.totals })] }));
175
281
  }
@@ -178,18 +284,38 @@ function SelectionDetailsPanel(props) {
178
284
  function UsageTotalsDetails(props) {
179
285
  const { totals } = props;
180
286
  const inputPerOutput = formatInputPerOutput(totals);
181
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Total credits burned: ", formatCredits(totals.estimatedCredits)] }), _jsxs(Text, { children: ["Credits Value (@ $0.01/credit): ", formatUsd(totals.estimatedCredits * CODEX_CREDIT_COST_USD)] }), _jsxs(Text, { children: ["IpO: ", inputPerOutput.cached, ":", inputPerOutput.nonCached, ":", inputPerOutput.output] })] }));
287
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Total credits burned: ", formatUsageCredits(totals)] }), _jsxs(Text, { children: ["Credits Value (@ $0.01/credit): ", formatUsageUsd(totals)] }), _jsxs(Text, { children: ["IpO: ", inputPerOutput.cached, ":", inputPerOutput.nonCached, ":", inputPerOutput.output] })] }));
182
288
  }
183
289
  function formatInteger(value) {
184
290
  return Math.round(value).toLocaleString("en-US");
185
291
  }
186
292
  function formatCredits(value) {
293
+ if (value > 0 && value < 0.01) {
294
+ return "<0.01";
295
+ }
187
296
  return value.toLocaleString("en-US", {
188
297
  minimumFractionDigits: 2,
189
298
  maximumFractionDigits: 2
190
299
  });
191
300
  }
301
+ function formatUsageCredits(totals) {
302
+ return totals.estimatedCreditsStatus === "unavailable" ? "unknown" : formatCredits(totals.estimatedCredits);
303
+ }
304
+ function formatUsageUsd(totals) {
305
+ return totals.estimatedCreditsStatus === "unavailable"
306
+ ? "unknown"
307
+ : formatUsd(totals.estimatedCredits * CODEX_CREDIT_COST_USD);
308
+ }
309
+ function formatCacheTokens(totals, kind) {
310
+ if (totals.cacheStatus === "unavailable") {
311
+ return "unknown";
312
+ }
313
+ return formatInteger(kind === "cached" ? totals.cachedInputTokens : totals.nonCachedInputTokens);
314
+ }
192
315
  function formatUsd(value) {
316
+ if (value > 0 && value < 0.0001) {
317
+ return "<$0.0001";
318
+ }
193
319
  return value.toLocaleString("en-US", {
194
320
  currency: "USD",
195
321
  style: "currency",
@@ -217,10 +343,32 @@ function formatLocalDateTime(value) {
217
343
  const lookup = Object.fromEntries(parts.map((part) => [part.type, part.value]));
218
344
  return `${lookup.day} ${lookup.month} ${lookup.year} ${lookup.hour}:${lookup.minute}`;
219
345
  }
346
+ function formatUtcDay(value) {
347
+ if (value === "unknown") {
348
+ return "unknown";
349
+ }
350
+ const parts = new Intl.DateTimeFormat("en-US", {
351
+ month: "short",
352
+ day: "2-digit",
353
+ year: "2-digit",
354
+ timeZone: "UTC"
355
+ }).formatToParts(new Date(`${value}T00:00:00.000Z`));
356
+ const lookup = Object.fromEntries(parts.map((part) => [part.type, part.value]));
357
+ return `${lookup.day} ${lookup.month} ${lookup.year}`;
358
+ }
359
+ function formatEventRange(firstEventUtcIso, lastEventUtcIso) {
360
+ if (!firstEventUtcIso || !lastEventUtcIso) {
361
+ return "unknown";
362
+ }
363
+ return `${formatLocalDateTime(firstEventUtcIso)} -> ${formatLocalDateTime(lastEventUtcIso)}`;
364
+ }
220
365
  function pad(value, length) {
221
366
  return value.length >= length ? value.slice(0, length) : value.padEnd(length);
222
367
  }
223
368
  function formatInputPerOutput(totals) {
369
+ if (totals.cacheStatus === "unavailable") {
370
+ return { cached: "unknown", nonCached: "unknown", output: "1" };
371
+ }
224
372
  if (totals.outputTokens <= 0) {
225
373
  return { cached: "0", nonCached: "0", output: "0" };
226
374
  }
@@ -236,6 +384,20 @@ function clampSelectionIndex(value, rowCount) {
236
384
  }
237
385
  return Math.max(0, Math.min(value, rowCount - 1));
238
386
  }
387
+ function sortProviderStatesByUsage(states) {
388
+ return states
389
+ .map((state, index) => ({ state, index }))
390
+ .sort((left, right) => providerUsageScore(right.state) - providerUsageScore(left.state) ||
391
+ left.index - right.index)
392
+ .map((entry) => entry.state);
393
+ }
394
+ function providerUsageScore(state) {
395
+ if (state.status !== "ready") {
396
+ return 0;
397
+ }
398
+ const totals = state.stats.summary.totals;
399
+ return totals.inputTokens + totals.cachedInputTokens + totals.outputTokens;
400
+ }
239
401
  function getLimitRows(providerState) {
240
402
  if (providerState.status !== "ready") {
241
403
  return [];
@@ -248,10 +410,21 @@ function getModelRows(providerState) {
248
410
  }
249
411
  return providerState.stats.modelUsage;
250
412
  }
413
+ function getDayRows(providerState) {
414
+ if (providerState.status !== "ready") {
415
+ return [];
416
+ }
417
+ return providerState.stats.dayUsage;
418
+ }
251
419
  function getLimitRowKey(row) {
252
420
  return `${row.scope}-${row.planType}-${row.limitId}-${row.startTimeUtcIso}-${row.endTimeUtcIso}`;
253
421
  }
254
- export function main() {
255
- render(_jsx(App, {}));
422
+ function parseStatsOptions(argv) {
423
+ return {
424
+ verbose: argv.includes("-v") || argv.includes("--verbose")
425
+ };
426
+ }
427
+ export function main(argv = process.argv.slice(2)) {
428
+ render(_jsx(App, { statsOptions: parseStatsOptions(argv) }));
256
429
  }
257
430
  main();
@@ -4,31 +4,34 @@ import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
6
6
  import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
7
+ import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
7
8
  const RATE_CARD = {
8
- "claude-opus-4-8": { input: 500, cacheWrite5m: 625, cacheWrite1h: 1000, cacheRead: 50, output: 2500 },
9
- "claude-opus-4-7": { input: 500, cacheWrite5m: 625, cacheWrite1h: 1000, cacheRead: 50, output: 2500 },
10
- "claude-opus-4-6": { input: 500, cacheWrite5m: 625, cacheWrite1h: 1000, cacheRead: 50, output: 2500 },
11
- "claude-opus-4-5": { input: 500, cacheWrite5m: 625, cacheWrite1h: 1000, cacheRead: 50, output: 2500 },
12
- "claude-opus-4-1": { input: 1500, cacheWrite5m: 1875, cacheWrite1h: 3000, cacheRead: 150, output: 7500 },
13
- "claude-opus-4": { input: 1500, cacheWrite5m: 1875, cacheWrite1h: 3000, cacheRead: 150, output: 7500 },
14
- "claude-sonnet-4-6": { input: 300, cacheWrite5m: 375, cacheWrite1h: 600, cacheRead: 30, output: 1500 },
15
- "claude-sonnet-4-5": { input: 300, cacheWrite5m: 375, cacheWrite1h: 600, cacheRead: 30, output: 1500 },
16
- "claude-sonnet-4": { input: 300, cacheWrite5m: 375, cacheWrite1h: 600, cacheRead: 30, output: 1500 },
17
- "claude-haiku-4-5": { input: 100, cacheWrite5m: 125, cacheWrite1h: 200, cacheRead: 10, output: 500 },
18
- "claude-haiku-3-5": { input: 80, cacheWrite5m: 100, cacheWrite1h: 160, cacheRead: 8, output: 400 }
9
+ "claude-opus-4-8": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
10
+ "claude-opus-4-7": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
11
+ "claude-opus-4-6": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
12
+ "claude-opus-4-5": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
13
+ "claude-opus-4-1": { input: 15, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5, output: 75 },
14
+ "claude-opus-4": { input: 15, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5, output: 75 },
15
+ "claude-sonnet-4-6": { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
16
+ "claude-sonnet-4-5": { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
17
+ "claude-sonnet-4": { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
18
+ "claude-haiku-4-5": { input: 1, cacheWrite5m: 1.25, cacheWrite1h: 2, cacheRead: 0.1, output: 5 },
19
+ "claude-haiku-3-5": { input: 0.8, cacheWrite5m: 1, cacheWrite1h: 1.6, cacheRead: 0.08, output: 4 }
19
20
  };
21
+ const USD_TO_CREDITS = 100;
20
22
  export class ClaudeUsageProvider extends UsageProviderBase {
21
23
  constructor(options = {}) {
22
24
  super("claude", "Claude");
23
25
  this.root = path.resolve(options.root ?? os.homedir());
24
26
  }
25
- async getStats() {
27
+ async getStats(options = {}) {
26
28
  const sessionsRoot = path.join(this.root, ".claude", "projects");
27
29
  const byModel = new Map();
30
+ const byDay = createDailyUsageAggregates();
28
31
  const windows = createLimitWindowAggregates();
29
32
  const planTypes = new Set();
30
33
  const warnings = [];
31
- const seenUsageEvents = new Set();
34
+ const parsedEvents = createParsedUsageEventAccumulator();
32
35
  const parseTotals = {
33
36
  filesScanned: 0,
34
37
  linesRead: 0,
@@ -37,14 +40,34 @@ export class ClaudeUsageProvider extends UsageProviderBase {
37
40
  };
38
41
  for await (const file of walkSessionFiles(sessionsRoot)) {
39
42
  parseTotals.filesScanned += 1;
40
- const fileStats = await parseSessionFile(file, byModel, windows, planTypes, seenUsageEvents);
43
+ const fileStats = await parseSessionFile(file, parsedEvents);
41
44
  parseTotals.linesRead += fileStats.linesRead;
42
- parseTotals.tokenEvents += fileStats.tokenEvents;
43
45
  parseTotals.malformedLines += fileStats.malformedLines;
44
46
  }
47
+ const selectedEvents = [
48
+ ...parsedEvents.keyedEvents.values(),
49
+ ...parsedEvents.unkeyedEvents.values()
50
+ ];
51
+ for (const event of selectedEvents) {
52
+ addModelUsage(byModel, event.modelId, event.totals);
53
+ const planType = typeof event.rateLimits?.plan_type === "string" ? event.rateLimits.plan_type : undefined;
54
+ const safeEventTimeMs = Number.isFinite(event.timestampMs) ? event.timestampMs : 0;
55
+ addDailyUsage(byDay, event.timestampMs, event.modelId, planType, event.totals);
56
+ applyRateLimits(windows, event.rateLimits, safeEventTimeMs, event.totals, planTypes);
57
+ }
58
+ parseTotals.tokenEvents = selectedEvents.length;
45
59
  if (parseTotals.malformedLines > 0) {
46
60
  warnings.push(`Skipped ${parseTotals.malformedLines} malformed JSONL line(s).`);
47
61
  }
62
+ if (options.verbose && parsedEvents.duplicateUsageKeys > 0) {
63
+ warnings.push(`Collapsed ${parsedEvents.duplicateUsageKeys} duplicate Claude usage event(s) by request/message key.`);
64
+ }
65
+ if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
66
+ warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage; keeping the highest-cost/latest event per key.`);
67
+ }
68
+ if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
69
+ warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} duplicate unkeyed Claude usage event(s) by usage signature.`);
70
+ }
48
71
  const modelUsage = [...byModel.entries()]
49
72
  .map(([modelId, totals]) => ({ modelId, totals }))
50
73
  .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
@@ -58,6 +81,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
58
81
  warnings.push(`No Claude session files found under ${sessionsRoot}.`);
59
82
  }
60
83
  const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
84
+ const dayUsage = buildDailyUsageRows(byDay);
61
85
  const [primaryLimitWindows, secondaryLimitWindows] = buildWindowLists(windows);
62
86
  if (parseTotals.filesScanned > 0 &&
63
87
  parseTotals.tokenEvents > 0 &&
@@ -79,6 +103,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
79
103
  rootPath: sessionsRoot
80
104
  },
81
105
  modelUsage,
106
+ dayUsage,
82
107
  primaryLimitWindows,
83
108
  secondaryLimitWindows,
84
109
  warnings
@@ -124,7 +149,8 @@ function creditsFor(modelId, usage) {
124
149
  (usage.cacheCreation1hInputTokens / 1000000) * rate.cacheWrite1h +
125
150
  (cacheWriteFallbackTokens / 1000000) * rate.cacheWrite5m +
126
151
  (usage.outputTokens / 1000000) * rate.output) *
127
- inferenceMultiplier);
152
+ inferenceMultiplier *
153
+ USD_TO_CREDITS);
128
154
  }
129
155
  function usageToTotals(modelId, usage) {
130
156
  const nonCachedInputTokens = usage.inputTokens + usage.cacheCreationInputTokens;
@@ -167,11 +193,10 @@ async function* walkSessionFiles(directory) {
167
193
  }
168
194
  }
169
195
  }
170
- async function parseSessionFile(filePath, byModel, windows, planTypes, seenUsageEvents) {
196
+ async function parseSessionFile(filePath, parsedEvents) {
171
197
  const stream = fs.createReadStream(filePath, { encoding: "utf8" });
172
198
  const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
173
199
  let linesRead = 0;
174
- let tokenEvents = 0;
175
200
  let malformedLines = 0;
176
201
  for await (const line of lineReader) {
177
202
  linesRead += 1;
@@ -194,22 +219,23 @@ async function parseSessionFile(filePath, byModel, windows, planTypes, seenUsage
194
219
  if (!usage) {
195
220
  continue;
196
221
  }
197
- const usageKey = buildUsageEventKey(payloadObject, message);
198
- if (usageKey && seenUsageEvents.has(usageKey)) {
199
- continue;
200
- }
201
- if (usageKey) {
202
- seenUsageEvents.add(usageKey);
203
- }
204
222
  const modelId = String(message?.model ?? "unknown");
205
- const deltaTotals = usageToTotals(modelId, normalizeUsage(usage));
206
- addModelUsage(byModel, modelId, deltaTotals);
207
- tokenEvents += 1;
208
223
  const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
209
- const safeEventTimeMs = Number.isFinite(eventTimeMs) ? eventTimeMs : 0;
210
- applyRateLimits(windows, extractRateLimits(payloadObject, message), safeEventTimeMs, deltaTotals, planTypes);
224
+ const rateLimits = extractRateLimits(payloadObject, message);
225
+ const normalizedUsage = normalizeUsage(usage);
226
+ const usageKey = buildUsageEventKey(payloadObject, message);
227
+ const usageSignature = buildUsageSignature(payloadObject, modelId, normalizedUsage);
228
+ const parsedEvent = {
229
+ usageKey,
230
+ usageSignature,
231
+ timestampMs: eventTimeMs,
232
+ modelId,
233
+ totals: usageToTotals(modelId, normalizedUsage),
234
+ rateLimits
235
+ };
236
+ recordParsedUsageEvent(parsedEvents, parsedEvent);
211
237
  }
212
- return { linesRead, tokenEvents, malformedLines };
238
+ return { linesRead, malformedLines };
213
239
  }
214
240
  function buildUsageEventKey(payloadObject, message) {
215
241
  const sessionId = String(payloadObject.sessionId ?? "");
@@ -220,6 +246,66 @@ function buildUsageEventKey(payloadObject, message) {
220
246
  }
221
247
  return `${sessionId}|${requestId || messageId}`;
222
248
  }
249
+ function buildUsageSignature(payloadObject, modelId, usage) {
250
+ return [
251
+ String(payloadObject.sessionId ?? ""),
252
+ modelId,
253
+ usage.inputTokens,
254
+ usage.cacheCreationInputTokens,
255
+ usage.cacheCreation5mInputTokens,
256
+ usage.cacheCreation1hInputTokens,
257
+ usage.cacheReadInputTokens,
258
+ usage.outputTokens,
259
+ usage.inferenceGeo
260
+ ].join("|");
261
+ }
262
+ function createParsedUsageEventAccumulator() {
263
+ return {
264
+ keyedEvents: new Map(),
265
+ unkeyedEvents: new Map(),
266
+ duplicateUsageKeys: 0,
267
+ duplicateUsageKeyCollisions: 0,
268
+ duplicateUnkeyedEvents: 0
269
+ };
270
+ }
271
+ function recordParsedUsageEvent(parsedEvents, event) {
272
+ if (event.usageKey) {
273
+ const previous = parsedEvents.keyedEvents.get(event.usageKey);
274
+ if (!previous) {
275
+ parsedEvents.keyedEvents.set(event.usageKey, event);
276
+ return;
277
+ }
278
+ parsedEvents.duplicateUsageKeys += 1;
279
+ if (previous.usageSignature !== event.usageSignature) {
280
+ parsedEvents.duplicateUsageKeyCollisions += 1;
281
+ }
282
+ if (shouldReplaceUsageEvent(previous, event)) {
283
+ parsedEvents.keyedEvents.set(event.usageKey, event);
284
+ }
285
+ return;
286
+ }
287
+ const previous = parsedEvents.unkeyedEvents.get(event.usageSignature);
288
+ if (!previous) {
289
+ parsedEvents.unkeyedEvents.set(event.usageSignature, event);
290
+ return;
291
+ }
292
+ parsedEvents.duplicateUnkeyedEvents += 1;
293
+ if (shouldReplaceUsageEvent(previous, event)) {
294
+ parsedEvents.unkeyedEvents.set(event.usageSignature, event);
295
+ }
296
+ }
297
+ function shouldReplaceUsageEvent(previous, next) {
298
+ if (next.totals.estimatedCredits > previous.totals.estimatedCredits) {
299
+ return true;
300
+ }
301
+ if (next.totals.estimatedCredits === previous.totals.estimatedCredits) {
302
+ return normalizeTimestamp(next.timestampMs) > normalizeTimestamp(previous.timestampMs);
303
+ }
304
+ return false;
305
+ }
306
+ function normalizeTimestamp(value) {
307
+ return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
308
+ }
223
309
  function extractRateLimits(payloadObject, message) {
224
310
  return asRecord(payloadObject.rate_limits) ?? asRecord(message?.rate_limits);
225
311
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
6
6
  import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
7
+ import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
7
8
  const RATE_CARD = {
8
9
  "gpt-5.5": { input: 125, cachedInput: 12.5, output: 750 },
9
10
  "gpt-5.4": { input: 62.5, cachedInput: 6.25, output: 375 },
@@ -14,9 +15,10 @@ export class CodexUsageProvider extends UsageProviderBase {
14
15
  super("codex", "Codex");
15
16
  this.root = path.resolve(options.root ?? os.homedir());
16
17
  }
17
- async getStats() {
18
+ async getStats(_options = {}) {
18
19
  const sessionsRoot = path.join(this.root, ".codex", "sessions");
19
20
  const byModel = new Map();
21
+ const byDay = createDailyUsageAggregates();
20
22
  const windows = createLimitWindowAggregates();
21
23
  const planTypes = new Set();
22
24
  const warnings = [];
@@ -28,7 +30,7 @@ export class CodexUsageProvider extends UsageProviderBase {
28
30
  };
29
31
  for await (const file of walkSessionFiles(sessionsRoot)) {
30
32
  parseTotals.filesScanned += 1;
31
- const fileStats = await parseSessionFile(file, byModel, windows, planTypes);
33
+ const fileStats = await parseSessionFile(file, byModel, byDay, windows, planTypes);
32
34
  parseTotals.linesRead += fileStats.linesRead;
33
35
  parseTotals.tokenEvents += fileStats.tokenEvents;
34
36
  parseTotals.malformedLines += fileStats.malformedLines;
@@ -49,6 +51,7 @@ export class CodexUsageProvider extends UsageProviderBase {
49
51
  warnings.push(`No Codex session files found under ${sessionsRoot}.`);
50
52
  }
51
53
  const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
54
+ const dayUsage = buildDailyUsageRows(byDay);
52
55
  const [primaryLimitWindows, secondaryLimitWindows] = buildWindowLists(windows);
53
56
  return {
54
57
  providerId: this.id,
@@ -64,6 +67,7 @@ export class CodexUsageProvider extends UsageProviderBase {
64
67
  rootPath: sessionsRoot
65
68
  },
66
69
  modelUsage,
70
+ dayUsage,
67
71
  primaryLimitWindows,
68
72
  secondaryLimitWindows,
69
73
  warnings
@@ -156,7 +160,7 @@ async function* walkSessionFiles(directory) {
156
160
  }
157
161
  }
158
162
  }
159
- async function parseSessionFile(filePath, byModel, windows, planTypes) {
163
+ async function parseSessionFile(filePath, byModel, byDay, windows, planTypes) {
160
164
  const stream = fs.createReadStream(filePath, { encoding: "utf8" });
161
165
  const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
162
166
  let currentModel = "unknown";
@@ -202,7 +206,10 @@ async function parseSessionFile(filePath, byModel, windows, planTypes) {
202
206
  addModelUsage(byModel, resolvedModelId, deltaTotals);
203
207
  const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
204
208
  const safeEventTimeMs = Number.isFinite(eventTimeMs) ? eventTimeMs : 0;
205
- applyRateLimits(windows, asRecord(payload.rate_limits), safeEventTimeMs, deltaTotals, planTypes);
209
+ const rateLimits = asRecord(payload.rate_limits);
210
+ const planType = typeof rateLimits?.plan_type === "string" ? rateLimits.plan_type : undefined;
211
+ addDailyUsage(byDay, eventTimeMs, resolvedModelId, planType, deltaTotals);
212
+ applyRateLimits(windows, rateLimits, safeEventTimeMs, deltaTotals, planTypes);
206
213
  }
207
214
  return { linesRead, tokenEvents, malformedLines };
208
215
  }
@@ -25,6 +25,12 @@ export function addUsageTotals(target, source) {
25
25
  target.totalTokens += source.totalTokens;
26
26
  target.estimatedCredits += source.estimatedCredits;
27
27
  target.eventCount += source.eventCount;
28
+ if (source.cacheStatus === "unavailable") {
29
+ target.cacheStatus = "unavailable";
30
+ }
31
+ if (source.estimatedCreditsStatus === "unavailable") {
32
+ target.estimatedCreditsStatus = "unavailable";
33
+ }
28
34
  }
29
35
  export function sumUsageTotals(rows) {
30
36
  const totals = createEmptyUsageTotals();
@@ -0,0 +1,380 @@
1
+ import fs from "node:fs";
2
+ import { applyEdits, modify, parse } from "jsonc-parser";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import readline from "node:readline";
6
+ import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
7
+ import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
8
+ import { asRecord } from "./limits.js";
9
+ const VSCODE_OTEL_SETTINGS = {
10
+ "github.copilot.chat.otel.enabled": true,
11
+ "github.copilot.chat.otel.exporterType": "file",
12
+ "github.copilot.chat.otel.captureContent": false
13
+ };
14
+ const RATE_CARD = {
15
+ "gpt-5-mini": { input: 25, cachedInput: 2.5, output: 200 },
16
+ "gpt-5.3-codex": { input: 175, cachedInput: 17.5, output: 1400 },
17
+ "gpt-5.4": { input: 250, cachedInput: 25, output: 1500 },
18
+ "gpt-5.4-mini": { input: 75, cachedInput: 7.5, output: 450 },
19
+ "gpt-5.4-nano": { input: 20, cachedInput: 2, output: 125 },
20
+ "gpt-5.5": { input: 500, cachedInput: 50, output: 3000 },
21
+ "claude-haiku-4-5": { input: 100, cachedInput: 10, cacheWrite: 125, output: 500 },
22
+ "claude-sonnet-4-5": { input: 300, cachedInput: 30, cacheWrite: 375, output: 1500 },
23
+ "claude-sonnet-4-6": { input: 300, cachedInput: 30, cacheWrite: 375, output: 1500 },
24
+ "claude-opus-4-5": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
25
+ "claude-opus-4-6": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
26
+ "claude-opus-4-7": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
27
+ "claude-opus-4-8": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
28
+ "claude-fable-5": { input: 1000, cachedInput: 100, cacheWrite: 1250, output: 5000 },
29
+ "gemini-2.5-pro": { input: 125, cachedInput: 12.5, output: 1000 },
30
+ "gemini-3-flash": { input: 50, cachedInput: 5, output: 300 },
31
+ "gemini-3.1-pro": { input: 200, cachedInput: 20, output: 1200 },
32
+ "gemini-3.5-flash": { input: 150, cachedInput: 15, output: 900 },
33
+ "mai-code-1-flash": { input: 75, cachedInput: 7.5, output: 450 },
34
+ "raptor-mini": { input: 25, cachedInput: 2.5, output: 200 }
35
+ };
36
+ const LONG_CONTEXT_RATE_CARD = {
37
+ "gpt-5.4": { thresholdInputTokens: 272000, input: 500, cachedInput: 50, output: 2250 },
38
+ "gpt-5.5": { thresholdInputTokens: 272000, input: 1000, cachedInput: 100, output: 4500 },
39
+ "gemini-3.1-pro": { thresholdInputTokens: 200000, input: 400, cachedInput: 40, output: 1800 }
40
+ };
41
+ const NON_BILLABLE_MODEL_PREFIXES = ["copilot-nes", "copilot-suggestion"];
42
+ export class CopilotUsageProvider extends UsageProviderBase {
43
+ constructor(options = {}) {
44
+ super("copilot", "Copilot");
45
+ this.root = path.resolve(options.root ?? os.homedir());
46
+ }
47
+ async getStats() {
48
+ const vscodeOtelFile = getCopilotOtelPath(this.root);
49
+ const byModel = new Map();
50
+ const byDay = createDailyUsageAggregates();
51
+ const warnings = [];
52
+ const parseTotals = {
53
+ linesRead: 0,
54
+ tokenEvents: 0,
55
+ malformedLines: 0
56
+ };
57
+ const vscodeOtelFileExists = await isReadableFile(vscodeOtelFile);
58
+ if (vscodeOtelFileExists) {
59
+ const fileStats = await parseCopilotJsonlFile(vscodeOtelFile, byModel, byDay);
60
+ parseTotals.linesRead += fileStats.linesRead;
61
+ parseTotals.tokenEvents += fileStats.tokenEvents;
62
+ parseTotals.malformedLines += fileStats.malformedLines;
63
+ }
64
+ else if (await isCopilotVsCodeLoggingEnabled(this.root, vscodeOtelFile)) {
65
+ warnings.push(`VS Code Copilot logging is enabled, but ${vscodeOtelFile} has not been created yet. Reload VS Code and send a Copilot Chat request.`);
66
+ }
67
+ if (parseTotals.malformedLines > 0) {
68
+ warnings.push(`Skipped ${parseTotals.malformedLines} malformed Copilot JSONL line(s).`);
69
+ }
70
+ const filesScanned = vscodeOtelFileExists ? 1 : 0;
71
+ if (filesScanned === 0) {
72
+ warnings.push(`No Copilot VS Code OTEL usage file found at ${vscodeOtelFile}.`);
73
+ }
74
+ else if (parseTotals.tokenEvents === 0) {
75
+ warnings.push("No Copilot token usage events found. For VS Code, run Start logging VS Code and reload VS Code.");
76
+ }
77
+ const modelUsage = [...byModel.entries()]
78
+ .map(([modelId, totals]) => ({ modelId, totals }))
79
+ .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
80
+ const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
81
+ if (summaryTotals.cacheStatus === "unavailable") {
82
+ warnings.push("Copilot cache token attributes are unavailable for some events; cached/non-cached tokens and estimated credits are shown as unknown.");
83
+ }
84
+ return {
85
+ providerId: this.id,
86
+ providerLabel: this.label,
87
+ summary: {
88
+ filesScanned,
89
+ linesRead: parseTotals.linesRead,
90
+ tokenEvents: parseTotals.tokenEvents,
91
+ totals: summaryTotals,
92
+ distinctModels: modelUsage.map((row) => row.modelId),
93
+ distinctPlanTypes: [],
94
+ rootLabel: "~/.copilot/otel/vscode.jsonl",
95
+ rootPath: vscodeOtelFile
96
+ },
97
+ modelUsage,
98
+ dayUsage: buildDailyUsageRows(byDay),
99
+ primaryLimitWindows: [],
100
+ secondaryLimitWindows: [],
101
+ warnings
102
+ };
103
+ }
104
+ }
105
+ export async function configureCopilotVsCodeLogging(options = {}) {
106
+ const root = path.resolve(options.root ?? os.homedir());
107
+ const outfile = getCopilotOtelPath(root);
108
+ const settingsPath = options.settingsPath ?? (await getVsCodeSettingsPath(root));
109
+ const settingsText = await readTextFileOrEmpty(settingsPath);
110
+ const { text, changed } = updateJsoncSettings(settingsText, {
111
+ ...VSCODE_OTEL_SETTINGS,
112
+ "github.copilot.chat.otel.outfile": toVsCodeOutfilePath(outfile)
113
+ });
114
+ await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
115
+ await fs.promises.mkdir(path.dirname(outfile), { recursive: true });
116
+ if (changed) {
117
+ await fs.promises.writeFile(settingsPath, text, "utf8");
118
+ }
119
+ return { settingsPath, outfile, changed };
120
+ }
121
+ function getCopilotOtelPath(root) {
122
+ return path.join(root, ".copilot", "otel", "vscode.jsonl");
123
+ }
124
+ function toVsCodeOutfilePath(filePath) {
125
+ return process.platform === "win32" ? filePath.replace(/\\/g, "/") : filePath;
126
+ }
127
+ async function getVsCodeSettingsPath(root) {
128
+ const userRoots = getVsCodeUserRoots(root);
129
+ for (const userRoot of userRoots) {
130
+ if (await isDirectory(userRoot)) {
131
+ return path.join(userRoot, "settings.json");
132
+ }
133
+ }
134
+ return path.join(userRoots[0], "settings.json");
135
+ }
136
+ function getVsCodeUserRoots(root) {
137
+ if (process.platform === "darwin") {
138
+ const applicationSupport = path.join(root, "Library", "Application Support");
139
+ return [
140
+ path.join(applicationSupport, "Code", "User"),
141
+ path.join(applicationSupport, "Code - Insiders", "User")
142
+ ];
143
+ }
144
+ if (process.platform === "win32") {
145
+ const appData = process.env.APPDATA ?? path.join(root, "AppData", "Roaming");
146
+ return [path.join(appData, "Code", "User"), path.join(appData, "Code - Insiders", "User")];
147
+ }
148
+ const configRoot = path.join(root, ".config");
149
+ return [path.join(configRoot, "Code", "User"), path.join(configRoot, "Code - Insiders", "User")];
150
+ }
151
+ async function parseCopilotJsonlFile(filePath, byModel, byDay) {
152
+ const stream = fs.createReadStream(filePath, { encoding: "utf8" });
153
+ const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
154
+ const parseTotals = {
155
+ linesRead: 0,
156
+ tokenEvents: 0,
157
+ malformedLines: 0
158
+ };
159
+ for await (const line of lineReader) {
160
+ parseTotals.linesRead += 1;
161
+ if (!line.trim()) {
162
+ continue;
163
+ }
164
+ let payload;
165
+ try {
166
+ payload = JSON.parse(line);
167
+ }
168
+ catch {
169
+ parseTotals.malformedLines += 1;
170
+ continue;
171
+ }
172
+ const event = extractCopilotUsageEvent(payload);
173
+ if (event) {
174
+ parseTotals.tokenEvents += 1;
175
+ addModelUsage(byModel, event.modelId, event.totals);
176
+ addDailyUsage(byDay, event.timestampMs, event.modelId, undefined, event.totals);
177
+ }
178
+ }
179
+ return parseTotals;
180
+ }
181
+ function extractCopilotUsageEvent(payload) {
182
+ const record = asRecord(payload);
183
+ if (!record) {
184
+ return null;
185
+ }
186
+ const attributes = asRecord(record.attributes);
187
+ if (!attributes || !isCopilotChatSpan(attributes)) {
188
+ return null;
189
+ }
190
+ const usage = usageFromAttributes(attributes);
191
+ if (!usage) {
192
+ return null;
193
+ }
194
+ const modelId = stringAttribute(attributes, "gen_ai.response.model") ?? "unknown";
195
+ const timestampMs = hrTimeToMs(record.hrTime) ?? Number.NaN;
196
+ return {
197
+ timestampMs,
198
+ modelId,
199
+ totals: createUsageTotals(modelId, usage)
200
+ };
201
+ }
202
+ function usageFromAttributes(attributes) {
203
+ const inputTokens = numberAttribute(attributes, "gen_ai.usage.input_tokens") ?? 0;
204
+ const outputTokens = numberAttribute(attributes, "gen_ai.usage.output_tokens") ?? 0;
205
+ const reasoningOutputTokens = numberAttribute(attributes, "gen_ai.usage.reasoning.output_tokens");
206
+ const cachedInputTokens = numberAttribute(attributes, "gen_ai.usage.cache_read.input_tokens");
207
+ const cacheCreationInputTokens = numberAttribute(attributes, "gen_ai.usage.cache_creation.input_tokens");
208
+ if (inputTokens <= 0 && outputTokens <= 0 && (reasoningOutputTokens ?? 0) <= 0) {
209
+ return null;
210
+ }
211
+ return {
212
+ inputTokens,
213
+ cachedInputTokens,
214
+ cacheCreationInputTokens,
215
+ outputTokens,
216
+ reasoningOutputTokens
217
+ };
218
+ }
219
+ function isCopilotChatSpan(attributes) {
220
+ return stringAttribute(attributes, "gen_ai.operation.name") === "chat";
221
+ }
222
+ function createUsageTotals(modelId, usage) {
223
+ const hasCacheInfo = usage.cachedInputTokens !== undefined || usage.cacheCreationInputTokens !== undefined;
224
+ const hasKnownCreditPricing = isNonBillableModel(modelId) || (hasCacheInfo && rateForModel(modelId, usage.inputTokens) !== undefined);
225
+ const cachedInputTokens = hasCacheInfo ? Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens) : 0;
226
+ return {
227
+ inputTokens: usage.inputTokens,
228
+ cachedInputTokens,
229
+ nonCachedInputTokens: hasCacheInfo ? Math.max(0, usage.inputTokens - cachedInputTokens) : 0,
230
+ outputTokens: usage.outputTokens,
231
+ reasoningOutputTokens: Math.min(usage.reasoningOutputTokens ?? 0, usage.outputTokens),
232
+ totalTokens: usage.inputTokens + usage.outputTokens,
233
+ estimatedCredits: creditsFor(modelId, usage),
234
+ eventCount: 1,
235
+ cacheStatus: hasCacheInfo ? "known" : "unavailable",
236
+ estimatedCreditsStatus: hasKnownCreditPricing ? "known" : "unavailable"
237
+ };
238
+ }
239
+ function creditsFor(modelId, usage) {
240
+ if (isNonBillableModel(modelId)) {
241
+ return 0;
242
+ }
243
+ const rate = rateForModel(modelId, usage.inputTokens);
244
+ if (!rate) {
245
+ return 0;
246
+ }
247
+ if (usage.cachedInputTokens === undefined && usage.cacheCreationInputTokens === undefined) {
248
+ return 0;
249
+ }
250
+ const cacheRead = Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens);
251
+ const cacheWrite = Math.min(usage.cacheCreationInputTokens ?? 0, Math.max(0, usage.inputTokens - cacheRead));
252
+ const regularInput = Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
253
+ return ((regularInput / 1000000) * rate.input +
254
+ (cacheRead / 1000000) * rate.cachedInput +
255
+ (cacheWrite / 1000000) * (rate.cacheWrite ?? rate.input) +
256
+ (usage.outputTokens / 1000000) * rate.output);
257
+ }
258
+ function rateForModel(modelId, inputTokens) {
259
+ const candidates = Object.keys(RATE_CARD).sort((left, right) => right.length - left.length);
260
+ const model = candidates.find((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`));
261
+ if (!model) {
262
+ return undefined;
263
+ }
264
+ const longContextRate = LONG_CONTEXT_RATE_CARD[model];
265
+ if (longContextRate && inputTokens > longContextRate.thresholdInputTokens) {
266
+ return longContextRate;
267
+ }
268
+ return RATE_CARD[model];
269
+ }
270
+ function isNonBillableModel(modelId) {
271
+ return NON_BILLABLE_MODEL_PREFIXES.some((prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`));
272
+ }
273
+ function addModelUsage(byModel, modelId, deltaTotals) {
274
+ const resolvedModelId = modelId || "unknown";
275
+ const totals = byModel.get(resolvedModelId) ?? createEmptyUsageTotals();
276
+ addUsageTotals(totals, deltaTotals);
277
+ byModel.set(resolvedModelId, totals);
278
+ }
279
+ function numberAttribute(attributes, key) {
280
+ const value = attributes[key];
281
+ if (typeof value === "number" && Number.isFinite(value)) {
282
+ return value;
283
+ }
284
+ return undefined;
285
+ }
286
+ function stringAttribute(attributes, key) {
287
+ const value = attributes[key];
288
+ if (typeof value === "string" && value) {
289
+ return value;
290
+ }
291
+ return undefined;
292
+ }
293
+ function hrTimeToMs(value) {
294
+ if (!Array.isArray(value)) {
295
+ return undefined;
296
+ }
297
+ const [seconds, nanoseconds] = value;
298
+ if (typeof seconds !== "number" ||
299
+ !Number.isFinite(seconds) ||
300
+ typeof nanoseconds !== "number" ||
301
+ !Number.isFinite(nanoseconds)) {
302
+ return undefined;
303
+ }
304
+ return seconds * 1000 + nanoseconds / 1000000;
305
+ }
306
+ async function isReadableFile(filePath) {
307
+ try {
308
+ const stat = await fs.promises.stat(filePath);
309
+ return stat.isFile();
310
+ }
311
+ catch {
312
+ return false;
313
+ }
314
+ }
315
+ async function isDirectory(filePath) {
316
+ try {
317
+ const stat = await fs.promises.stat(filePath);
318
+ return stat.isDirectory();
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ }
324
+ async function isCopilotVsCodeLoggingEnabled(root, outfile) {
325
+ const settings = await readJsonSettings(await getVsCodeSettingsPath(root));
326
+ const configuredOutfile = settings["github.copilot.chat.otel.outfile"];
327
+ return (settings["github.copilot.chat.otel.enabled"] === true &&
328
+ settings["github.copilot.chat.otel.exporterType"] === "file" &&
329
+ typeof configuredOutfile === "string" &&
330
+ normalizeComparablePath(configuredOutfile) === normalizeComparablePath(toVsCodeOutfilePath(outfile)));
331
+ }
332
+ function normalizeComparablePath(filePath) {
333
+ const normalized = path.resolve(filePath).replace(/\\/g, "/");
334
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
335
+ }
336
+ async function readJsonSettings(filePath) {
337
+ return parseJsoncSettings(await readTextFileOrEmpty(filePath));
338
+ }
339
+ async function readTextFileOrEmpty(filePath) {
340
+ try {
341
+ return await fs.promises.readFile(filePath, "utf8");
342
+ }
343
+ catch (error) {
344
+ if (error.code === "ENOENT") {
345
+ return "";
346
+ }
347
+ throw error;
348
+ }
349
+ }
350
+ function parseJsoncSettings(raw) {
351
+ if (!raw.trim()) {
352
+ return {};
353
+ }
354
+ const parsed = parse(raw);
355
+ return asRecord(parsed) ?? {};
356
+ }
357
+ function updateJsoncSettings(raw, values) {
358
+ let text = raw.trim() ? raw : "{\n}";
359
+ let changed = false;
360
+ for (const [key, value] of Object.entries(values)) {
361
+ if (parseJsoncSettings(text)[key] === value) {
362
+ continue;
363
+ }
364
+ const edits = modify(text, [key], value, {
365
+ formattingOptions: {
366
+ eol: "\n",
367
+ insertSpaces: true,
368
+ tabSize: 4
369
+ }
370
+ });
371
+ if (edits.length > 0) {
372
+ text = applyEdits(text, edits);
373
+ changed = true;
374
+ }
375
+ }
376
+ if (changed && !text.endsWith("\n")) {
377
+ text += "\n";
378
+ }
379
+ return { text, changed };
380
+ }
@@ -0,0 +1,64 @@
1
+ import { addUsageTotals } from "./contract.js";
2
+ export function createDailyUsageAggregates() {
3
+ return new Map();
4
+ }
5
+ export function addDailyUsage(rows, eventTimeMs, modelId, planType, deltaTotals) {
6
+ const { dayKey, sortTimeMs } = resolveDayBucket(eventTimeMs);
7
+ const resolvedModelId = modelId || "unknown";
8
+ const existing = rows.get(dayKey);
9
+ if (!existing) {
10
+ const models = new Set();
11
+ models.add(resolvedModelId);
12
+ const planTypes = new Set();
13
+ if (planType) {
14
+ planTypes.add(planType);
15
+ }
16
+ rows.set(dayKey, {
17
+ dayKey,
18
+ sortTimeMs,
19
+ firstEventMs: Number.isFinite(eventTimeMs) ? eventTimeMs : null,
20
+ lastEventMs: Number.isFinite(eventTimeMs) ? eventTimeMs : null,
21
+ totals: { ...deltaTotals },
22
+ models,
23
+ planTypes
24
+ });
25
+ return;
26
+ }
27
+ addUsageTotals(existing.totals, deltaTotals);
28
+ existing.models.add(resolvedModelId);
29
+ if (planType) {
30
+ existing.planTypes.add(planType);
31
+ }
32
+ if (Number.isFinite(eventTimeMs)) {
33
+ existing.sortTimeMs = Math.max(existing.sortTimeMs, sortTimeMs);
34
+ existing.firstEventMs =
35
+ existing.firstEventMs === null ? eventTimeMs : Math.min(existing.firstEventMs, eventTimeMs);
36
+ existing.lastEventMs =
37
+ existing.lastEventMs === null ? eventTimeMs : Math.max(existing.lastEventMs, eventTimeMs);
38
+ }
39
+ }
40
+ export function buildDailyUsageRows(rows) {
41
+ return [...rows.values()]
42
+ .sort((left, right) => right.sortTimeMs - left.sortTimeMs || right.dayKey.localeCompare(left.dayKey))
43
+ .map((row) => ({
44
+ dayKey: row.dayKey,
45
+ firstEventUtcIso: row.firstEventMs === null ? null : formatIsoFromMilliseconds(row.firstEventMs),
46
+ lastEventUtcIso: row.lastEventMs === null ? null : formatIsoFromMilliseconds(row.lastEventMs),
47
+ distinctModels: [...row.models].sort(),
48
+ distinctPlanTypes: [...row.planTypes].sort(),
49
+ totals: { ...row.totals }
50
+ }));
51
+ }
52
+ function resolveDayBucket(eventTimeMs) {
53
+ if (!Number.isFinite(eventTimeMs)) {
54
+ return { dayKey: "unknown", sortTimeMs: Number.NEGATIVE_INFINITY };
55
+ }
56
+ const dayKey = new Date(eventTimeMs).toISOString().slice(0, 10);
57
+ return {
58
+ dayKey,
59
+ sortTimeMs: Date.parse(`${dayKey}T00:00:00.000Z`)
60
+ };
61
+ }
62
+ function formatIsoFromMilliseconds(milliseconds) {
63
+ return new Date(milliseconds).toISOString().replace(".000Z", "Z");
64
+ }
@@ -1,8 +1,10 @@
1
1
  import { ClaudeUsageProvider } from "./claude.js";
2
2
  import { CodexUsageProvider } from "./codex.js";
3
+ import { CopilotUsageProvider } from "./copilot.js";
3
4
  export function createProviders() {
4
- return [new CodexUsageProvider(), new ClaudeUsageProvider()];
5
+ return [new CodexUsageProvider(), new ClaudeUsageProvider(), new CopilotUsageProvider()];
5
6
  }
6
7
  export { ClaudeUsageProvider } from "./claude.js";
7
8
  export { CodexUsageProvider } from "./codex.js";
9
+ export { CopilotUsageProvider, configureCopilotVsCodeLogging } from "./copilot.js";
8
10
  export { UsageProviderBase } from "./contract.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmecode",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Provider-based terminal usage dashboard for LetMeCode.",
5
5
  "author": "devforth.io",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "ink": "4.4.1",
29
+ "jsonc-parser": "^3.3.1",
29
30
  "react": "18.3.1"
30
31
  },
31
32
  "devDependencies": {