@leo000001/opencode-quota-sidebar 3.0.10 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +0 -1
- package/README.md +163 -42
- package/README.zh-CN.md +163 -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 +391 -49
- 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 +38 -2
- 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 +21 -10
- 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,157 @@
|
|
|
1
|
+
import { debug } from './helpers.js';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function isFiniteNumber(value) {
|
|
6
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
7
|
+
}
|
|
8
|
+
function decodeTokens(value) {
|
|
9
|
+
if (!isRecord(value))
|
|
10
|
+
return undefined;
|
|
11
|
+
if (!isFiniteNumber(value.input))
|
|
12
|
+
return undefined;
|
|
13
|
+
if (!isFiniteNumber(value.output))
|
|
14
|
+
return undefined;
|
|
15
|
+
const reasoning = isFiniteNumber(value.reasoning) ? value.reasoning : 0;
|
|
16
|
+
const cacheRaw = isRecord(value.cache) ? value.cache : {};
|
|
17
|
+
const read = isFiniteNumber(cacheRaw.read) ? cacheRaw.read : 0;
|
|
18
|
+
const write = isFiniteNumber(cacheRaw.write) ? cacheRaw.write : 0;
|
|
19
|
+
return {
|
|
20
|
+
input: value.input,
|
|
21
|
+
output: value.output,
|
|
22
|
+
reasoning,
|
|
23
|
+
cache: { read, write },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function decodeMessageInfo(value) {
|
|
27
|
+
if (!isRecord(value))
|
|
28
|
+
return undefined;
|
|
29
|
+
if (typeof value.id !== 'string')
|
|
30
|
+
return undefined;
|
|
31
|
+
if (typeof value.sessionID !== 'string')
|
|
32
|
+
return undefined;
|
|
33
|
+
if (typeof value.role !== 'string')
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!isRecord(value.time))
|
|
36
|
+
return undefined;
|
|
37
|
+
if (!isFiniteNumber(value.time.created))
|
|
38
|
+
return undefined;
|
|
39
|
+
if (value.time.completed !== undefined &&
|
|
40
|
+
!isFiniteNumber(value.time.completed)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (value.role !== 'assistant') {
|
|
44
|
+
return {
|
|
45
|
+
...value,
|
|
46
|
+
time: {
|
|
47
|
+
created: value.time.created,
|
|
48
|
+
completed: value.time.completed,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (typeof value.providerID !== 'string')
|
|
53
|
+
return undefined;
|
|
54
|
+
if (typeof value.modelID !== 'string')
|
|
55
|
+
return undefined;
|
|
56
|
+
const tokens = decodeTokens(value.tokens);
|
|
57
|
+
if (!tokens)
|
|
58
|
+
return undefined;
|
|
59
|
+
return {
|
|
60
|
+
...value,
|
|
61
|
+
time: {
|
|
62
|
+
created: value.time.created,
|
|
63
|
+
completed: value.time.completed,
|
|
64
|
+
},
|
|
65
|
+
tokens,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function decodeMessageEntries(value) {
|
|
69
|
+
if (!Array.isArray(value))
|
|
70
|
+
return undefined;
|
|
71
|
+
const decoded = value
|
|
72
|
+
.map((item) => {
|
|
73
|
+
if (!isRecord(item))
|
|
74
|
+
return undefined;
|
|
75
|
+
const info = decodeMessageInfo(item.info);
|
|
76
|
+
if (!info)
|
|
77
|
+
return undefined;
|
|
78
|
+
return { info };
|
|
79
|
+
})
|
|
80
|
+
.filter((item) => Boolean(item));
|
|
81
|
+
if (decoded.length > 0 && decoded.length < value.length) {
|
|
82
|
+
debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (decoded.length === 0 && value.length > 0)
|
|
86
|
+
return undefined;
|
|
87
|
+
return decoded;
|
|
88
|
+
}
|
|
89
|
+
export function nextCursorFromResponse(value) {
|
|
90
|
+
if (!isRecord(value))
|
|
91
|
+
return undefined;
|
|
92
|
+
const response = value.response;
|
|
93
|
+
if (!isRecord(response))
|
|
94
|
+
return undefined;
|
|
95
|
+
const headers = response.headers;
|
|
96
|
+
if (!headers || typeof headers.get !== 'function') {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const next = headers.get('X-Next-Cursor');
|
|
100
|
+
return typeof next === 'string' && next ? next : undefined;
|
|
101
|
+
}
|
|
102
|
+
function errorStatusCode(value, seen = new Set()) {
|
|
103
|
+
if (!isRecord(value) || seen.has(value))
|
|
104
|
+
return undefined;
|
|
105
|
+
seen.add(value);
|
|
106
|
+
const status = value.status;
|
|
107
|
+
if (typeof status === 'number' && Number.isFinite(status))
|
|
108
|
+
return status;
|
|
109
|
+
const statusCode = value.statusCode;
|
|
110
|
+
if (typeof statusCode === 'number' && Number.isFinite(statusCode)) {
|
|
111
|
+
return statusCode;
|
|
112
|
+
}
|
|
113
|
+
return (errorStatusCode(value.response, seen) ||
|
|
114
|
+
errorStatusCode(value.cause, seen) ||
|
|
115
|
+
errorStatusCode(value.error, seen));
|
|
116
|
+
}
|
|
117
|
+
function errorText(value, seen = new Set()) {
|
|
118
|
+
if (!value || seen.has(value))
|
|
119
|
+
return '';
|
|
120
|
+
if (typeof value === 'string')
|
|
121
|
+
return value;
|
|
122
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
123
|
+
return `${value}`;
|
|
124
|
+
if (value instanceof Error) {
|
|
125
|
+
seen.add(value);
|
|
126
|
+
return [
|
|
127
|
+
value.message,
|
|
128
|
+
errorText(value.cause, seen),
|
|
129
|
+
]
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join('\n');
|
|
132
|
+
}
|
|
133
|
+
if (!isRecord(value))
|
|
134
|
+
return '';
|
|
135
|
+
seen.add(value);
|
|
136
|
+
return [
|
|
137
|
+
typeof value.message === 'string' ? value.message : '',
|
|
138
|
+
typeof value.error === 'string' ? value.error : '',
|
|
139
|
+
typeof value.detail === 'string' ? value.detail : '',
|
|
140
|
+
typeof value.title === 'string' ? value.title : '',
|
|
141
|
+
errorText(value.response, seen),
|
|
142
|
+
errorText(value.data, seen),
|
|
143
|
+
errorText(value.cause, seen),
|
|
144
|
+
]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join('\n');
|
|
147
|
+
}
|
|
148
|
+
export function isMissingSessionError(error) {
|
|
149
|
+
const status = errorStatusCode(error);
|
|
150
|
+
if (status === 404 || status === 410)
|
|
151
|
+
return true;
|
|
152
|
+
const text = errorText(error).toLowerCase();
|
|
153
|
+
if (!text)
|
|
154
|
+
return false;
|
|
155
|
+
return (/\b(session|conversation)\b.*\b(not found|missing|deleted|does not exist)\b/.test(text) ||
|
|
156
|
+
/\b(not found|missing|deleted|does not exist)\b.*\b(session|conversation)\b/.test(text));
|
|
157
|
+
}
|
|
@@ -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;
|