@leo000001/opencode-quota-sidebar 3.0.9 → 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.
- package/CHANGELOG.md +0 -1
- package/README.md +157 -42
- package/README.zh-CN.md +157 -42
- package/SECURITY.md +1 -1
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +354 -0
- package/dist/cli_render.d.ts +17 -0
- package/dist/cli_render.js +292 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.js +2 -2
- package/dist/format.d.ts +4 -0
- package/dist/format.js +302 -41
- package/dist/history_messages.d.ts +8 -0
- package/dist/history_messages.js +157 -0
- package/dist/history_usage.d.ts +93 -0
- package/dist/history_usage.js +251 -0
- package/dist/index.js +29 -4
- package/dist/period.d.ts +29 -1
- package/dist/period.js +187 -9
- package/dist/provider_catalog.d.ts +8 -0
- package/dist/provider_catalog.js +68 -0
- package/dist/providers/core/anthropic.d.ts +1 -1
- package/dist/providers/core/anthropic.js +69 -45
- package/dist/providers/core/openai.js +101 -8
- package/dist/providers/index.d.ts +1 -2
- package/dist/providers/index.js +1 -3
- package/dist/quota.d.ts +4 -2
- package/dist/quota.js +18 -21
- package/dist/quota_render.d.ts +1 -1
- package/dist/quota_render.js +23 -24
- package/dist/quota_service.d.ts +1 -0
- package/dist/quota_service.js +151 -19
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +4 -4
- package/dist/storage_dates.d.ts +1 -1
- package/dist/storage_dates.js +8 -5
- package/dist/storage_parse.js +23 -1
- package/dist/supported_quota.d.ts +4 -0
- package/dist/supported_quota.js +36 -0
- package/dist/title.js +18 -8
- package/dist/tools.d.ts +14 -3
- package/dist/tools.js +54 -2
- package/dist/tui.tsx +17 -6
- package/dist/tui_helpers.js +11 -6
- package/dist/types.d.ts +8 -0
- package/dist/usage.d.ts +18 -0
- package/dist/usage.js +93 -9
- package/dist/usage_service.d.ts +4 -1
- package/dist/usage_service.js +193 -189
- package/package.json +4 -1
- package/quota-sidebar.config.example.json +36 -45
- package/dist/providers/third_party/xyai.d.ts +0 -2
- 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
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
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
|
|
8
|
-
const
|
|
9
|
-
|
|
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
|
-
|
|
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;
|