@leo000001/opencode-quota-sidebar 2.0.0 → 2.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/dist/usage.js CHANGED
@@ -1,4 +1,10 @@
1
- export const USAGE_BILLING_CACHE_VERSION = 3;
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;
2
8
  function emptyCacheUsageBucket() {
3
9
  return {
4
10
  input: 0,
@@ -25,8 +31,8 @@ function cloneCacheUsageBuckets(buckets) {
25
31
  if (!buckets)
26
32
  return undefined;
27
33
  return {
28
- readOnly: cloneCacheUsageBucket(buckets?.readOnly),
29
- readWrite: cloneCacheUsageBucket(buckets?.readWrite),
34
+ readOnly: cloneCacheUsageBucket(buckets.readOnly),
35
+ readWrite: cloneCacheUsageBucket(buckets.readWrite),
30
36
  };
31
37
  }
32
38
  function mergeCacheUsageBucket(target, source) {
@@ -44,6 +50,14 @@ function addMessageCacheUsage(target, message) {
44
50
  target.cacheWrite += message.tokens.cache.write;
45
51
  target.assistantMessages += 1;
46
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
+ */
47
61
  function fallbackCacheUsageBuckets(usage) {
48
62
  if (usage.cacheWrite > 0) {
49
63
  return {
@@ -70,8 +84,25 @@ function fallbackCacheUsageBuckets(usage) {
70
84
  return undefined;
71
85
  }
72
86
  function resolvedCacheUsageBuckets(usage) {
73
- return (cloneCacheUsageBuckets(usage.cacheBuckets || fallbackCacheUsageBuckets(usage)) ||
74
- emptyCacheUsageBuckets());
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;
75
106
  }
76
107
  export function getCacheCoverageMetrics(usage) {
77
108
  const buckets = resolvedCacheUsageBuckets(usage);
@@ -89,6 +120,9 @@ export function getCacheCoverageMetrics(usage) {
89
120
  : undefined,
90
121
  };
91
122
  }
123
+ export function getProviderCacheCoverageMetrics(usage) {
124
+ return getCacheCoverageMetrics(usage);
125
+ }
92
126
  export function emptyUsageSummary() {
93
127
  return {
94
128
  input: 0,
@@ -101,7 +135,6 @@ export function emptyUsageSummary() {
101
135
  apiCost: 0,
102
136
  assistantMessages: 0,
103
137
  sessionCount: 0,
104
- cacheBuckets: emptyCacheUsageBuckets(),
105
138
  providers: {},
106
139
  };
107
140
  }
@@ -117,6 +150,7 @@ function emptyProviderUsage(providerID) {
117
150
  cost: 0,
118
151
  apiCost: 0,
119
152
  assistantMessages: 0,
153
+ cacheBuckets: undefined,
120
154
  };
121
155
  }
122
156
  function isAssistant(message) {
@@ -164,23 +198,47 @@ function addMessageUsage(target, message, options) {
164
198
  if (cacheMode === 'read-only') {
165
199
  const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
166
200
  addMessageCacheUsage(buckets.readOnly, message);
201
+ const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
202
+ addMessageCacheUsage(providerBuckets.readOnly, message);
167
203
  }
168
204
  else if (cacheMode === 'read-write') {
169
205
  const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
170
206
  addMessageCacheUsage(buckets.readWrite, message);
207
+ const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
208
+ addMessageCacheUsage(providerBuckets.readWrite, message);
171
209
  }
172
210
  }
211
+ function completedTimeOf(message) {
212
+ const completed = message.time.completed;
213
+ if (typeof completed !== 'number')
214
+ return undefined;
215
+ if (!Number.isFinite(completed))
216
+ return undefined;
217
+ return completed;
218
+ }
219
+ function isCompletedAssistantInRange(message, startAt = 0, endAt = Number.POSITIVE_INFINITY) {
220
+ if (!isAssistant(message))
221
+ return false;
222
+ const completed = completedTimeOf(message);
223
+ if (completed === undefined)
224
+ return false;
225
+ return completed >= startAt && completed <= endAt;
226
+ }
173
227
  export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
174
228
  const summary = emptyUsageSummary();
175
229
  summary.sessionCount = sessionCount;
176
230
  for (const entry of entries) {
177
- if (!isAssistant(entry.info))
231
+ if (!isCompletedAssistantInRange(entry.info, startAt))
178
232
  continue;
179
- if (typeof entry.info.time.completed !== 'number')
180
- continue;
181
- if (!Number.isFinite(entry.info.time.completed))
182
- continue;
183
- if (entry.info.time.created < startAt)
233
+ addMessageUsage(summary, entry.info, options);
234
+ }
235
+ return summary;
236
+ }
237
+ export function summarizeMessagesInCompletedRange(entries, startAt, endAt, sessionCount = 1, options) {
238
+ const summary = emptyUsageSummary();
239
+ summary.sessionCount = sessionCount;
240
+ for (const entry of entries) {
241
+ if (!isCompletedAssistantInRange(entry.info, startAt, endAt))
184
242
  continue;
185
243
  addMessageUsage(summary, entry.info, options);
186
244
  }
@@ -376,9 +434,9 @@ export function mergeUsage(target, source, options) {
376
434
  target.apiCost += source.apiCost;
377
435
  target.assistantMessages += source.assistantMessages;
378
436
  target.sessionCount += source.sessionCount;
379
- const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
380
437
  const sourceBuckets = source.cacheBuckets;
381
438
  if (sourceBuckets) {
439
+ const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
382
440
  mergeCacheUsageBucket(targetBuckets.readOnly, sourceBuckets.readOnly);
383
441
  mergeCacheUsageBucket(targetBuckets.readWrite, sourceBuckets.readWrite);
384
442
  }
@@ -395,6 +453,11 @@ export function mergeUsage(target, source, options) {
395
453
  }
396
454
  existing.apiCost += provider.apiCost;
397
455
  existing.assistantMessages += provider.assistantMessages;
456
+ if (provider.cacheBuckets) {
457
+ const providerBuckets = (existing.cacheBuckets ||= emptyCacheUsageBuckets());
458
+ mergeCacheUsageBucket(providerBuckets.readOnly, provider.cacheBuckets.readOnly);
459
+ mergeCacheUsageBucket(providerBuckets.readWrite, provider.cacheBuckets.readWrite);
460
+ }
398
461
  target.providers[provider.providerID] = existing;
399
462
  }
400
463
  return target;
@@ -412,6 +475,7 @@ export function toCachedSessionUsage(summary) {
412
475
  cost: provider.cost,
413
476
  apiCost: provider.apiCost,
414
477
  assistantMessages: provider.assistantMessages,
478
+ cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
415
479
  };
416
480
  return acc;
417
481
  }, {});
@@ -459,6 +523,7 @@ export function fromCachedSessionUsage(cached, sessionCount = 1) {
459
523
  cost: provider.cost,
460
524
  apiCost: provider.apiCost || 0,
461
525
  assistantMessages: provider.assistantMessages,
526
+ cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
462
527
  };
463
528
  return acc;
464
529
  }, {}),
@@ -1,9 +1,15 @@
1
1
  import { TtlValueCache } from './cache.js';
2
2
  import { cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
3
- import { dateKeyFromTimestamp, scanSessionsByCreatedRange, updateSessionsInDayChunks, } from './storage.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
  };
@@ -109,13 +117,19 @@ export function createUsageService(deps) {
109
117
  return 'read-write';
110
118
  if (message.tokens.cache.read <= 0)
111
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.
112
126
  if (canonicalProviderID === 'anthropic' ||
113
127
  message.modelID.toLowerCase().includes('claude')) {
114
128
  return 'read-write';
115
129
  }
116
- if (canonicalProviderID === 'openai')
117
- return 'read-only';
118
- return 'none';
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';
119
133
  };
120
134
  const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
121
135
  const decodeTokens = (value) => {
@@ -145,10 +159,6 @@ export function createUsageService(deps) {
145
159
  return undefined;
146
160
  if (typeof value.role !== 'string')
147
161
  return undefined;
148
- if (typeof value.providerID !== 'string')
149
- return undefined;
150
- if (typeof value.modelID !== 'string')
151
- return undefined;
152
162
  if (!isRecord(value.time))
153
163
  return undefined;
154
164
  if (!isFiniteNumber(value.time.created))
@@ -157,6 +167,19 @@ export function createUsageService(deps) {
157
167
  !isFiniteNumber(value.time.completed)) {
158
168
  return undefined;
159
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;
160
183
  const tokens = decodeTokens(value.tokens);
161
184
  if (!tokens)
162
185
  return undefined;
@@ -187,6 +210,7 @@ export function createUsageService(deps) {
187
210
  .filter((item) => Boolean(item));
188
211
  if (decoded.length > 0 && decoded.length < value.length) {
189
212
  debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
213
+ return undefined;
190
214
  }
191
215
  // If the API returned entries but none match the expected shape,
192
216
  // treat it as a load failure so we don't silently undercount.
@@ -222,7 +246,22 @@ export function createUsageService(deps) {
222
246
  return false;
223
247
  return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
224
248
  };
225
- const summarizeSessionUsage = async (sessionID, generationAtStart) => {
249
+ const hasStaleZeroApiCost = (cached, modelCostMap) => {
250
+ if (!cached)
251
+ return false;
252
+ if (cached.apiCost > 0)
253
+ return false;
254
+ return Object.entries(cached.providers).some(([providerID, provider]) => {
255
+ if (provider.apiCost > 0)
256
+ return false;
257
+ const canonicalProviderID = canonicalApiCostProviderID(providerID);
258
+ if (!SUBSCRIPTION_API_COST_PROVIDERS.has(canonicalProviderID))
259
+ return false;
260
+ return Object.keys(modelCostMap).some((key) => key.startsWith(`${providerID}:`) ||
261
+ key.startsWith(`${canonicalProviderID}:`));
262
+ });
263
+ };
264
+ const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
226
265
  const entries = await loadSessionEntries(sessionID);
227
266
  const sessionState = deps.state.sessions[sessionID];
228
267
  // If we can't load messages (transient API failure), fall back to cached
@@ -234,13 +273,17 @@ export function createUsageService(deps) {
234
273
  persist: false,
235
274
  };
236
275
  }
276
+ if (options?.requireEntries) {
277
+ throw new Error(`session usage unavailable: failed to load messages for ${sessionID}`);
278
+ }
237
279
  const empty = emptyUsageSummary();
238
280
  empty.sessionCount = 1;
239
281
  return { usage: empty, persist: false };
240
282
  }
241
283
  const modelCostMap = await getModelCostMap();
242
284
  const staleBillingCache = Boolean(sessionState?.usage) &&
243
- !isUsageBillingCurrent(sessionState?.usage);
285
+ (!isUsageBillingCurrent(sessionState?.usage) ||
286
+ hasStaleZeroApiCost(sessionState?.usage, modelCostMap));
244
287
  const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
245
288
  if (forceRescan)
246
289
  forceRescanSessions.delete(sessionID);
@@ -255,13 +298,14 @@ export function createUsageService(deps) {
255
298
  // Update cursor in state
256
299
  if (sessionState) {
257
300
  sessionState.cursor = cursor;
301
+ sessionState.dirty = false;
258
302
  }
259
303
  if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
260
304
  cleanGeneration.set(sessionID, generationAtStart);
261
305
  }
262
306
  return { usage, persist: true };
263
307
  };
264
- const summarizeSessionUsageLocked = async (sessionID) => {
308
+ const summarizeSessionUsageLocked = async (sessionID, options) => {
265
309
  for (let attempt = 0; attempt < 2; attempt++) {
266
310
  const generationAtStart = dirtyGeneration.get(sessionID) || 0;
267
311
  const existing = usageInFlight.get(sessionID);
@@ -271,13 +315,15 @@ export function createUsageService(deps) {
271
315
  continue;
272
316
  return result;
273
317
  }
274
- const promise = summarizeSessionUsage(sessionID, generationAtStart);
318
+ const promise = summarizeSessionUsage(sessionID, generationAtStart, options);
275
319
  const entry = { generation: generationAtStart, promise };
276
- promise.finally(() => {
320
+ void promise
321
+ .finally(() => {
277
322
  const current = usageInFlight.get(sessionID);
278
323
  if (current?.promise === promise)
279
324
  usageInFlight.delete(sessionID);
280
- });
325
+ })
326
+ .catch(() => undefined);
281
327
  usageInFlight.set(sessionID, entry);
282
328
  const result = await promise;
283
329
  if ((dirtyGeneration.get(sessionID) || 0) !== generationAtStart)
@@ -290,18 +336,26 @@ export function createUsageService(deps) {
290
336
  const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
291
337
  const root = await summarizeSessionUsageLocked(sessionID);
292
338
  const usage = root.usage;
339
+ let dirty = false;
293
340
  if (root.persist) {
294
341
  persistSessionUsage(sessionID, toCachedSessionUsage(usage));
342
+ dirty = true;
295
343
  }
296
- if (!includeChildren)
344
+ if (!includeChildren) {
345
+ if (dirty)
346
+ deps.persistence.scheduleSave();
297
347
  return usage;
348
+ }
298
349
  const descendantIDs = await deps.descendantsResolver.listDescendantSessionIDs(sessionID, {
299
350
  maxDepth: deps.config.sidebar.childrenMaxDepth,
300
351
  maxSessions: deps.config.sidebar.childrenMaxSessions,
301
352
  concurrency: deps.config.sidebar.childrenConcurrency,
302
353
  });
303
- if (descendantIDs.length === 0)
354
+ if (descendantIDs.length === 0) {
355
+ if (dirty)
356
+ deps.persistence.scheduleSave();
304
357
  return usage;
358
+ }
305
359
  const merged = emptyUsageSummary();
306
360
  mergeUsage(merged, usage);
307
361
  const needsFetch = [];
@@ -323,6 +377,7 @@ export function createUsageService(deps) {
323
377
  const child = await summarizeSessionUsageLocked(childID);
324
378
  if (child.persist) {
325
379
  persistSessionUsage(childID, toCachedSessionUsage(child.usage));
380
+ dirty = true;
326
381
  }
327
382
  return child.usage;
328
383
  });
@@ -330,15 +385,17 @@ export function createUsageService(deps) {
330
385
  mergeUsage(merged, childUsage, { includeCost: false });
331
386
  }
332
387
  }
388
+ if (dirty)
389
+ deps.persistence.scheduleSave();
333
390
  return merged;
334
391
  };
335
392
  const RANGE_USAGE_CONCURRENCY = 5;
336
393
  const summarizeRangeUsage = async (period) => {
337
394
  const startAt = periodStart(period);
395
+ const endAt = Date.now();
338
396
  await deps.persistence.flushSave();
339
- const sessions = await scanSessionsByCreatedRange(deps.statePath, startAt, Date.now(), deps.state);
397
+ const sessions = await scanAllSessions(deps.statePath, deps.state);
340
398
  const usage = emptyUsageSummary();
341
- usage.sessionCount = sessions.length;
342
399
  const modelCostMap = await getModelCostMap();
343
400
  const hasPricing = Object.keys(modelCostMap).length > 0;
344
401
  const hasAnySubscriptionProvider = (cached) => {
@@ -367,80 +424,108 @@ export function createUsageService(deps) {
367
424
  return false;
368
425
  return true;
369
426
  };
370
- const needsFetch = [];
371
- for (const session of sessions) {
372
- if (session.state.usage) {
373
- if (shouldRecomputeUsageCache(session.state.usage)) {
374
- needsFetch.push(session);
375
- }
376
- else {
377
- mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
378
- }
379
- }
380
- else {
381
- needsFetch.push(session);
382
- }
383
- }
384
- if (needsFetch.length > 0) {
385
- const fetched = await mapConcurrent(needsFetch, RANGE_USAGE_CONCURRENCY, async (session) => {
427
+ if (sessions.length > 0) {
428
+ const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
386
429
  const entries = await loadSessionEntries(session.sessionID);
387
430
  if (!entries) {
388
- if (session.state.usage) {
389
- return {
390
- sessionID: session.sessionID,
391
- dateKey: session.dateKey,
392
- computed: fromCachedSessionUsage(session.state.usage, 1),
393
- persist: false,
394
- cursor: session.state.cursor,
395
- };
396
- }
397
- const empty = emptyUsageSummary();
398
- empty.sessionCount = 1;
399
431
  return {
400
432
  sessionID: session.sessionID,
401
433
  dateKey: session.dateKey,
402
- computed: empty,
434
+ createdAt: session.state.createdAt,
435
+ lastMessageTime: session.state.cursor?.lastMessageTime,
436
+ dirty: session.state.dirty === true,
437
+ computed: emptyUsageSummary(),
438
+ fullUsage: undefined,
439
+ loadFailed: true,
403
440
  persist: false,
404
441
  cursor: undefined,
405
442
  };
406
443
  }
407
- const { usage: computed, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
444
+ const computed = summarizeMessagesInCompletedRange(entries, startAt, endAt, 0, {
445
+ calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
446
+ classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
447
+ });
448
+ const shouldPersistFullUsage = !session.state.usage || shouldRecomputeUsageCache(session.state.usage);
449
+ if (!shouldPersistFullUsage) {
450
+ return {
451
+ sessionID: session.sessionID,
452
+ dateKey: session.dateKey,
453
+ createdAt: session.state.createdAt,
454
+ lastMessageTime: session.state.cursor?.lastMessageTime,
455
+ dirty: session.state.dirty === true,
456
+ computed,
457
+ fullUsage: undefined,
458
+ loadFailed: false,
459
+ persist: false,
460
+ cursor: session.state.cursor,
461
+ };
462
+ }
463
+ const { usage: fullUsage, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
408
464
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
409
465
  classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
410
466
  });
411
467
  return {
412
468
  sessionID: session.sessionID,
413
469
  dateKey: session.dateKey,
470
+ createdAt: session.state.createdAt,
471
+ lastMessageTime: cursor.lastMessageTime,
472
+ dirty: false,
414
473
  computed,
474
+ fullUsage,
475
+ loadFailed: false,
415
476
  persist: true,
416
477
  cursor,
417
478
  };
418
479
  });
480
+ const failedLoads = fetched.filter((item) => {
481
+ if (!item.loadFailed)
482
+ return false;
483
+ if (item.dirty)
484
+ return true;
485
+ const lastMessageTime = item.lastMessageTime;
486
+ if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
487
+ return false;
488
+ }
489
+ return true;
490
+ });
491
+ if (failedLoads.length > 0) {
492
+ throw new Error(`range usage unavailable: failed to load ${failedLoads.length} session(s)`);
493
+ }
419
494
  let dirty = false;
420
495
  const diskOnlyUpdates = [];
421
- for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
422
- mergeUsage(usage, { ...computed, sessionCount: 0 });
496
+ for (const { sessionID, dateKey, computed, fullUsage, persist, cursor } of fetched) {
497
+ if (computed.assistantMessages > 0) {
498
+ computed.sessionCount = 1;
499
+ mergeUsage(usage, computed);
500
+ }
423
501
  const memoryState = deps.state.sessions[sessionID];
424
- if (persist && memoryState) {
425
- memoryState.usage = toCachedSessionUsage(computed);
502
+ if (persist && fullUsage && memoryState) {
503
+ memoryState.usage = toCachedSessionUsage(fullUsage);
426
504
  memoryState.cursor = cursor;
427
505
  const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
428
506
  dateKeyFromTimestamp(memoryState.createdAt);
429
507
  deps.state.sessionDateMap[sessionID] = resolvedDateKey;
430
508
  deps.persistence.markDirty(resolvedDateKey);
509
+ memoryState.dirty = false;
431
510
  dirty = true;
432
511
  }
433
- else if (persist) {
512
+ else if (persist && fullUsage) {
434
513
  diskOnlyUpdates.push({
435
514
  sessionID,
436
515
  dateKey,
437
- usage: toCachedSessionUsage(computed),
516
+ usage: toCachedSessionUsage(fullUsage),
438
517
  cursor,
439
518
  });
440
519
  }
441
520
  }
442
521
  if (diskOnlyUpdates.length > 0) {
443
- await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(swallow('updateSessionsInDayChunks'));
522
+ const persisted = await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch((error) => {
523
+ swallow('updateSessionsInDayChunks')(error);
524
+ return false;
525
+ });
526
+ if (!persisted) {
527
+ throw new Error(`range usage unavailable: failed to persist ${diskOnlyUpdates.length} disk-only session(s)`);
528
+ }
444
529
  }
445
530
  if (dirty)
446
531
  deps.persistence.scheduleSave();
@@ -449,12 +534,31 @@ export function createUsageService(deps) {
449
534
  };
450
535
  const summarizeForTool = async (period, sessionID, includeChildren) => {
451
536
  if (period === 'session') {
537
+ if (!includeChildren) {
538
+ const session = await summarizeSessionUsageLocked(sessionID, {
539
+ requireEntries: true,
540
+ });
541
+ if (session.persist) {
542
+ persistSessionUsage(sessionID, toCachedSessionUsage(session.usage));
543
+ deps.persistence.scheduleSave();
544
+ }
545
+ return session.usage;
546
+ }
452
547
  return summarizeSessionUsageForDisplay(sessionID, includeChildren);
453
548
  }
454
549
  return summarizeRangeUsage(period);
455
550
  };
456
551
  const markSessionDirty = (sessionID) => {
457
552
  bumpDirty(sessionID);
553
+ const sessionState = deps.state.sessions[sessionID];
554
+ if (sessionState && !sessionState.dirty) {
555
+ sessionState.dirty = true;
556
+ const dateKey = deps.state.sessionDateMap[sessionID] ||
557
+ dateKeyFromTimestamp(sessionState.createdAt);
558
+ deps.state.sessionDateMap[sessionID] = dateKey;
559
+ deps.persistence.markDirty(dateKey);
560
+ deps.persistence.scheduleSave();
561
+ }
458
562
  };
459
563
  const markForceRescan = (sessionID) => {
460
564
  forceRescanSessions.add(sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",