@leo000001/opencode-quota-sidebar 1.13.10 → 2.0.1

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/dist/usage.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
- import type { CachedSessionUsage, IncrementalCursor } from './types.js';
3
- export declare const USAGE_BILLING_CACHE_VERSION = 2;
2
+ import type { CacheCoverageMetrics, CacheCoverageMode, CacheUsageBuckets, CachedSessionUsage, IncrementalCursor } from './types.js';
3
+ /**
4
+ * Billing cache version — bump this whenever the persisted `CachedSessionUsage`
5
+ * shape changes in a way that requires recomputation (e.g. new aggregate
6
+ * fields). This is distinct from the plugin *state* version managed by the
7
+ * persistence layer; billing version only governs usage-cache staleness.
8
+ */
9
+ export declare const USAGE_BILLING_CACHE_VERSION = 4;
4
10
  export type ProviderUsage = {
5
11
  providerID: string;
6
12
  input: number;
@@ -26,16 +32,23 @@ export type UsageSummary = {
26
32
  apiCost: number;
27
33
  assistantMessages: number;
28
34
  sessionCount: number;
35
+ cacheBuckets?: CacheUsageBuckets;
29
36
  providers: Record<string, ProviderUsage>;
30
37
  };
31
38
  export type UsageOptions = {
32
39
  /** Equivalent API cost calculator for the message. */
33
40
  calcApiCost?: (message: AssistantMessage) => number;
41
+ /** Cache-behavior classifier for the message model/provider. */
42
+ classifyCacheMode?: (message: AssistantMessage) => CacheCoverageMode;
34
43
  };
44
+ export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
35
45
  export declare function emptyUsageSummary(): UsageSummary;
36
46
  export declare function summarizeMessages(entries: Array<{
37
47
  info: Message;
38
48
  }>, startAt?: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
49
+ export declare function summarizeMessagesInCompletedRange(entries: Array<{
50
+ info: Message;
51
+ }>, startAt: number, endAt: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
39
52
  /**
40
53
  * P1: Incremental usage aggregation.
41
54
  * Only processes messages newer than the cursor. Returns updated cursor.
package/dist/usage.js CHANGED
@@ -1,4 +1,125 @@
1
- export const USAGE_BILLING_CACHE_VERSION = 2;
1
+ /**
2
+ * Billing cache version — bump this whenever the persisted `CachedSessionUsage`
3
+ * shape changes in a way that requires recomputation (e.g. new aggregate
4
+ * fields). This is distinct from the plugin *state* version managed by the
5
+ * persistence layer; billing version only governs usage-cache staleness.
6
+ */
7
+ export const USAGE_BILLING_CACHE_VERSION = 4;
8
+ function emptyCacheUsageBucket() {
9
+ return {
10
+ input: 0,
11
+ cacheRead: 0,
12
+ cacheWrite: 0,
13
+ assistantMessages: 0,
14
+ };
15
+ }
16
+ function emptyCacheUsageBuckets() {
17
+ return {
18
+ readOnly: emptyCacheUsageBucket(),
19
+ readWrite: emptyCacheUsageBucket(),
20
+ };
21
+ }
22
+ function cloneCacheUsageBucket(bucket) {
23
+ return {
24
+ input: bucket?.input ?? 0,
25
+ cacheRead: bucket?.cacheRead ?? 0,
26
+ cacheWrite: bucket?.cacheWrite ?? 0,
27
+ assistantMessages: bucket?.assistantMessages ?? 0,
28
+ };
29
+ }
30
+ function cloneCacheUsageBuckets(buckets) {
31
+ if (!buckets)
32
+ return undefined;
33
+ return {
34
+ readOnly: cloneCacheUsageBucket(buckets.readOnly),
35
+ readWrite: cloneCacheUsageBucket(buckets.readWrite),
36
+ };
37
+ }
38
+ function mergeCacheUsageBucket(target, source) {
39
+ if (!source)
40
+ return target;
41
+ target.input += source.input;
42
+ target.cacheRead += source.cacheRead;
43
+ target.cacheWrite += source.cacheWrite;
44
+ target.assistantMessages += source.assistantMessages;
45
+ return target;
46
+ }
47
+ function addMessageCacheUsage(target, message) {
48
+ target.input += message.tokens.input;
49
+ target.cacheRead += message.tokens.cache.read;
50
+ target.cacheWrite += message.tokens.cache.write;
51
+ target.assistantMessages += 1;
52
+ }
53
+ /**
54
+ * Best-effort fallback for legacy cached data that lacks per-message cache
55
+ * buckets. When `cacheWrite > 0` we assume all tokens came from a read-write
56
+ * model (Anthropic-like); when only `cacheRead > 0` we assume read-only
57
+ * (OpenAI-like). Mixed-provider sessions that were cached before v3 will be
58
+ * attributed to a single bucket — this is a known limitation; new sessions
59
+ * classify per-message and are not affected.
60
+ */
61
+ function fallbackCacheUsageBuckets(usage) {
62
+ if (usage.cacheWrite > 0) {
63
+ return {
64
+ readOnly: emptyCacheUsageBucket(),
65
+ readWrite: {
66
+ input: usage.input,
67
+ cacheRead: usage.cacheRead,
68
+ cacheWrite: usage.cacheWrite,
69
+ assistantMessages: usage.assistantMessages,
70
+ },
71
+ };
72
+ }
73
+ if (usage.cacheRead > 0) {
74
+ return {
75
+ readOnly: {
76
+ input: usage.input,
77
+ cacheRead: usage.cacheRead,
78
+ cacheWrite: 0,
79
+ assistantMessages: usage.assistantMessages,
80
+ },
81
+ readWrite: emptyCacheUsageBucket(),
82
+ };
83
+ }
84
+ return undefined;
85
+ }
86
+ function resolvedCacheUsageBuckets(usage) {
87
+ const explicit = cloneCacheUsageBuckets(usage.cacheBuckets);
88
+ if (!explicit) {
89
+ return cloneCacheUsageBuckets(fallbackCacheUsageBuckets(usage)) || emptyCacheUsageBuckets();
90
+ }
91
+ const accountedInput = explicit.readOnly.input + explicit.readWrite.input;
92
+ const accountedCacheRead = explicit.readOnly.cacheRead + explicit.readWrite.cacheRead;
93
+ const accountedCacheWrite = explicit.readOnly.cacheWrite + explicit.readWrite.cacheWrite;
94
+ const accountedAssistantMessages = explicit.readOnly.assistantMessages + explicit.readWrite.assistantMessages;
95
+ const residual = fallbackCacheUsageBuckets({
96
+ input: Math.max(0, usage.input - accountedInput),
97
+ cacheRead: Math.max(0, usage.cacheRead - accountedCacheRead),
98
+ cacheWrite: Math.max(0, usage.cacheWrite - accountedCacheWrite),
99
+ assistantMessages: Math.max(0, usage.assistantMessages - accountedAssistantMessages),
100
+ });
101
+ if (residual) {
102
+ mergeCacheUsageBucket(explicit.readOnly, residual.readOnly);
103
+ mergeCacheUsageBucket(explicit.readWrite, residual.readWrite);
104
+ }
105
+ return explicit;
106
+ }
107
+ export function getCacheCoverageMetrics(usage) {
108
+ const buckets = resolvedCacheUsageBuckets(usage);
109
+ const readWritePromptSurface = buckets.readWrite.input +
110
+ buckets.readWrite.cacheRead +
111
+ buckets.readWrite.cacheWrite;
112
+ const readOnlyPromptSurface = buckets.readOnly.input + buckets.readOnly.cacheRead;
113
+ return {
114
+ cacheCoverage: readWritePromptSurface > 0
115
+ ? (buckets.readWrite.cacheRead + buckets.readWrite.cacheWrite) /
116
+ readWritePromptSurface
117
+ : undefined,
118
+ cacheReadCoverage: readOnlyPromptSurface > 0
119
+ ? buckets.readOnly.cacheRead / readOnlyPromptSurface
120
+ : undefined,
121
+ };
122
+ }
2
123
  export function emptyUsageSummary() {
3
124
  return {
4
125
  input: 0,
@@ -69,18 +190,47 @@ function addMessageUsage(target, message, options) {
69
190
  provider.apiCost += apiCost;
70
191
  provider.assistantMessages += 1;
71
192
  target.providers[message.providerID] = provider;
193
+ const cacheMode = options?.classifyCacheMode?.(message) || 'none';
194
+ if (cacheMode === 'read-only') {
195
+ const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
196
+ addMessageCacheUsage(buckets.readOnly, message);
197
+ }
198
+ else if (cacheMode === 'read-write') {
199
+ const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
200
+ addMessageCacheUsage(buckets.readWrite, message);
201
+ }
202
+ }
203
+ function completedTimeOf(message) {
204
+ const completed = message.time.completed;
205
+ if (typeof completed !== 'number')
206
+ return undefined;
207
+ if (!Number.isFinite(completed))
208
+ return undefined;
209
+ return completed;
210
+ }
211
+ function isCompletedAssistantInRange(message, startAt = 0, endAt = Number.POSITIVE_INFINITY) {
212
+ if (!isAssistant(message))
213
+ return false;
214
+ const completed = completedTimeOf(message);
215
+ if (completed === undefined)
216
+ return false;
217
+ return completed >= startAt && completed <= endAt;
72
218
  }
73
219
  export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
74
220
  const summary = emptyUsageSummary();
75
221
  summary.sessionCount = sessionCount;
76
222
  for (const entry of entries) {
77
- if (!isAssistant(entry.info))
78
- continue;
79
- if (typeof entry.info.time.completed !== 'number')
223
+ if (!isCompletedAssistantInRange(entry.info, startAt))
80
224
  continue;
81
- if (!Number.isFinite(entry.info.time.completed))
82
- continue;
83
- if (entry.info.time.created < startAt)
225
+ addMessageUsage(summary, entry.info, options);
226
+ }
227
+ return summary;
228
+ }
229
+ export function summarizeMessagesInCompletedRange(entries, startAt, endAt, sessionCount = 1, options) {
230
+ const summary = emptyUsageSummary();
231
+ summary.sessionCount = sessionCount;
232
+ for (const entry of entries) {
233
+ if (!isCompletedAssistantInRange(entry.info, startAt, endAt))
84
234
  continue;
85
235
  addMessageUsage(summary, entry.info, options);
86
236
  }
@@ -276,6 +426,12 @@ export function mergeUsage(target, source, options) {
276
426
  target.apiCost += source.apiCost;
277
427
  target.assistantMessages += source.assistantMessages;
278
428
  target.sessionCount += source.sessionCount;
429
+ const sourceBuckets = source.cacheBuckets;
430
+ if (sourceBuckets) {
431
+ const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
432
+ mergeCacheUsageBucket(targetBuckets.readOnly, sourceBuckets.readOnly);
433
+ mergeCacheUsageBucket(targetBuckets.readWrite, sourceBuckets.readWrite);
434
+ }
279
435
  for (const provider of Object.values(source.providers)) {
280
436
  const existing = target.providers[provider.providerID] ||
281
437
  emptyProviderUsage(provider.providerID);
@@ -321,12 +477,14 @@ export function toCachedSessionUsage(summary) {
321
477
  cost: summary.cost,
322
478
  apiCost: summary.apiCost,
323
479
  assistantMessages: summary.assistantMessages,
480
+ cacheBuckets: cloneCacheUsageBuckets(summary.cacheBuckets),
324
481
  providers,
325
482
  };
326
483
  }
327
484
  export function fromCachedSessionUsage(cached, sessionCount = 1) {
328
485
  // Merge cached reasoning into output for a single output metric.
329
486
  const mergedOutputValue = cached.output + cached.reasoning;
487
+ const cacheBuckets = cloneCacheUsageBuckets(cached.cacheBuckets);
330
488
  return {
331
489
  input: cached.input,
332
490
  output: mergedOutputValue,
@@ -338,6 +496,7 @@ export function fromCachedSessionUsage(cached, sessionCount = 1) {
338
496
  apiCost: cached.apiCost || 0,
339
497
  assistantMessages: cached.assistantMessages,
340
498
  sessionCount,
499
+ cacheBuckets,
341
500
  providers: Object.entries(cached.providers).reduce((acc, [providerID, provider]) => {
342
501
  acc[providerID] = {
343
502
  providerID,
@@ -1,9 +1,15 @@
1
1
  import { TtlValueCache } from './cache.js';
2
- import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
3
- import { dateKeyFromTimestamp, scanSessionsByCreatedRange, updateSessionsInDayChunks, } from './storage.js';
2
+ import { cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
3
+ import { dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
4
4
  import { periodStart } from './period.js';
5
5
  import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
6
- import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
6
+ import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesInCompletedRange, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
7
+ const READ_ONLY_CACHE_PROVIDERS = new Set([
8
+ 'openai',
9
+ 'github-copilot',
10
+ 'venice',
11
+ 'openrouter',
12
+ ]);
7
13
  export function createUsageService(deps) {
8
14
  const forceRescanSessions = new Set();
9
15
  const dirtyGeneration = new Map();
@@ -12,6 +18,8 @@ export function createUsageService(deps) {
12
18
  dirtyGeneration.set(sessionID, (dirtyGeneration.get(sessionID) || 0) + 1);
13
19
  };
14
20
  const isDirty = (sessionID) => {
21
+ if (deps.state.sessions[sessionID]?.dirty)
22
+ return true;
15
23
  return ((dirtyGeneration.get(sessionID) || 0) !==
16
24
  (cleanGeneration.get(sessionID) || 0));
17
25
  };
@@ -48,13 +56,10 @@ export function createUsageService(deps) {
48
56
  const map = all.reduce((acc, provider) => {
49
57
  if (!isRecord(provider))
50
58
  return acc;
51
- const providerID = typeof provider.id === 'string'
52
- ? canonicalApiCostProviderID(provider.id)
53
- : undefined;
54
- if (!providerID)
55
- return acc;
56
- if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
59
+ const rawProviderID = typeof provider.id === 'string' ? provider.id : undefined;
60
+ if (!rawProviderID)
57
61
  return acc;
62
+ const canonicalProviderID = canonicalApiCostProviderID(rawProviderID);
58
63
  const models = provider.models;
59
64
  if (!isRecord(models))
60
65
  return acc;
@@ -65,9 +70,15 @@ export function createUsageService(deps) {
65
70
  if (!rates)
66
71
  continue;
67
72
  const modelID = typeof modelValue.id === 'string' ? modelValue.id : modelKey;
68
- acc[modelCostKey(providerID, modelID)] = rates;
73
+ acc[modelCostKey(rawProviderID, modelID)] = rates;
69
74
  if (modelKey !== modelID) {
70
- acc[modelCostKey(providerID, modelKey)] = rates;
75
+ acc[modelCostKey(rawProviderID, modelKey)] = rates;
76
+ }
77
+ if (canonicalProviderID !== rawProviderID) {
78
+ acc[modelCostKey(canonicalProviderID, modelID)] = rates;
79
+ if (modelKey !== modelID) {
80
+ acc[modelCostKey(canonicalProviderID, modelKey)] = rates;
81
+ }
71
82
  }
72
83
  }
73
84
  return acc;
@@ -78,7 +89,8 @@ export function createUsageService(deps) {
78
89
  const providerID = canonicalApiCostProviderID(message.providerID);
79
90
  if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
80
91
  return 0;
81
- const rates = modelCostMap[modelCostKey(providerID, message.modelID)];
92
+ const rates = modelCostMap[modelCostKey(message.providerID, message.modelID)] ||
93
+ modelCostMap[modelCostKey(providerID, message.modelID)];
82
94
  if (!rates) {
83
95
  const key = modelCostKey(providerID, message.modelID);
84
96
  if (!missingApiCostRateKeys.has(key)) {
@@ -89,6 +101,36 @@ export function createUsageService(deps) {
89
101
  }
90
102
  return calcEquivalentApiCostForMessage(message, rates);
91
103
  };
104
+ const classifyCacheMode = (message, modelCostMap) => {
105
+ const canonicalProviderID = canonicalApiCostProviderID(message.providerID);
106
+ const baseRates = modelCostMap[modelCostKey(message.providerID, message.modelID)] ||
107
+ modelCostMap[modelCostKey(canonicalProviderID, message.modelID)];
108
+ const effectiveRates = baseRates &&
109
+ message.tokens.input + message.tokens.cache.read > 200_000 &&
110
+ baseRates.contextOver200k
111
+ ? baseRates.contextOver200k
112
+ : baseRates;
113
+ const fromRates = cacheCoverageModeFromRates(effectiveRates);
114
+ if (fromRates !== 'none')
115
+ return fromRates;
116
+ if (message.tokens.cache.write > 0)
117
+ return 'read-write';
118
+ if (message.tokens.cache.read <= 0)
119
+ return 'none';
120
+ const rawProviderID = message.providerID.toLowerCase();
121
+ if (READ_ONLY_CACHE_PROVIDERS.has(canonicalProviderID) ||
122
+ READ_ONLY_CACHE_PROVIDERS.has(rawProviderID)) {
123
+ return 'read-only';
124
+ }
125
+ // Heuristic fallback: classify by provider identity when pricing is missing.
126
+ if (canonicalProviderID === 'anthropic' ||
127
+ message.modelID.toLowerCase().includes('claude')) {
128
+ return 'read-write';
129
+ }
130
+ // Last resort: if the message has cache.read tokens from an unknown provider,
131
+ // treat it as read-only (the safer default — avoids inflating Cache Coverage).
132
+ return 'read-only';
133
+ };
92
134
  const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
93
135
  const decodeTokens = (value) => {
94
136
  if (!isRecord(value))
@@ -117,10 +159,6 @@ export function createUsageService(deps) {
117
159
  return undefined;
118
160
  if (typeof value.role !== 'string')
119
161
  return undefined;
120
- if (typeof value.providerID !== 'string')
121
- return undefined;
122
- if (typeof value.modelID !== 'string')
123
- return undefined;
124
162
  if (!isRecord(value.time))
125
163
  return undefined;
126
164
  if (!isFiniteNumber(value.time.created))
@@ -129,6 +167,19 @@ export function createUsageService(deps) {
129
167
  !isFiniteNumber(value.time.completed)) {
130
168
  return undefined;
131
169
  }
170
+ if (value.role !== 'assistant') {
171
+ return {
172
+ ...value,
173
+ time: {
174
+ created: value.time.created,
175
+ completed: value.time.completed,
176
+ },
177
+ };
178
+ }
179
+ if (typeof value.providerID !== 'string')
180
+ return undefined;
181
+ if (typeof value.modelID !== 'string')
182
+ return undefined;
132
183
  const tokens = decodeTokens(value.tokens);
133
184
  if (!tokens)
134
185
  return undefined;
@@ -194,6 +245,21 @@ export function createUsageService(deps) {
194
245
  return false;
195
246
  return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
196
247
  };
248
+ const hasStaleZeroApiCost = (cached, modelCostMap) => {
249
+ if (!cached)
250
+ return false;
251
+ if (cached.apiCost > 0)
252
+ return false;
253
+ return Object.entries(cached.providers).some(([providerID, provider]) => {
254
+ if (provider.apiCost > 0)
255
+ return false;
256
+ const canonicalProviderID = canonicalApiCostProviderID(providerID);
257
+ if (!SUBSCRIPTION_API_COST_PROVIDERS.has(canonicalProviderID))
258
+ return false;
259
+ return Object.keys(modelCostMap).some((key) => key.startsWith(`${providerID}:`) ||
260
+ key.startsWith(`${canonicalProviderID}:`));
261
+ });
262
+ };
197
263
  const summarizeSessionUsage = async (sessionID, generationAtStart) => {
198
264
  const entries = await loadSessionEntries(sessionID);
199
265
  const sessionState = deps.state.sessions[sessionID];
@@ -212,7 +278,8 @@ export function createUsageService(deps) {
212
278
  }
213
279
  const modelCostMap = await getModelCostMap();
214
280
  const staleBillingCache = Boolean(sessionState?.usage) &&
215
- !isUsageBillingCurrent(sessionState?.usage);
281
+ (!isUsageBillingCurrent(sessionState?.usage) ||
282
+ hasStaleZeroApiCost(sessionState?.usage, modelCostMap));
216
283
  const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
217
284
  if (forceRescan)
218
285
  forceRescanSessions.delete(sessionID);
@@ -221,11 +288,13 @@ export function createUsageService(deps) {
221
288
  }
222
289
  const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
223
290
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
291
+ classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
224
292
  });
225
293
  usage.sessionCount = 1;
226
294
  // Update cursor in state
227
295
  if (sessionState) {
228
296
  sessionState.cursor = cursor;
297
+ sessionState.dirty = false;
229
298
  }
230
299
  if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
231
300
  cleanGeneration.set(sessionID, generationAtStart);
@@ -261,11 +330,16 @@ export function createUsageService(deps) {
261
330
  const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
262
331
  const root = await summarizeSessionUsageLocked(sessionID);
263
332
  const usage = root.usage;
333
+ let dirty = false;
264
334
  if (root.persist) {
265
335
  persistSessionUsage(sessionID, toCachedSessionUsage(usage));
336
+ dirty = true;
266
337
  }
267
- if (!includeChildren)
338
+ if (!includeChildren) {
339
+ if (dirty)
340
+ deps.persistence.scheduleSave();
268
341
  return usage;
342
+ }
269
343
  const descendantIDs = await deps.descendantsResolver.listDescendantSessionIDs(sessionID, {
270
344
  maxDepth: deps.config.sidebar.childrenMaxDepth,
271
345
  maxSessions: deps.config.sidebar.childrenMaxSessions,
@@ -294,6 +368,7 @@ export function createUsageService(deps) {
294
368
  const child = await summarizeSessionUsageLocked(childID);
295
369
  if (child.persist) {
296
370
  persistSessionUsage(childID, toCachedSessionUsage(child.usage));
371
+ dirty = true;
297
372
  }
298
373
  return child.usage;
299
374
  });
@@ -301,15 +376,17 @@ export function createUsageService(deps) {
301
376
  mergeUsage(merged, childUsage, { includeCost: false });
302
377
  }
303
378
  }
379
+ if (dirty)
380
+ deps.persistence.scheduleSave();
304
381
  return merged;
305
382
  };
306
383
  const RANGE_USAGE_CONCURRENCY = 5;
307
384
  const summarizeRangeUsage = async (period) => {
308
385
  const startAt = periodStart(period);
386
+ const endAt = Date.now();
309
387
  await deps.persistence.flushSave();
310
- const sessions = await scanSessionsByCreatedRange(deps.statePath, startAt, Date.now(), deps.state);
388
+ const sessions = await scanAllSessions(deps.statePath, deps.state);
311
389
  const usage = emptyUsageSummary();
312
- usage.sessionCount = sessions.length;
313
390
  const modelCostMap = await getModelCostMap();
314
391
  const hasPricing = Object.keys(modelCostMap).length > 0;
315
392
  const hasAnySubscriptionProvider = (cached) => {
@@ -338,61 +415,78 @@ export function createUsageService(deps) {
338
415
  return false;
339
416
  return true;
340
417
  };
341
- const needsFetch = [];
342
- for (const session of sessions) {
343
- if (session.state.usage) {
344
- if (shouldRecomputeUsageCache(session.state.usage)) {
345
- needsFetch.push(session);
346
- }
347
- else {
348
- mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
349
- }
350
- }
351
- else {
352
- needsFetch.push(session);
353
- }
354
- }
355
- if (needsFetch.length > 0) {
356
- const fetched = await mapConcurrent(needsFetch, RANGE_USAGE_CONCURRENCY, async (session) => {
418
+ if (sessions.length > 0) {
419
+ const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
357
420
  const entries = await loadSessionEntries(session.sessionID);
358
421
  if (!entries) {
359
- if (session.state.usage) {
360
- return {
361
- sessionID: session.sessionID,
362
- dateKey: session.dateKey,
363
- computed: fromCachedSessionUsage(session.state.usage, 1),
364
- persist: false,
365
- cursor: session.state.cursor,
366
- };
367
- }
368
- const empty = emptyUsageSummary();
369
- empty.sessionCount = 1;
370
422
  return {
371
423
  sessionID: session.sessionID,
372
424
  dateKey: session.dateKey,
373
- computed: empty,
425
+ createdAt: session.state.createdAt,
426
+ lastMessageTime: session.state.cursor?.lastMessageTime,
427
+ computed: emptyUsageSummary(),
428
+ fullUsage: undefined,
429
+ loadFailed: true,
374
430
  persist: false,
375
431
  cursor: undefined,
376
432
  };
377
433
  }
378
- const { usage: computed, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
434
+ const computed = summarizeMessagesInCompletedRange(entries, startAt, endAt, 0, {
435
+ calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
436
+ classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
437
+ });
438
+ const shouldPersistFullUsage = !session.state.usage || shouldRecomputeUsageCache(session.state.usage);
439
+ if (!shouldPersistFullUsage) {
440
+ return {
441
+ sessionID: session.sessionID,
442
+ dateKey: session.dateKey,
443
+ createdAt: session.state.createdAt,
444
+ lastMessageTime: session.state.cursor?.lastMessageTime,
445
+ computed,
446
+ fullUsage: undefined,
447
+ loadFailed: false,
448
+ persist: false,
449
+ cursor: session.state.cursor,
450
+ };
451
+ }
452
+ const { usage: fullUsage, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
379
453
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
454
+ classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
380
455
  });
381
456
  return {
382
457
  sessionID: session.sessionID,
383
458
  dateKey: session.dateKey,
459
+ createdAt: session.state.createdAt,
460
+ lastMessageTime: cursor.lastMessageTime,
384
461
  computed,
462
+ fullUsage,
463
+ loadFailed: false,
385
464
  persist: true,
386
465
  cursor,
387
466
  };
388
467
  });
468
+ const failedLoads = fetched.filter((item) => {
469
+ if (!item.loadFailed)
470
+ return false;
471
+ const lastMessageTime = item.lastMessageTime;
472
+ if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
473
+ return false;
474
+ }
475
+ return true;
476
+ });
477
+ if (failedLoads.length > 0) {
478
+ throw new Error(`range usage unavailable: failed to load ${failedLoads.length} session(s)`);
479
+ }
389
480
  let dirty = false;
390
481
  const diskOnlyUpdates = [];
391
- for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
392
- mergeUsage(usage, { ...computed, sessionCount: 0 });
482
+ for (const { sessionID, dateKey, computed, fullUsage, persist, cursor } of fetched) {
483
+ if (computed.assistantMessages > 0) {
484
+ computed.sessionCount = 1;
485
+ mergeUsage(usage, computed);
486
+ }
393
487
  const memoryState = deps.state.sessions[sessionID];
394
- if (persist && memoryState) {
395
- memoryState.usage = toCachedSessionUsage(computed);
488
+ if (persist && fullUsage && memoryState) {
489
+ memoryState.usage = toCachedSessionUsage(fullUsage);
396
490
  memoryState.cursor = cursor;
397
491
  const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
398
492
  dateKeyFromTimestamp(memoryState.createdAt);
@@ -400,11 +494,11 @@ export function createUsageService(deps) {
400
494
  deps.persistence.markDirty(resolvedDateKey);
401
495
  dirty = true;
402
496
  }
403
- else if (persist) {
497
+ else if (persist && fullUsage) {
404
498
  diskOnlyUpdates.push({
405
499
  sessionID,
406
500
  dateKey,
407
- usage: toCachedSessionUsage(computed),
501
+ usage: toCachedSessionUsage(fullUsage),
408
502
  cursor,
409
503
  });
410
504
  }
@@ -425,6 +519,15 @@ export function createUsageService(deps) {
425
519
  };
426
520
  const markSessionDirty = (sessionID) => {
427
521
  bumpDirty(sessionID);
522
+ const sessionState = deps.state.sessions[sessionID];
523
+ if (sessionState && !sessionState.dirty) {
524
+ sessionState.dirty = true;
525
+ const dateKey = deps.state.sessionDateMap[sessionID] ||
526
+ dateKeyFromTimestamp(sessionState.createdAt);
527
+ deps.state.sessionDateMap[sessionID] = dateKey;
528
+ deps.persistence.markDirty(dateKey);
529
+ deps.persistence.scheduleSave();
530
+ }
428
531
  };
429
532
  const markForceRescan = (sessionID) => {
430
533
  forceRescanSessions.add(sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "1.13.10",
3
+ "version": "2.0.1",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",