opencode-tokenwatch 0.1.0 → 0.3.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.
@@ -0,0 +1,369 @@
1
+ import { getUsageReport, getPresetRange } from "./queries.js";
2
+ import { formatUsageReport } from "./formatter.js";
3
+ import { generateUsageHtml } from "./generate-usage-html.js";
4
+ import { t, setLanguage } from "./i18n.js";
5
+ import { readLogs } from "./perf-tracker.js";
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { execSync } from "node:child_process";
10
+ const DEFAULT_CONFIG = {
11
+ sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
12
+ language: "auto",
13
+ };
14
+ export async function registerCommands(api) {
15
+ api.command?.register(() => [
16
+ {
17
+ value: "tokenwatch-usage",
18
+ title: "TokenWatch",
19
+ description: "Token usage reports, export, and settings",
20
+ category: "Stats",
21
+ slash: { name: "usage" },
22
+ onSelect: async (dialog) => {
23
+ if (dialog)
24
+ showUsageMenu(api, dialog);
25
+ },
26
+ },
27
+ ]);
28
+ }
29
+ function ensureReportDir() {
30
+ const dir = join(homedir(), ".opencode", "reports");
31
+ if (!existsSync(dir))
32
+ mkdirSync(dir, { recursive: true });
33
+ return dir;
34
+ }
35
+ function openInBrowser(filePath) {
36
+ try {
37
+ const platform = process.platform;
38
+ if (platform === "win32")
39
+ execSync(`start "" "${filePath}"`, { windowsHide: true, timeout: 5000 });
40
+ else if (platform === "darwin")
41
+ execSync(`open "${filePath}"`, { timeout: 5000 });
42
+ else
43
+ execSync(`xdg-open "${filePath}"`, { timeout: 5000 });
44
+ }
45
+ catch { /* silently fail */ }
46
+ }
47
+ /** 线性插值百分位数,输入须为有序数组 */
48
+ function computePercentile(sortedArr, p) {
49
+ if (sortedArr.length === 0)
50
+ return null;
51
+ if (sortedArr.length === 1)
52
+ return sortedArr[0];
53
+ const idx = (p / 100) * (sortedArr.length - 1);
54
+ const lo = Math.floor(idx);
55
+ const hi = Math.ceil(idx);
56
+ if (lo === hi)
57
+ return sortedArr[lo];
58
+ return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
59
+ }
60
+ function aggregatePerfStats(logs) {
61
+ const map = new Map();
62
+ for (const entry of logs) {
63
+ const key = entry.model;
64
+ let s = map.get(key);
65
+ if (!s) {
66
+ s = {
67
+ model: key,
68
+ providerID: entry.providerID,
69
+ requestCount: 0,
70
+ ttftCount: 0, // Bug fix: 独立维护有效样本计数
71
+ tpsCount: 0,
72
+ latencyCount: 0,
73
+ totalInput: 0, totalOutput: 0, totalCacheRead: 0, totalCacheWrite: 0, totalCost: 0,
74
+ avgTTFT: null, maxTTFT: null, minTTFT: null,
75
+ p50TTFT: null, p95TTFT: null, p99TTFT: null,
76
+ avgTPS: null, maxTPS: null, minTPS: null,
77
+ avgLatency: null, maxLatency: null, minLatency: null,
78
+ p50Latency: null, p95Latency: null, p99Latency: null,
79
+ cacheHitRate: null,
80
+ };
81
+ map.set(key, s);
82
+ }
83
+ s.requestCount++;
84
+ s.totalInput += entry.inputTokens;
85
+ s.totalOutput += entry.outputTokens;
86
+ s.totalCacheRead += entry.cacheReadTokens;
87
+ s.totalCacheWrite += entry.cacheWriteTokens;
88
+ s.totalCost += entry.cost;
89
+ if (entry.ttft_ms != null) {
90
+ // Bug fix: 分母使用 ttftCount(有效样本数),而非 requestCount(总请求数)
91
+ s.ttftCount++;
92
+ const c = s.ttftCount;
93
+ s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
94
+ s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
95
+ s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
96
+ }
97
+ if (entry.tps != null) {
98
+ // Bug fix: 分母使用 tpsCount(有效样本数)
99
+ s.tpsCount++;
100
+ const c = s.tpsCount;
101
+ s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
102
+ s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
103
+ s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
104
+ }
105
+ if (entry.latency_ms != null) {
106
+ s.latencyCount++;
107
+ const c = s.latencyCount;
108
+ s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
109
+ s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
110
+ s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
111
+ }
112
+ }
113
+ // 分位数后处理:需要收集每个模型的所有样本然后计算
114
+ // 注: 此处采用单次遍历日志重新收集分数据,需要两次遍历
115
+ const ttftBuckets = new Map();
116
+ const latBuckets = new Map();
117
+ for (const entry of logs) {
118
+ const key = entry.model;
119
+ if (entry.ttft_ms != null) {
120
+ const arr = ttftBuckets.get(key) ?? [];
121
+ arr.push(entry.ttft_ms);
122
+ ttftBuckets.set(key, arr);
123
+ }
124
+ if (entry.latency_ms != null) {
125
+ const arr = latBuckets.get(key) ?? [];
126
+ arr.push(entry.latency_ms);
127
+ latBuckets.set(key, arr);
128
+ }
129
+ }
130
+ const result = Array.from(map.values());
131
+ for (const s of result) {
132
+ const ttftArr = [...(ttftBuckets.get(s.model) ?? [])].sort((a, b) => a - b);
133
+ s.p50TTFT = computePercentile(ttftArr, 50);
134
+ s.p95TTFT = computePercentile(ttftArr, 95);
135
+ s.p99TTFT = computePercentile(ttftArr, 99);
136
+ const latArr = [...(latBuckets.get(s.model) ?? [])].sort((a, b) => a - b);
137
+ s.p50Latency = computePercentile(latArr, 50);
138
+ s.p95Latency = computePercentile(latArr, 95);
139
+ s.p99Latency = computePercentile(latArr, 99);
140
+ const denom = s.totalInput + s.totalCacheRead;
141
+ s.cacheHitRate = denom > 0 ? (s.totalCacheRead / denom) * 100 : null;
142
+ }
143
+ return result;
144
+ }
145
+ async function buildCombinedData(api, filters = {}) {
146
+ const report = await getUsageReport(filters);
147
+ const logs = readLogs(1000);
148
+ const now = new Date();
149
+ const pad = (n) => String(n).padStart(2, '0');
150
+ const meta = {
151
+ generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
152
+ dateRange: {
153
+ start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "—",
154
+ end: report.daily.length > 0 ? report.daily[0].day : "—",
155
+ },
156
+ };
157
+ return {
158
+ ...report,
159
+ perfLogs: logs,
160
+ perfSummary: aggregatePerfStats(logs),
161
+ meta,
162
+ };
163
+ }
164
+ async function showHtmlReport(api, filters = {}) {
165
+ try {
166
+ const data = await buildCombinedData(api, filters);
167
+ const html = generateUsageHtml(data);
168
+ const dir = ensureReportDir();
169
+ const dateStr = new Date().toISOString().slice(0, 10);
170
+ const filePath = join(dir, `tokenwatch-${dateStr}.html`);
171
+ writeFileSync(filePath, html, "utf-8");
172
+ api.ui.toast?.({ message: `Report: ${filePath}`, variant: "info" });
173
+ openInBrowser(filePath);
174
+ }
175
+ catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
178
+ }
179
+ }
180
+ function showHtmlReportRangeMenu(api, dialog) {
181
+ dialog.replace(() => (<api.ui.DialogSelect title={t("cmdTitleHtml")} placeholder="Select date range..." options={[
182
+ {
183
+ title: t("menuToday"),
184
+ value: "today",
185
+ onSelect: () => {
186
+ dialog.clear();
187
+ const d = new Date();
188
+ const pad = (n) => String(n).padStart(2, "0");
189
+ const s = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
190
+ showHtmlReport(api, { startDate: s, endDate: s });
191
+ },
192
+ },
193
+ {
194
+ title: t("menu7d"),
195
+ value: "7d",
196
+ onSelect: () => { dialog.clear(); showHtmlReport(api, getPresetRange("7d")); },
197
+ },
198
+ {
199
+ title: t("menu30d"),
200
+ value: "30d",
201
+ onSelect: () => { dialog.clear(); showHtmlReport(api, getPresetRange("30d")); },
202
+ },
203
+ {
204
+ title: t("menuAll"),
205
+ value: "all",
206
+ onSelect: () => { dialog.clear(); showHtmlReport(api, getPresetRange("all")); },
207
+ },
208
+ ]} flat={true}/>));
209
+ }
210
+ function showUsageMenu(api, dialog) {
211
+ dialog.replace(() => (<api.ui.DialogSelect title={t("panelTitle")} placeholder="Select an action..." options={[
212
+ {
213
+ title: `${t("cmdTitleHtml")} ▸`,
214
+ value: "html",
215
+ description: t("cmdDescHtml"),
216
+ onSelect: () => showHtmlReportRangeMenu(api, dialog),
217
+ },
218
+ {
219
+ title: t("cmdTitleJson"),
220
+ value: "json",
221
+ description: t("cmdDescJson"),
222
+ onSelect: () => { dialog.clear(); showJsonExport(api); },
223
+ },
224
+ {
225
+ title: t("cmdTitleText"),
226
+ value: "text",
227
+ description: t("cmdDescText"),
228
+ onSelect: () => { dialog.clear(); showTextReport(api); },
229
+ },
230
+ {
231
+ title: `${t("cmdTitleSettings")} ▸`,
232
+ value: "settings",
233
+ description: t("cmdDescSettings"),
234
+ onSelect: () => showSettingsDialog(api, dialog),
235
+ },
236
+ ]} flat={true}/>));
237
+ }
238
+ async function showJsonExport(api) {
239
+ try {
240
+ const report = await getUsageReport({});
241
+ const dir = ensureReportDir();
242
+ const dateStr = new Date().toISOString().slice(0, 10);
243
+ const filePath = join(dir, `tokenwatch-${dateStr}.json`);
244
+ writeFileSync(filePath, JSON.stringify(report, null, 2), "utf-8");
245
+ api.ui.toast?.({ message: `JSON: ${filePath}`, variant: "info" });
246
+ }
247
+ catch (err) {
248
+ const msg = err instanceof Error ? err.message : String(err);
249
+ api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
250
+ }
251
+ }
252
+ async function showTextReport(api) {
253
+ try {
254
+ const report = await getUsageReport({});
255
+ const formatted = formatUsageReport(report);
256
+ const dir = ensureReportDir();
257
+ const dateStr = new Date().toISOString().slice(0, 10);
258
+ const filePath = join(dir, `tokenwatch-${dateStr}.md`);
259
+ writeFileSync(filePath, formatted, "utf-8");
260
+ api.ui.toast?.({ message: `Report saved to ${filePath}`, variant: "info" });
261
+ }
262
+ catch (err) {
263
+ const msg = err instanceof Error ? err.message : String(err);
264
+ api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
265
+ }
266
+ }
267
+ function saveConfigToStore(api, cfg) {
268
+ api.kv?.set?.("tokenwatch-config", cfg);
269
+ }
270
+ let lastSelectedSetting;
271
+ function showSettingsDialog(api, dialog) {
272
+ if (!dialog)
273
+ return;
274
+ const reopen = (value) => {
275
+ lastSelectedSetting = value;
276
+ setTimeout(() => showSettingsDialog(api, dialog), 0);
277
+ };
278
+ const cfg = loadConfigFromStore(api).sidebar;
279
+ dialog.replace(() => (<api.ui.DialogSelect title={t("settingsTitle")} placeholder={t("settingsPlaceholder")} options={[
280
+ {
281
+ title: `${cfg.showPerformance ? "✓ " : " "}${t("showPerformance")}`,
282
+ value: "showPerformance",
283
+ description: t("descShowPerformance"),
284
+ onSelect: () => { toggleSidebarSetting(api, "showPerformance"); reopen("showPerformance"); },
285
+ },
286
+ {
287
+ title: `${cfg.showPricing ? "✓ " : " "}${t("showPricing")}`,
288
+ value: "showPricing",
289
+ description: t("descShowPricing"),
290
+ onSelect: () => { toggleSidebarSetting(api, "showPricing"); reopen("showPricing"); },
291
+ },
292
+ {
293
+ title: `${cfg.showTokenDistribution ? "✓ " : " "}${t("showTokenDistribution")}`,
294
+ value: "showTokenDistribution",
295
+ description: t("descShowTokenDistribution"),
296
+ onSelect: () => { toggleSidebarSetting(api, "showTokenDistribution"); reopen("showTokenDistribution"); },
297
+ },
298
+ {
299
+ title: `${cfg.showTrend ? "✓ " : " "}${t("showTrend")}`,
300
+ value: "showTrend",
301
+ description: t("descShowTrend"),
302
+ onSelect: () => { toggleSidebarSetting(api, "showTrend"); reopen("showTrend"); },
303
+ },
304
+ {
305
+ title: `${t("settingsLanguage")} ▸`,
306
+ value: "language",
307
+ description: t("descSettingsLanguage"),
308
+ onSelect: () => showLanguageMenu(api, dialog),
309
+ },
310
+ {
311
+ title: t("done"),
312
+ value: "done",
313
+ description: t("closeSettings"),
314
+ onSelect: () => { lastSelectedSetting = undefined; dialog.clear(); },
315
+ },
316
+ ]} flat={true} current={lastSelectedSetting}/>));
317
+ }
318
+ function showLanguageMenu(api, dialog) {
319
+ const current = api.kv?.get?.("tokenwatch-config")?.language ?? "auto";
320
+ dialog.replace(() => (<api.ui.DialogSelect title={t("settingsLanguage")} placeholder={t("settingsLanguage")} options={[
321
+ {
322
+ title: `${current === "auto" ? "✓ " : " "}${t("langAuto")}`,
323
+ value: "auto",
324
+ description: "自动检测 / Auto-detect",
325
+ onSelect: () => { setLanguageSetting(api, "auto"); lastSelectedSetting = "language"; dialog.clear(); showSettingsDialog(api, dialog); },
326
+ },
327
+ {
328
+ title: `${current === "zh" ? "✓ " : " "}中文`,
329
+ value: "zh",
330
+ description: "简体中文",
331
+ onSelect: () => { setLanguageSetting(api, "zh"); lastSelectedSetting = "language"; dialog.clear(); showSettingsDialog(api, dialog); },
332
+ },
333
+ {
334
+ title: `${current === "en" ? "✓ " : " "}English`,
335
+ value: "en",
336
+ description: "English",
337
+ onSelect: () => { setLanguageSetting(api, "en"); lastSelectedSetting = "language"; dialog.clear(); showSettingsDialog(api, dialog); },
338
+ },
339
+ ]} flat={true}/>));
340
+ }
341
+ function setLanguageSetting(api, lang) {
342
+ setLanguage(lang);
343
+ const current = loadConfigFromStore(api);
344
+ current.language = lang;
345
+ saveConfigToStore(api, current);
346
+ const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
347
+ api.kv?.set?.("tokenwatch-config-version", v);
348
+ }
349
+ function toggleSidebarSetting(api, key) {
350
+ const current = loadConfigFromStore(api);
351
+ current.sidebar[key] = !current.sidebar[key];
352
+ saveConfigToStore(api, current);
353
+ const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
354
+ api.kv?.set?.("tokenwatch-config-version", v);
355
+ }
356
+ function loadConfigFromStore(api) {
357
+ const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
358
+ try {
359
+ const stored = api.kv?.get?.("tokenwatch-config");
360
+ if (stored) {
361
+ if (stored.sidebar)
362
+ Object.assign(base.sidebar, stored.sidebar);
363
+ if (stored.language)
364
+ base.language = stored.language;
365
+ }
366
+ }
367
+ catch { /* defaults */ }
368
+ return base;
369
+ }
@@ -67,6 +67,22 @@ export interface SessionBreakdownItem {
67
67
  totalCost: number;
68
68
  day: string;
69
69
  }
70
+ /** 失败请求统计 */
71
+ export interface ErrorStats {
72
+ /** 过滤内总请求数(tokens.total > 0 的 assistant) */
73
+ successCount: number;
74
+ /** 失败请求数(tokens.total == 0 的 assistant) */
75
+ failedCount: number;
76
+ /** 失败率:failedCount / (successCount + failedCount) */
77
+ errorRate: number;
78
+ /** 按模型细化的失败数 */
79
+ byModel: Array<{
80
+ provider: string;
81
+ model: string;
82
+ failed: number;
83
+ total: number;
84
+ }>;
85
+ }
70
86
  export interface UsageReport {
71
87
  filters: UsageFilters;
72
88
  summary: SessionTokenData;
@@ -74,9 +90,11 @@ export interface UsageReport {
74
90
  providers: ProviderBreakdownItem[];
75
91
  daily: DailyBreakdownItem[];
76
92
  sessions: SessionBreakdownItem[];
93
+ errors?: ErrorStats;
77
94
  }
78
95
  export declare function formatTokens(n: number): string;
79
96
  export declare function formatCost(n: number): string;
97
+ export declare function formatDuration(ms: number | null): string;
80
98
  export declare function formatFilters(filters: UsageFilters): string;
81
99
  export declare function formatSessionSummary(data: SessionTokenData, title?: string): string;
82
100
  export declare function formatStatusBar(data: SessionTokenData): string;
@@ -85,3 +103,88 @@ export declare function formatProviderBreakdown(items: ProviderBreakdownItem[]):
85
103
  export declare function formatDailyBreakdown(items: DailyBreakdownItem[]): string;
86
104
  export declare function formatSessionBreakdown(items: SessionBreakdownItem[]): string;
87
105
  export declare function formatUsageReport(report: UsageReport): string;
106
+ export interface SessionPerfStats {
107
+ models: Record<string, ModelPerfStats>;
108
+ totals: {
109
+ totalInput: number;
110
+ totalOutput: number;
111
+ totalCacheRead: number;
112
+ totalCacheWrite: number;
113
+ totalRequests: number;
114
+ totalCost: number;
115
+ /** 全局加权缓存命中率(按请求数加权平均) */
116
+ weightedCacheHitRate: number | null;
117
+ };
118
+ }
119
+ export interface ModelPerfStats {
120
+ model: string;
121
+ providerID: string;
122
+ requestCount: number;
123
+ ttftCount: number;
124
+ tpsCount: number;
125
+ latencyCount: number;
126
+ totalInput: number;
127
+ totalOutput: number;
128
+ totalCacheRead: number;
129
+ totalCacheWrite: number;
130
+ totalCost: number;
131
+ avgTTFT: number | null;
132
+ maxTTFT: number | null;
133
+ minTTFT: number | null;
134
+ p50TTFT: number | null;
135
+ p95TTFT: number | null;
136
+ p99TTFT: number | null;
137
+ avgTPS: number | null;
138
+ maxTPS: number | null;
139
+ minTPS: number | null;
140
+ avgLatency: number | null;
141
+ maxLatency: number | null;
142
+ minLatency: number | null;
143
+ p50Latency: number | null;
144
+ p95Latency: number | null;
145
+ p99Latency: number | null;
146
+ /** 该模型加权缓存命中率:cacheRead / (cacheRead + input) */
147
+ cacheHitRate: number | null;
148
+ }
149
+ export interface TokenDistribution {
150
+ system: number;
151
+ user: number;
152
+ agent: number;
153
+ toolCall: number;
154
+ toolResult: number;
155
+ output: number;
156
+ total: number;
157
+ }
158
+ export interface LogEntry {
159
+ ts: string;
160
+ model: string;
161
+ providerID: string;
162
+ modelID: string;
163
+ sessionID: string;
164
+ ttft_ms: number | null;
165
+ tps: number | null;
166
+ latency_ms: number | null;
167
+ inputTokens: number;
168
+ outputTokens: number;
169
+ reasoningTokens: number;
170
+ cacheReadTokens: number;
171
+ cacheWriteTokens: number;
172
+ cost: number;
173
+ }
174
+ export interface HtmlReportMeta {
175
+ generatedAt: string;
176
+ dateRange: {
177
+ start: string;
178
+ end: string;
179
+ };
180
+ }
181
+ export interface CombinedReportData {
182
+ summary: SessionTokenData;
183
+ models: ModelBreakdownItem[];
184
+ providers: ProviderBreakdownItem[];
185
+ daily: DailyBreakdownItem[];
186
+ sessions: SessionBreakdownItem[];
187
+ perfLogs: LogEntry[];
188
+ perfSummary: ModelPerfStats[];
189
+ meta: HtmlReportMeta;
190
+ }
package/dist/formatter.js CHANGED
@@ -14,6 +14,17 @@ export function formatCost(n) {
14
14
  return `$${n.toFixed(4)}`;
15
15
  return `$${n.toFixed(2)}`;
16
16
  }
17
+ export function formatDuration(ms) {
18
+ if (ms === null)
19
+ return "—";
20
+ if (ms < 1000)
21
+ return `${ms.toFixed(0)}ms`;
22
+ if (ms < 60000)
23
+ return `${(ms / 1000).toFixed(1)}s`;
24
+ const m = Math.floor(ms / 60000);
25
+ const s = Math.floor((ms % 60000) / 1000);
26
+ return `${m}m ${s}s`;
27
+ }
17
28
  export function formatFilters(filters) {
18
29
  const parts = [];
19
30
  if (filters.sessionId)
@@ -87,13 +98,13 @@ export function formatSessionSummary(data, title = "Current Session") {
87
98
  `═══ ${title} ═══`,
88
99
  `Models: ${modelLabel}`,
89
100
  `Provider: ${data.provider || "(mixed)"}`,
90
- `Requests: ${data.requestCount}`,
101
+ `Req: ${data.requestCount}`,
91
102
  `Total Tokens: ${formatTokens(data.totalTokens)}`,
92
103
  ` Input: ${formatTokens(data.inputTokens)}`,
93
104
  ` Output: ${formatTokens(data.outputTokens)}`,
94
105
  ` Reasoning: ${formatTokens(data.reasoningTokens)}`,
95
- ` Cache Read: ${formatTokens(data.cacheRead)}`,
96
- ` Cache Write: ${formatTokens(data.cacheWrite)}`,
106
+ ` C.Read: ${formatTokens(data.cacheRead)}`,
107
+ ` C.Write: ${formatTokens(data.cacheWrite)}`,
97
108
  ` Cost: ${formatCost(data.totalCost)}`,
98
109
  ].join("\n");
99
110
  }
@@ -0,0 +1,2 @@
1
+ import type { CombinedReportData } from "./formatter.js";
2
+ export declare function generateUsageHtml(data: CombinedReportData): string;