@leo000001/opencode-quota-sidebar 3.0.10 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +0 -1
  2. package/README.md +157 -42
  3. package/README.zh-CN.md +157 -42
  4. package/SECURITY.md +1 -1
  5. package/dist/cli.d.ts +18 -0
  6. package/dist/cli.js +354 -0
  7. package/dist/cli_render.d.ts +17 -0
  8. package/dist/cli_render.js +292 -0
  9. package/dist/events.d.ts +1 -1
  10. package/dist/events.js +2 -2
  11. package/dist/format.d.ts +4 -0
  12. package/dist/format.js +302 -41
  13. package/dist/history_messages.d.ts +8 -0
  14. package/dist/history_messages.js +157 -0
  15. package/dist/history_usage.d.ts +93 -0
  16. package/dist/history_usage.js +251 -0
  17. package/dist/index.js +29 -4
  18. package/dist/period.d.ts +29 -1
  19. package/dist/period.js +187 -9
  20. package/dist/provider_catalog.d.ts +8 -0
  21. package/dist/provider_catalog.js +68 -0
  22. package/dist/providers/core/anthropic.d.ts +1 -1
  23. package/dist/providers/core/anthropic.js +69 -45
  24. package/dist/providers/core/openai.js +38 -2
  25. package/dist/providers/index.d.ts +1 -2
  26. package/dist/providers/index.js +1 -3
  27. package/dist/quota.d.ts +4 -2
  28. package/dist/quota.js +18 -21
  29. package/dist/quota_render.d.ts +1 -1
  30. package/dist/quota_render.js +23 -24
  31. package/dist/quota_service.d.ts +1 -0
  32. package/dist/quota_service.js +151 -19
  33. package/dist/storage.d.ts +1 -1
  34. package/dist/storage.js +4 -4
  35. package/dist/storage_dates.d.ts +1 -1
  36. package/dist/storage_dates.js +8 -5
  37. package/dist/storage_parse.js +23 -1
  38. package/dist/supported_quota.d.ts +4 -0
  39. package/dist/supported_quota.js +36 -0
  40. package/dist/title.js +18 -8
  41. package/dist/tools.d.ts +14 -3
  42. package/dist/tools.js +54 -2
  43. package/dist/tui.tsx +17 -6
  44. package/dist/tui_helpers.js +11 -6
  45. package/dist/types.d.ts +8 -0
  46. package/dist/usage.d.ts +18 -0
  47. package/dist/usage.js +93 -9
  48. package/dist/usage_service.d.ts +4 -1
  49. package/dist/usage_service.js +193 -189
  50. package/package.json +4 -1
  51. package/quota-sidebar.config.example.json +36 -45
  52. package/dist/providers/third_party/xyai.d.ts +0 -2
  53. package/dist/providers/third_party/xyai.js +0 -348
@@ -0,0 +1,93 @@
1
+ import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
+ import { type HistoryPeriod, type PeriodRange, type SinceSpec } from './period.js';
3
+ import { type UsageSummary } from './usage.js';
4
+ import type { CacheCoverageMode, CachedSessionUsage, IncrementalCursor } from './types.js';
5
+ export type HistoryDialogRow = {
6
+ label: string;
7
+ isCurrent: boolean;
8
+ usage: UsageSummary;
9
+ };
10
+ export type HistoryDialogData = {
11
+ period: HistoryPeriod;
12
+ since: string;
13
+ rows: HistoryDialogRow[];
14
+ total: UsageSummary;
15
+ warning?: string;
16
+ };
17
+ export type HistoryUsageRow = {
18
+ range: PeriodRange;
19
+ usage: UsageSummary;
20
+ };
21
+ export type HistoryUsageResult = {
22
+ period: HistoryPeriod;
23
+ since: SinceSpec;
24
+ rows: HistoryUsageRow[];
25
+ total: UsageSummary;
26
+ warning?: string;
27
+ persistenceHints?: HistoryPersistenceHint[];
28
+ };
29
+ type MessageEntry = {
30
+ info: Message;
31
+ };
32
+ export type HistoryPersistenceHint = {
33
+ sessionID: string;
34
+ dateKey: string;
35
+ lastMessageTime: number | undefined;
36
+ dirty: boolean;
37
+ ranges: UsageSummary[];
38
+ totalUsage: UsageSummary;
39
+ fullUsage: UsageSummary | undefined;
40
+ persist: boolean;
41
+ cursor: IncrementalCursor | undefined;
42
+ missing: boolean;
43
+ loadFailed: boolean;
44
+ };
45
+ export type LoadMessagesPageResult = {
46
+ status: 'ok';
47
+ entries: MessageEntry[];
48
+ nextBefore?: string;
49
+ } | {
50
+ status: 'missing';
51
+ } | {
52
+ status: 'error';
53
+ };
54
+ type SessionEntry = {
55
+ sessionID: string;
56
+ dateKey: string;
57
+ state: {
58
+ createdAt: number;
59
+ cursor?: IncrementalCursor;
60
+ dirty?: boolean;
61
+ usage?: CachedSessionUsage;
62
+ };
63
+ };
64
+ export type ComputeHistoryUsageDeps = {
65
+ /** All known sessions (from memory state + disk chunks). */
66
+ sessions: SessionEntry[];
67
+ /** Paged message loader — injected so both server and TUI can provide their own implementation. */
68
+ loadMessagesPage: (sessionID: string, before?: string) => Promise<LoadMessagesPageResult>;
69
+ /** Model pricing map for API-cost estimation. */
70
+ getModelCostMap: () => Promise<Record<string, unknown>>;
71
+ /** Calculate equivalent API cost for a single assistant message. */
72
+ calcApiCost: (message: AssistantMessage, modelCostMap: Record<string, unknown>) => number;
73
+ /** Classify cache coverage mode for a single assistant message. */
74
+ classifyCacheMode: (message: AssistantMessage, modelCostMap: Record<string, unknown>) => CacheCoverageMode;
75
+ /** Check whether a set of entries has at least one resolvable API-cost message. */
76
+ hasResolvableApiCostMessages: (entries: MessageEntry[], modelCostMap: Record<string, unknown>) => boolean;
77
+ /** Whether the cached usage for a session needs a full recompute. */
78
+ shouldTrackFullUsage: (cached: CachedSessionUsage | undefined, hasPricing: boolean) => boolean;
79
+ /** Whether cached usage needs recompute (for persistence decision). */
80
+ shouldRecomputeUsageCache: (cached: CachedSessionUsage, hasPricing: boolean, hasResolvableApiCostMessage: boolean) => boolean;
81
+ throwOnLoadFailure?: boolean;
82
+ };
83
+ /**
84
+ * Compute day/week/month history usage by paging through session messages.
85
+ *
86
+ * This is the shared core used by both the server-side `quota_summary` tool
87
+ * and the TUI-local `/qday /qweek /qmonth` popup.
88
+ *
89
+ * Callers inject their own `loadMessagesPage` implementation so the function
90
+ * does not depend on any specific runtime context.
91
+ */
92
+ export declare function computeHistoryUsage(deps: ComputeHistoryUsageDeps, period: HistoryPeriod, rawSince: string): Promise<HistoryUsageResult>;
93
+ export {};
@@ -0,0 +1,251 @@
1
+ import { parseSince, periodRanges, } from './period.js';
2
+ import { mapConcurrent } from './helpers.js';
3
+ import { accumulateMessagesAcrossCompletedRanges, accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
4
+ // ── Constants ──────────────────────────────────────────────────────────────
5
+ const RANGE_USAGE_CONCURRENCY = 5;
6
+ // ── Core ───────────────────────────────────────────────────────────────────
7
+ function filterRangeSessions(sessions, startAt, endAt) {
8
+ return sessions.filter((session) => {
9
+ if (session.state.createdAt > endAt)
10
+ return false;
11
+ if (session.state.dirty === true)
12
+ return true;
13
+ const lastMessageTime = session.state.cursor?.lastMessageTime;
14
+ if (typeof lastMessageTime === 'number' &&
15
+ Number.isFinite(lastMessageTime) &&
16
+ lastMessageTime < startAt) {
17
+ return false;
18
+ }
19
+ return true;
20
+ });
21
+ }
22
+ function pageLatestTimestamp(entries) {
23
+ let latest = Number.NEGATIVE_INFINITY;
24
+ for (const entry of entries) {
25
+ const info = entry.info;
26
+ const completed = info.time?.completed;
27
+ if (typeof completed === 'number' && Number.isFinite(completed)) {
28
+ if (completed > latest)
29
+ latest = completed;
30
+ continue;
31
+ }
32
+ const created = info.time?.created;
33
+ if (typeof created === 'number' && Number.isFinite(created)) {
34
+ if (created > latest)
35
+ latest = created;
36
+ }
37
+ }
38
+ return latest;
39
+ }
40
+ function rangeIndexForTimestamp(ranges, timestamp) {
41
+ let low = 0;
42
+ let high = ranges.length - 1;
43
+ while (low <= high) {
44
+ const mid = Math.floor((low + high) / 2);
45
+ const range = ranges[mid];
46
+ if (timestamp < range.startAt) {
47
+ high = mid - 1;
48
+ continue;
49
+ }
50
+ if (timestamp >= range.endAt) {
51
+ low = mid + 1;
52
+ continue;
53
+ }
54
+ return mid;
55
+ }
56
+ return -1;
57
+ }
58
+ function canUseCurrentSessionCache(cached, session, ranges) {
59
+ if (!cached)
60
+ return undefined;
61
+ if (cached.billingVersion !== USAGE_BILLING_CACHE_VERSION)
62
+ return undefined;
63
+ if (session.state.dirty === true)
64
+ return undefined;
65
+ const lastMessageTime = session.state.cursor?.lastMessageTime;
66
+ if (typeof lastMessageTime !== 'number' ||
67
+ !Number.isFinite(lastMessageTime)) {
68
+ return undefined;
69
+ }
70
+ if (typeof session.state.createdAt !== 'number' ||
71
+ !Number.isFinite(session.state.createdAt)) {
72
+ return undefined;
73
+ }
74
+ const startIndex = rangeIndexForTimestamp(ranges, session.state.createdAt);
75
+ const endIndex = rangeIndexForTimestamp(ranges, lastMessageTime);
76
+ if (startIndex < 0 || endIndex < 0 || startIndex !== endIndex) {
77
+ return undefined;
78
+ }
79
+ return {
80
+ index: startIndex,
81
+ usage: fromCachedSessionUsage(cached, 1),
82
+ lastMessageTime,
83
+ };
84
+ }
85
+ /**
86
+ * Compute day/week/month history usage by paging through session messages.
87
+ *
88
+ * This is the shared core used by both the server-side `quota_summary` tool
89
+ * and the TUI-local `/qday /qweek /qmonth` popup.
90
+ *
91
+ * Callers inject their own `loadMessagesPage` implementation so the function
92
+ * does not depend on any specific runtime context.
93
+ */
94
+ export async function computeHistoryUsage(deps, period, rawSince) {
95
+ const now = Date.now();
96
+ const since = parseSince(rawSince, now);
97
+ const ranges = periodRanges(period, since, now);
98
+ const total = emptyUsageSummary();
99
+ const rows = ranges.map((range) => ({
100
+ range,
101
+ usage: emptyUsageSummary(),
102
+ }));
103
+ if (ranges.length === 0) {
104
+ return { period, since, rows, total };
105
+ }
106
+ const startAt = ranges[0].startAt;
107
+ const endAt = ranges[ranges.length - 1].endAt;
108
+ const sessions = filterRangeSessions(deps.sessions, startAt, endAt);
109
+ const modelCostMap = await deps.getModelCostMap();
110
+ const hasPricing = Object.keys(modelCostMap).length > 0;
111
+ if (sessions.length > 0) {
112
+ const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
113
+ const cachedHit = canUseCurrentSessionCache(session.state.usage, session, rows.map((row) => ({
114
+ startAt: row.range.startAt,
115
+ endAt: row.range.endAt,
116
+ })));
117
+ if (cachedHit) {
118
+ const rangeUsage = rows.map(() => emptyUsageSummary());
119
+ rangeUsage[cachedHit.index] = cachedHit.usage;
120
+ return {
121
+ sessionID: session.sessionID,
122
+ dateKey: session.dateKey,
123
+ lastMessageTime: cachedHit.lastMessageTime,
124
+ dirty: false,
125
+ ranges: rangeUsage,
126
+ totalUsage: cachedHit.usage,
127
+ fullUsage: undefined,
128
+ loadFailed: false,
129
+ missing: false,
130
+ persist: false,
131
+ cursor: session.state.cursor,
132
+ };
133
+ }
134
+ const usageOptions = {
135
+ calcApiCost: (message) => deps.calcApiCost(message, modelCostMap),
136
+ classifyCacheMode: (message) => deps.classifyCacheMode(message, modelCostMap),
137
+ };
138
+ const rangeUsage = rows.map(() => emptyUsageSummary());
139
+ const totalUsage = emptyUsageSummary();
140
+ const trackFullUsage = deps.shouldTrackFullUsage(session.state.usage, hasPricing);
141
+ const fullUsage = trackFullUsage ? emptyUsageSummary() : undefined;
142
+ let cursor;
143
+ let hasResolvable = false;
144
+ let before;
145
+ while (true) {
146
+ const load = await deps.loadMessagesPage(session.sessionID, before);
147
+ if (load.status !== 'ok') {
148
+ return {
149
+ sessionID: session.sessionID,
150
+ dateKey: session.dateKey,
151
+ lastMessageTime: session.state.cursor?.lastMessageTime,
152
+ dirty: session.state.dirty === true,
153
+ ranges: rows.map(() => emptyUsageSummary()),
154
+ totalUsage: emptyUsageSummary(),
155
+ fullUsage: undefined,
156
+ loadFailed: load.status === 'error',
157
+ missing: load.status === 'missing',
158
+ persist: false,
159
+ cursor: undefined,
160
+ };
161
+ }
162
+ const entries = load.entries;
163
+ if (entries.length === 0)
164
+ break;
165
+ accumulateMessagesAcrossCompletedRanges(rangeUsage, entries, rows.map((row) => ({
166
+ startAt: row.range.startAt,
167
+ endAt: row.range.endAt,
168
+ })), usageOptions);
169
+ if (fullUsage) {
170
+ accumulateMessagesInCompletedRange(fullUsage, entries, 0, Number.POSITIVE_INFINITY, usageOptions);
171
+ cursor = mergeCursorFromEntries(cursor, entries);
172
+ }
173
+ if (!hasResolvable) {
174
+ hasResolvable = deps.hasResolvableApiCostMessages(entries, modelCostMap);
175
+ }
176
+ // `session.messages(limit, before)` pages from newest to oldest.
177
+ // When we are only computing range usage (no full-session persistence),
178
+ // we can stop as soon as this page's newest timestamp is already older
179
+ // than the earliest requested range boundary.
180
+ if (!fullUsage) {
181
+ const latestInPage = pageLatestTimestamp(entries);
182
+ if (Number.isFinite(latestInPage) && latestInPage < startAt) {
183
+ break;
184
+ }
185
+ }
186
+ if (!load.nextBefore)
187
+ break;
188
+ before = load.nextBefore;
189
+ }
190
+ for (const item of rangeUsage) {
191
+ if (item.assistantMessages > 0) {
192
+ mergeUsage(totalUsage, item);
193
+ }
194
+ }
195
+ if (totalUsage.assistantMessages > 0) {
196
+ totalUsage.sessionCount = 1;
197
+ }
198
+ const shouldPersist = !!fullUsage &&
199
+ (!session.state.usage ||
200
+ deps.shouldRecomputeUsageCache(session.state.usage, hasPricing, hasResolvable));
201
+ return {
202
+ sessionID: session.sessionID,
203
+ dateKey: session.dateKey,
204
+ lastMessageTime: cursor?.lastMessageTime,
205
+ dirty: session.state.dirty === true,
206
+ ranges: rangeUsage,
207
+ totalUsage,
208
+ fullUsage: shouldPersist ? fullUsage : undefined,
209
+ loadFailed: false,
210
+ missing: false,
211
+ persist: shouldPersist,
212
+ cursor,
213
+ };
214
+ });
215
+ const failedLoads = fetched.filter((item) => {
216
+ if (!item.loadFailed)
217
+ return false;
218
+ if (item.dirty)
219
+ return true;
220
+ const lastMessageTime = item.lastMessageTime;
221
+ if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
222
+ return false;
223
+ }
224
+ return true;
225
+ });
226
+ if (failedLoads.length > 0 && deps.throwOnLoadFailure !== false) {
227
+ throw new Error(`history usage unavailable: failed to load ${failedLoads.length} session(s)`);
228
+ }
229
+ for (const item of fetched) {
230
+ for (let index = 0; index < rows.length; index++) {
231
+ if (item.ranges[index].assistantMessages > 0) {
232
+ mergeUsage(rows[index].usage, item.ranges[index]);
233
+ }
234
+ }
235
+ if (item.totalUsage.assistantMessages > 0) {
236
+ mergeUsage(total, item.totalUsage);
237
+ }
238
+ }
239
+ return {
240
+ period,
241
+ since,
242
+ rows,
243
+ total,
244
+ warning: deps.throwOnLoadFailure === false && failedLoads.length > 0
245
+ ? `Skipped ${failedLoads.length} session(s) that could not be loaded.`
246
+ : undefined,
247
+ persistenceHints: fetched,
248
+ };
249
+ }
250
+ return { period, since, rows, total };
251
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from './format.js';
1
+ import { renderHistoryMarkdownReport, renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from './format.js';
2
2
  import { createQuotaRuntime } from './quota.js';
3
3
  import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, quotaConfigPaths, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
4
4
  import { debug, swallow } from './helpers.js';
@@ -11,6 +11,7 @@ import { createPersistenceScheduler } from './persistence.js';
11
11
  import { createQuotaService } from './quota_service.js';
12
12
  import { createUsageService } from './usage_service.js';
13
13
  import { createTitleApplicator } from './title_apply.js';
14
+ import { listCurrentProviderIDs } from './provider_catalog.js';
14
15
  const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
15
16
  const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
16
17
  const SESSION_ACTIVE_GRACE_MS = 15_000;
@@ -45,6 +46,7 @@ export async function QuotaSidebarPlugin(input) {
45
46
  scheduleSave,
46
47
  });
47
48
  const getQuotaSnapshots = quotaService.getQuotaSnapshots;
49
+ const invalidateQuotaForProvider = quotaService.invalidateForProvider;
48
50
  const ensureSessionState = (sessionID, title, createdAt = Date.now(), parentID) => {
49
51
  const existing = state.sessions[sessionID];
50
52
  if (existing) {
@@ -117,7 +119,9 @@ export async function QuotaSidebarPlugin(input) {
117
119
  });
118
120
  const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
119
121
  const summarizeForTool = usageService.summarizeForTool;
122
+ const summarizeHistoryForTool = usageService.summarizeHistoryUsage;
120
123
  const activeSessionUntil = new Map();
124
+ let lastTuiSessionID;
121
125
  const markSessionActive = (sessionID, now = Date.now()) => {
122
126
  activeSessionUntil.set(sessionID, now + SESSION_ACTIVE_GRACE_MS);
123
127
  };
@@ -219,6 +223,8 @@ export async function QuotaSidebarPlugin(input) {
219
223
  }
220
224
  }
221
225
  const showToast = async (period, message) => {
226
+ if (!input.client.tui?.showToast)
227
+ return;
222
228
  await input.client.tui
223
229
  .showToast({
224
230
  query: { directory: input.directory },
@@ -345,10 +351,16 @@ export async function QuotaSidebarPlugin(input) {
345
351
  scheduleActiveTitleRefresh(session.parentID, 0);
346
352
  }
347
353
  },
348
- onTuiActivity: async () => {
349
- return;
354
+ onTuiActivity: async (sessionID) => {
355
+ const target = sessionID || lastTuiSessionID;
356
+ if (!target)
357
+ return;
358
+ lastTuiSessionID = target;
359
+ markSessionActive(target);
350
360
  },
351
361
  onTuiSessionSelect: async (sessionID) => {
362
+ lastTuiSessionID = sessionID;
363
+ markSessionActive(sessionID);
352
364
  scheduleActiveTitleRefresh(sessionID, 0);
353
365
  },
354
366
  onMessageRemoved: async (info) => {
@@ -360,16 +372,23 @@ export async function QuotaSidebarPlugin(input) {
360
372
  const now = Date.now();
361
373
  const completed = message.time.completed;
362
374
  if (typeof completed !== 'number' || !Number.isFinite(completed)) {
375
+ const created = message.time.created;
376
+ if (typeof created === 'number' &&
377
+ Number.isFinite(created) &&
378
+ created < now - SESSION_ACTIVE_GRACE_MS) {
379
+ return;
380
+ }
363
381
  markSessionActive(message.sessionID, now);
364
382
  return;
365
383
  }
384
+ invalidateQuotaForProvider(message.providerID);
366
385
  const wasActive = isSessionActive(message.sessionID, now);
367
386
  if (!wasActive) {
368
387
  return;
369
388
  }
370
389
  markSessionActive(message.sessionID, now);
371
390
  usageService.markSessionDirty(message.sessionID);
372
- scheduleActiveTitleRefresh(message.sessionID);
391
+ scheduleActiveTitleRefresh(message.sessionID, 100);
373
392
  void maybeShowExpiryToast(message.sessionID);
374
393
  },
375
394
  });
@@ -399,9 +418,15 @@ export async function QuotaSidebarPlugin(input) {
399
418
  restoreSessionTitle: (sessionID) => titleApplicator.restoreSessionTitle(sessionID),
400
419
  showToast,
401
420
  summarizeForTool,
421
+ summarizeHistoryForTool,
422
+ listCurrentProviderIDs: () => listCurrentProviderIDs({
423
+ client: input.client,
424
+ directory: input.directory,
425
+ }),
402
426
  getQuotaSnapshots,
403
427
  renderMarkdownReport,
404
428
  renderToastMessage,
429
+ renderHistoryMarkdownReport,
405
430
  config: {
406
431
  sidebar: config.sidebar,
407
432
  sidebarEnabled: config.sidebar.enabled,
package/dist/period.d.ts CHANGED
@@ -1 +1,29 @@
1
- export declare function periodStart(period: 'day' | 'week' | 'month'): number;
1
+ export type HistoryPeriod = 'day' | 'week' | 'month';
2
+ export type SincePrecision = 'month' | 'day';
3
+ export type SinceSpec = {
4
+ raw: string;
5
+ precision: SincePrecision;
6
+ startAt: number;
7
+ };
8
+ export type PeriodRange = {
9
+ period: HistoryPeriod;
10
+ startAt: number;
11
+ endAt: number;
12
+ label: string;
13
+ shortLabel: string;
14
+ isCurrent: boolean;
15
+ isPartial: boolean;
16
+ index: number;
17
+ };
18
+ export declare function parseSince(raw: string, now?: number): {
19
+ raw: string;
20
+ precision: "month";
21
+ startAt: number;
22
+ } | {
23
+ raw: string;
24
+ precision: "day";
25
+ startAt: number;
26
+ };
27
+ export declare function periodRanges(period: HistoryPeriod, since: SinceSpec, endAt?: number): PeriodRange[];
28
+ export declare function periodStart(period: HistoryPeriod, now?: number): number;
29
+ export declare function sinceFromLast(period: HistoryPeriod, last: number, now?: number): string;
package/dist/period.js CHANGED
@@ -1,14 +1,192 @@
1
- export function periodStart(period) {
2
- const now = new Date();
1
+ const MONTH_NAMES = [
2
+ 'Jan',
3
+ 'Feb',
4
+ 'Mar',
5
+ 'Apr',
6
+ 'May',
7
+ 'Jun',
8
+ 'Jul',
9
+ 'Aug',
10
+ 'Sep',
11
+ 'Oct',
12
+ 'Nov',
13
+ 'Dec',
14
+ ];
15
+ function pad2(value) {
16
+ return `${value}`.padStart(2, '0');
17
+ }
18
+ function startOfDay(timestamp) {
19
+ const date = new Date(timestamp);
20
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
21
+ }
22
+ function startOfMonth(timestamp) {
23
+ const date = new Date(timestamp);
24
+ return new Date(date.getFullYear(), date.getMonth(), 1).getTime();
25
+ }
26
+ function startOfWeek(timestamp) {
27
+ const date = new Date(timestamp);
28
+ const day = date.getDay();
29
+ const shift = day === 0 ? 6 : day - 1;
30
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - shift).getTime();
31
+ }
32
+ function nextDayStart(timestamp) {
33
+ const date = new Date(timestamp);
34
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime();
35
+ }
36
+ function nextWeekStart(timestamp) {
37
+ const weekStart = new Date(startOfWeek(timestamp));
38
+ return new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7).getTime();
39
+ }
40
+ function nextMonthStart(timestamp) {
41
+ const date = new Date(timestamp);
42
+ return new Date(date.getFullYear(), date.getMonth() + 1, 1).getTime();
43
+ }
44
+ function formatLocalDate(timestamp) {
45
+ const date = new Date(timestamp);
46
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
47
+ }
48
+ function formatMonthInput(timestamp) {
49
+ const date = new Date(timestamp);
50
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;
51
+ }
52
+ function formatMonthLabel(timestamp) {
53
+ const date = new Date(timestamp);
54
+ return `${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
55
+ }
56
+ function formatMonthShortLabel(timestamp) {
57
+ const date = new Date(timestamp);
58
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;
59
+ }
60
+ function formatDayShortLabel(timestamp) {
61
+ const date = new Date(timestamp);
62
+ return `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
63
+ }
64
+ function periodBoundaryStart(period, timestamp) {
65
+ if (period === 'month')
66
+ return startOfMonth(timestamp);
67
+ if (period === 'week')
68
+ return startOfWeek(timestamp);
69
+ return startOfDay(timestamp);
70
+ }
71
+ function nextPeriodBoundary(period, timestamp) {
72
+ if (period === 'month')
73
+ return nextMonthStart(timestamp);
74
+ if (period === 'week')
75
+ return nextWeekStart(timestamp);
76
+ return nextDayStart(timestamp);
77
+ }
78
+ function periodRangeLabels(period, startAt, endAt) {
3
79
  if (period === 'month') {
4
- return new Date(now.getFullYear(), now.getMonth(), 1).getTime();
80
+ return {
81
+ label: formatMonthLabel(startAt),
82
+ shortLabel: formatMonthShortLabel(startAt),
83
+ };
84
+ }
85
+ if (period === 'week') {
86
+ const endLabel = formatLocalDate(Math.max(startAt, endAt - 1));
87
+ const startLabel = formatLocalDate(startAt);
88
+ return {
89
+ label: `${startLabel} to ${endLabel}`,
90
+ shortLabel: `${startLabel}..${endLabel}`,
91
+ };
92
+ }
93
+ return {
94
+ label: formatLocalDate(startAt),
95
+ shortLabel: formatDayShortLabel(startAt),
96
+ };
97
+ }
98
+ export function parseSince(raw, now = Date.now()) {
99
+ const value = raw.trim();
100
+ const monthMatch = /^(\d{4})-(\d{2})$/.exec(value);
101
+ if (monthMatch) {
102
+ const year = Number(monthMatch[1]);
103
+ const month = Number(monthMatch[2]);
104
+ if (year < 100 || month < 1 || month > 12) {
105
+ throw new Error('`since` is not a valid calendar date');
106
+ }
107
+ const startAt = new Date(year, month - 1, 1).getTime();
108
+ if (Number.isNaN(startAt)) {
109
+ throw new Error('`since` is not a valid calendar date');
110
+ }
111
+ if (startAt > now) {
112
+ throw new Error('`since` cannot be in the future');
113
+ }
114
+ return { raw: value, precision: 'month', startAt };
115
+ }
116
+ const dayMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
117
+ if (dayMatch) {
118
+ const year = Number(dayMatch[1]);
119
+ const month = Number(dayMatch[2]);
120
+ const day = Number(dayMatch[3]);
121
+ if (year < 100) {
122
+ throw new Error('`since` is not a valid calendar date');
123
+ }
124
+ const startAt = new Date(year, month - 1, day).getTime();
125
+ const probe = new Date(startAt);
126
+ if (Number.isNaN(startAt) ||
127
+ probe.getFullYear() !== year ||
128
+ probe.getMonth() !== month - 1 ||
129
+ probe.getDate() !== day) {
130
+ throw new Error('`since` is not a valid calendar date');
131
+ }
132
+ if (startAt > now) {
133
+ throw new Error('`since` cannot be in the future');
134
+ }
135
+ return { raw: value, precision: 'day', startAt };
136
+ }
137
+ throw new Error('`since` must be `YYYY-MM` or `YYYY-MM-DD`');
138
+ }
139
+ export function periodRanges(period, since, endAt = Date.now()) {
140
+ if (since.startAt > endAt) {
141
+ throw new Error('`since` cannot be in the future');
142
+ }
143
+ const ranges = [];
144
+ let cursor = since.startAt;
145
+ let index = 0;
146
+ while (cursor < endAt) {
147
+ const boundaryStart = periodBoundaryStart(period, cursor);
148
+ const boundaryEnd = nextPeriodBoundary(period, cursor);
149
+ const rangeEnd = Math.min(boundaryEnd, endAt);
150
+ const isCurrent = rangeEnd === endAt && endAt !== periodBoundaryStart(period, endAt);
151
+ const isPartial = cursor !== boundaryStart || isCurrent;
152
+ const { label, shortLabel } = periodRangeLabels(period, cursor, rangeEnd);
153
+ ranges.push({
154
+ period,
155
+ startAt: cursor,
156
+ endAt: rangeEnd,
157
+ label,
158
+ shortLabel,
159
+ isCurrent,
160
+ isPartial,
161
+ index,
162
+ });
163
+ cursor = rangeEnd;
164
+ index += 1;
165
+ }
166
+ if (period === 'day' && ranges.length > 90) {
167
+ throw new Error('day history is limited to 90 days; choose a later `since` date');
168
+ }
169
+ return ranges;
170
+ }
171
+ export function periodStart(period, now = Date.now()) {
172
+ return periodBoundaryStart(period, now);
173
+ }
174
+ export function sinceFromLast(period, last, now = Date.now()) {
175
+ if (!Number.isInteger(last) || last < 1) {
176
+ throw new Error('`last` must be a positive integer');
177
+ }
178
+ const currentStart = periodBoundaryStart(period, now);
179
+ if (period === 'day') {
180
+ const date = new Date(currentStart);
181
+ const start = new Date(date.getFullYear(), date.getMonth(), date.getDate() - (last - 1)).getTime();
182
+ return formatLocalDate(start);
5
183
  }
6
184
  if (period === 'week') {
7
- const day = now.getDay();
8
- const shift = day === 0 ? 6 : day - 1;
9
- const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - shift);
10
- start.setHours(0, 0, 0, 0);
11
- return start.getTime();
185
+ const date = new Date(currentStart);
186
+ const start = new Date(date.getFullYear(), date.getMonth(), date.getDate() - 7 * (last - 1)).getTime();
187
+ return formatLocalDate(start);
12
188
  }
13
- return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
189
+ const date = new Date(currentStart);
190
+ const start = new Date(date.getFullYear(), date.getMonth() - (last - 1), 1).getTime();
191
+ return formatMonthInput(start);
14
192
  }
@@ -0,0 +1,8 @@
1
+ import type { HistoryUsageResult } from './usage_service.js';
2
+ import type { UsageSummary } from './usage.js';
3
+ export declare function listCurrentProviderIDs(input: {
4
+ client: unknown;
5
+ directory: string;
6
+ }): Promise<Set<string>>;
7
+ export declare function filterUsageProvidersForDisplay(usage: UsageSummary, allowedProviderIDs: ReadonlySet<string>): UsageSummary;
8
+ export declare function filterHistoryProvidersForDisplay(result: HistoryUsageResult, allowedProviderIDs: ReadonlySet<string>): HistoryUsageResult;