letmecode 0.1.0 → 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.
@@ -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,6 +1,10 @@
1
+ import { ClaudeUsageProvider } from "./claude.js";
1
2
  import { CodexUsageProvider } from "./codex.js";
3
+ import { CopilotUsageProvider } from "./copilot.js";
2
4
  export function createProviders() {
3
- return [new CodexUsageProvider()];
5
+ return [new CodexUsageProvider(), new ClaudeUsageProvider(), new CopilotUsageProvider()];
4
6
  }
7
+ export { ClaudeUsageProvider } from "./claude.js";
5
8
  export { CodexUsageProvider } from "./codex.js";
9
+ export { CopilotUsageProvider, configureCopilotVsCodeLogging } from "./copilot.js";
6
10
  export { UsageProviderBase } from "./contract.js";
@@ -0,0 +1,146 @@
1
+ import { addUsageTotals, createEmptyUsageTotals } from "./contract.js";
2
+ export function createLimitWindowAggregates() {
3
+ return new Map();
4
+ }
5
+ export function numberOrZero(value) {
6
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
7
+ }
8
+ export function asRecord(value) {
9
+ return value && typeof value === "object" ? value : null;
10
+ }
11
+ export function applyRateLimits(windows, rateLimits, eventTimeMs, deltaTotals, planTypes) {
12
+ if (!rateLimits) {
13
+ return;
14
+ }
15
+ if (typeof rateLimits.plan_type === "string") {
16
+ planTypes.add(rateLimits.plan_type);
17
+ }
18
+ upsertWindow(windows, "primary", rateLimits, asRecord(rateLimits.primary), eventTimeMs, deltaTotals);
19
+ upsertWindow(windows, "secondary", rateLimits, asRecord(rateLimits.secondary), eventTimeMs, deltaTotals);
20
+ }
21
+ export function buildWindowLists(windows) {
22
+ const rows = collapseNearbyWindows([...windows.values()].map((window) => ({
23
+ scope: window.scope,
24
+ planType: window.planType,
25
+ limitId: window.limitId,
26
+ windowMinutes: window.windowMinutes,
27
+ startTimeUtcIso: formatIsoFromSeconds(window.minStartsAt),
28
+ endTimeUtcIso: formatIsoFromSeconds(window.maxResetsAt),
29
+ firstSeenUtcIso: formatIsoFromMilliseconds(window.firstSeenMs),
30
+ lastSeenUtcIso: formatIsoFromMilliseconds(window.lastSeenMs),
31
+ minUsedPercent: window.minUsedPercent,
32
+ maxUsedPercent: window.maxUsedPercent,
33
+ totals: computeWindowTotals(window.events),
34
+ eventCount: 0
35
+ })))
36
+ .map((row) => ({
37
+ ...row,
38
+ eventCount: row.totals.eventCount
39
+ }))
40
+ .sort((left, right) => right.endTimeUtcIso.localeCompare(left.endTimeUtcIso));
41
+ const primary = rows.filter((row) => row.scope === "primary").slice(0, 5);
42
+ const secondary = rows.filter((row) => row.scope === "secondary").slice(0, 5);
43
+ return [primary, secondary];
44
+ }
45
+ function formatIsoFromSeconds(seconds) {
46
+ return new Date(seconds * 1000).toISOString().replace(".000Z", "Z");
47
+ }
48
+ function formatIsoFromMilliseconds(milliseconds) {
49
+ return new Date(milliseconds).toISOString().replace(".000Z", "Z");
50
+ }
51
+ function makeWindowKey(scope, rateLimits, window) {
52
+ return [
53
+ scope,
54
+ String(rateLimits.limit_id ?? "unknown"),
55
+ String(rateLimits.plan_type ?? "unknown"),
56
+ numberOrZero(window.window_minutes),
57
+ numberOrZero(window.resets_at)
58
+ ].join("|");
59
+ }
60
+ function collapseNearbyWindows(rows) {
61
+ const collapsed = new Map();
62
+ for (const row of rows) {
63
+ const key = [
64
+ row.scope,
65
+ row.limitId,
66
+ row.planType,
67
+ row.windowMinutes,
68
+ Math.round(Date.parse(row.endTimeUtcIso) / 60000)
69
+ ].join("|");
70
+ const existing = collapsed.get(key);
71
+ if (!existing) {
72
+ collapsed.set(key, {
73
+ ...row,
74
+ totals: { ...row.totals }
75
+ });
76
+ continue;
77
+ }
78
+ existing.startTimeUtcIso =
79
+ existing.startTimeUtcIso < row.startTimeUtcIso ? existing.startTimeUtcIso : row.startTimeUtcIso;
80
+ existing.endTimeUtcIso =
81
+ existing.endTimeUtcIso > row.endTimeUtcIso ? existing.endTimeUtcIso : row.endTimeUtcIso;
82
+ existing.firstSeenUtcIso =
83
+ existing.firstSeenUtcIso < row.firstSeenUtcIso ? existing.firstSeenUtcIso : row.firstSeenUtcIso;
84
+ existing.lastSeenUtcIso =
85
+ existing.lastSeenUtcIso > row.lastSeenUtcIso ? existing.lastSeenUtcIso : row.lastSeenUtcIso;
86
+ existing.minUsedPercent = Math.min(existing.minUsedPercent, row.minUsedPercent);
87
+ existing.maxUsedPercent = Math.max(existing.maxUsedPercent, row.maxUsedPercent);
88
+ addUsageTotals(existing.totals, row.totals);
89
+ existing.eventCount = existing.totals.eventCount;
90
+ }
91
+ return [...collapsed.values()];
92
+ }
93
+ function computeWindowTotals(events) {
94
+ // Session files are not guaranteed to be parsed in timestamp order, so
95
+ // saturation has to be applied after we sort the captured window events.
96
+ const totals = createEmptyUsageTotals();
97
+ let sawBelowCap = false;
98
+ let isExhausted = false;
99
+ for (const event of [...events].sort((left, right) => left.eventTimeMs - right.eventTimeMs)) {
100
+ sawBelowCap || (sawBelowCap = event.usedPercent < 100);
101
+ if (!isExhausted) {
102
+ addUsageTotals(totals, event.totals);
103
+ if (sawBelowCap && event.usedPercent >= 100) {
104
+ isExhausted = true;
105
+ }
106
+ }
107
+ }
108
+ return totals;
109
+ }
110
+ function upsertWindow(windows, scope, rateLimits, window, eventTimeMs, deltaTotals) {
111
+ if (!window) {
112
+ return;
113
+ }
114
+ const windowMinutes = numberOrZero(window.window_minutes);
115
+ const resetsAt = numberOrZero(window.resets_at);
116
+ if (!windowMinutes || !resetsAt) {
117
+ return;
118
+ }
119
+ const startsAt = resetsAt - windowMinutes * 60;
120
+ const usedPercent = numberOrZero(window.used_percent);
121
+ const key = makeWindowKey(scope, rateLimits, window);
122
+ const existing = windows.get(key);
123
+ if (!existing) {
124
+ windows.set(key, {
125
+ scope,
126
+ limitId: String(rateLimits.limit_id ?? "unknown"),
127
+ planType: String(rateLimits.plan_type ?? "unknown"),
128
+ windowMinutes,
129
+ minStartsAt: startsAt,
130
+ maxResetsAt: resetsAt,
131
+ firstSeenMs: eventTimeMs,
132
+ lastSeenMs: eventTimeMs,
133
+ minUsedPercent: usedPercent,
134
+ maxUsedPercent: usedPercent,
135
+ events: [{ eventTimeMs, usedPercent, totals: { ...deltaTotals } }]
136
+ });
137
+ return;
138
+ }
139
+ existing.minStartsAt = Math.min(existing.minStartsAt, startsAt);
140
+ existing.maxResetsAt = Math.max(existing.maxResetsAt, resetsAt);
141
+ existing.firstSeenMs = Math.min(existing.firstSeenMs, eventTimeMs);
142
+ existing.lastSeenMs = Math.max(existing.lastSeenMs, eventTimeMs);
143
+ existing.minUsedPercent = Math.min(existing.minUsedPercent, usedPercent);
144
+ existing.maxUsedPercent = Math.max(existing.maxUsedPercent, usedPercent);
145
+ existing.events.push({ eventTimeMs, usedPercent, totals: { ...deltaTotals } });
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmecode",
3
- "version": "0.1.0",
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": {