@leo000001/opencode-quota-sidebar 2.0.6 → 2.0.8

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/title.js CHANGED
@@ -28,6 +28,20 @@ function isCoreDecoratedDetail(line) {
28
28
  }
29
29
  return false;
30
30
  }
31
+ function isQuotaDecoratedDetail(line) {
32
+ if (!line)
33
+ return false;
34
+ if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)\s*$/.test(line)) {
35
+ return true;
36
+ }
37
+ if (/^(?:(?:Daily\s+\$[\d.,]+\/\$[\d.,]+|\$[\d.,]+\/\$[\d.,]+)(?:\s+(?:Rst|Exp\+?)\s+[-:\d]+)?|(?:\d+[hdw]|Weekly|Monthly)\s+\d{1,3}%(?:\s+Rst\s+[-:\d]+)?|Balance\s+\$[\d.,]+|Remaining\s+\?|(?:error|unsupported|unavailable))$/.test(line)) {
38
+ return true;
39
+ }
40
+ if (/^(OpenAI|Copilot|Anthropic|Kimi|XYAI|Buzz|RC(?:-[^\s]+)?)(?:\s+(?:(?:Daily\s+\$[\d.,]+\/\$[\d.,]+|\$[\d.,]+\/\$[\d.,]+)(?:\s+(?:Rst|Exp\+?)\s+[-:\d]+)?|(?:\d+[hdw]|Weekly|Monthly)\s+\d{1,3}%(?:\s+Rst\s+[-:\d]+)?|(?:error|unsupported|unavailable)))$/.test(line)) {
41
+ return true;
42
+ }
43
+ return false;
44
+ }
31
45
  function isSingleLineDecoratedPrefix(line) {
32
46
  if (!line)
33
47
  return false;
@@ -47,7 +61,9 @@ function isSingleLineDecoratedPrefix(line) {
47
61
  return false;
48
62
  }
49
63
  function isSingleLineDetailPrefix(line) {
50
- return isCoreDecoratedDetail(line) || isSingleLineDecoratedPrefix(line);
64
+ return (isCoreDecoratedDetail(line) ||
65
+ isSingleLineDecoratedPrefix(line) ||
66
+ isQuotaDecoratedDetail(line));
51
67
  }
52
68
  function decoratedSingleLineBase(line) {
53
69
  const parts = sanitizeTitleFragment(line)
@@ -72,7 +88,7 @@ export function normalizeBaseTitle(title) {
72
88
  const lines = stripAnsi(safeTitle).split(/\r?\n/);
73
89
  if (lines.length > 1) {
74
90
  const detail = lines.slice(1).map((line) => sanitizeTitleFragment(line).trim());
75
- if (detail.some((line) => isCoreDecoratedDetail(line))) {
91
+ if (detail.some((line) => isCoreDecoratedDetail(line) || isQuotaDecoratedDetail(line))) {
76
92
  return sanitizeTitleFragment(firstLine) || 'Session';
77
93
  }
78
94
  }
@@ -122,5 +138,5 @@ export function looksDecorated(title) {
122
138
  const detail = lines
123
139
  .slice(1)
124
140
  .map((line) => sanitizeTitleFragment(line).trim());
125
- return detail.some((line) => isCoreDecoratedDetail(line));
141
+ return detail.some((line) => isCoreDecoratedDetail(line) || isQuotaDecoratedDetail(line));
126
142
  }
@@ -33,6 +33,13 @@ export function createTitleApplicator(deps) {
33
33
  if (canonicalizeTitle(currentTitle) !==
34
34
  canonicalizeTitle(sessionState.lastAppliedTitle || '')) {
35
35
  if (looksDecorated(currentTitle)) {
36
+ if (/\r?\n/.test(currentTitle)) {
37
+ const normalizedBase = normalizeBaseTitle(currentTitle);
38
+ if (sessionState.baseTitle !== normalizedBase) {
39
+ sessionState.baseTitle = normalizedBase;
40
+ stateMutated = true;
41
+ }
42
+ }
36
43
  // Ignore decorated echoes as base-title source.
37
44
  // If we previously applied a decorated title, treat this as an
38
45
  // equivalent echo (OpenCode may normalize whitespace) and keep
@@ -1,8 +1,8 @@
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, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
3
+ import { deleteSessionFromDayChunk, dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
4
4
  import { periodStart } from './period.js';
5
- import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
5
+ import { debug, debugError, isRecord, mapConcurrent, swallow, } from './helpers.js';
6
6
  import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesInCompletedRange, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
7
7
  const READ_ONLY_CACHE_PROVIDERS = new Set([
8
8
  'openai',
@@ -218,18 +218,81 @@ export function createUsageService(deps) {
218
218
  return undefined;
219
219
  return decoded;
220
220
  };
221
- const loadSessionEntries = async (sessionID) => {
222
- const response = await deps.client.session
223
- .messages({
224
- path: { id: sessionID },
225
- query: { directory: deps.directory },
226
- throwOnError: true,
227
- })
228
- .catch(swallow('loadSessionEntries'));
229
- if (!response)
221
+ const errorStatusCode = (value, seen = new Set()) => {
222
+ if (!isRecord(value) || seen.has(value))
230
223
  return undefined;
231
- const data = response.data;
232
- return decodeMessageEntries(data);
224
+ seen.add(value);
225
+ const status = value.status;
226
+ if (typeof status === 'number' && Number.isFinite(status))
227
+ return status;
228
+ const statusCode = value.statusCode;
229
+ if (typeof statusCode === 'number' && Number.isFinite(statusCode)) {
230
+ return statusCode;
231
+ }
232
+ return (errorStatusCode(value.response, seen) ||
233
+ errorStatusCode(value.cause, seen) ||
234
+ errorStatusCode(value.error, seen));
235
+ };
236
+ const errorText = (value, seen = new Set()) => {
237
+ if (!value || seen.has(value))
238
+ return '';
239
+ if (typeof value === 'string')
240
+ return value;
241
+ if (typeof value === 'number' || typeof value === 'boolean')
242
+ return `${value}`;
243
+ if (value instanceof Error) {
244
+ seen.add(value);
245
+ return [
246
+ value.message,
247
+ errorText(value.cause, seen),
248
+ ]
249
+ .filter(Boolean)
250
+ .join('\n');
251
+ }
252
+ if (!isRecord(value))
253
+ return '';
254
+ seen.add(value);
255
+ return [
256
+ typeof value.message === 'string' ? value.message : '',
257
+ typeof value.error === 'string' ? value.error : '',
258
+ typeof value.detail === 'string' ? value.detail : '',
259
+ typeof value.title === 'string' ? value.title : '',
260
+ errorText(value.response, seen),
261
+ errorText(value.data, seen),
262
+ errorText(value.cause, seen),
263
+ ]
264
+ .filter(Boolean)
265
+ .join('\n');
266
+ };
267
+ const isMissingSessionError = (error) => {
268
+ const status = errorStatusCode(error);
269
+ if (status === 404 || status === 410)
270
+ return true;
271
+ const text = errorText(error).toLowerCase();
272
+ if (!text)
273
+ return false;
274
+ return (/\b(session|conversation)\b.*\b(not found|missing|deleted|does not exist)\b/.test(text) ||
275
+ /\b(not found|missing|deleted|does not exist)\b.*\b(session|conversation)\b/.test(text));
276
+ };
277
+ const loadSessionEntries = async (sessionID) => {
278
+ try {
279
+ const response = await deps.client.session.messages({
280
+ path: { id: sessionID },
281
+ query: { directory: deps.directory },
282
+ throwOnError: true,
283
+ });
284
+ const data = response.data;
285
+ const entries = decodeMessageEntries(data);
286
+ if (!entries)
287
+ return { status: 'error' };
288
+ return { status: 'ok', entries };
289
+ }
290
+ catch (error) {
291
+ debugError(`loadSessionEntries ${sessionID}`, error);
292
+ return {
293
+ status: isMissingSessionError(error) ? 'missing' : 'error',
294
+ };
295
+ }
233
296
  };
234
297
  const persistSessionUsage = (sessionID, usage) => {
235
298
  const sessionState = deps.state.sessions[sessionID];
@@ -262,7 +325,8 @@ export function createUsageService(deps) {
262
325
  });
263
326
  };
264
327
  const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
265
- const entries = await loadSessionEntries(sessionID);
328
+ const load = await loadSessionEntries(sessionID);
329
+ const entries = load.status === 'ok' ? load.entries : undefined;
266
330
  const sessionState = deps.state.sessions[sessionID];
267
331
  // If we can't load messages (transient API failure), fall back to cached
268
332
  // usage if available and avoid mutating cursor/dirty state.
@@ -426,8 +490,8 @@ export function createUsageService(deps) {
426
490
  };
427
491
  if (sessions.length > 0) {
428
492
  const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
429
- const entries = await loadSessionEntries(session.sessionID);
430
- if (!entries) {
493
+ const load = await loadSessionEntries(session.sessionID);
494
+ if (load.status !== 'ok') {
431
495
  return {
432
496
  sessionID: session.sessionID,
433
497
  dateKey: session.dateKey,
@@ -436,16 +500,19 @@ export function createUsageService(deps) {
436
500
  dirty: session.state.dirty === true,
437
501
  computed: emptyUsageSummary(),
438
502
  fullUsage: undefined,
439
- loadFailed: true,
503
+ loadFailed: load.status === 'error',
504
+ missing: load.status === 'missing',
440
505
  persist: false,
441
506
  cursor: undefined,
442
507
  };
443
508
  }
509
+ const entries = load.entries;
444
510
  const computed = summarizeMessagesInCompletedRange(entries, startAt, endAt, 0, {
445
511
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
446
512
  classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
447
513
  });
448
- const shouldPersistFullUsage = !session.state.usage || shouldRecomputeUsageCache(session.state.usage);
514
+ const shouldPersistFullUsage = !session.state.usage ||
515
+ shouldRecomputeUsageCache(session.state.usage);
449
516
  if (!shouldPersistFullUsage) {
450
517
  return {
451
518
  sessionID: session.sessionID,
@@ -456,6 +523,7 @@ export function createUsageService(deps) {
456
523
  computed,
457
524
  fullUsage: undefined,
458
525
  loadFailed: false,
526
+ missing: false,
459
527
  persist: false,
460
528
  cursor: session.state.cursor,
461
529
  };
@@ -473,10 +541,35 @@ export function createUsageService(deps) {
473
541
  computed,
474
542
  fullUsage,
475
543
  loadFailed: false,
544
+ missing: false,
476
545
  persist: true,
477
546
  cursor,
478
547
  };
479
548
  });
549
+ const missingSessions = fetched.filter((item) => item.missing);
550
+ if (missingSessions.length > 0) {
551
+ let stateChanged = false;
552
+ for (const missing of missingSessions) {
553
+ deps.state.deletedSessionDateMap[missing.sessionID] = missing.dateKey;
554
+ delete deps.state.sessions[missing.sessionID];
555
+ delete deps.state.sessionDateMap[missing.sessionID];
556
+ deps.persistence.markDirty(missing.dateKey);
557
+ forgetSession(missing.sessionID);
558
+ stateChanged = true;
559
+ }
560
+ await Promise.all(missingSessions.map(async (missing) => {
561
+ const deletedFromChunk = await deleteSessionFromDayChunk(deps.statePath, missing.sessionID, missing.dateKey).catch((error) => {
562
+ swallow('deleteSessionFromDayChunk')(error);
563
+ return false;
564
+ });
565
+ if (!deletedFromChunk)
566
+ return;
567
+ delete deps.state.deletedSessionDateMap[missing.sessionID];
568
+ stateChanged = true;
569
+ }));
570
+ if (stateChanged)
571
+ deps.persistence.scheduleSave();
572
+ }
480
573
  const failedLoads = fetched.filter((item) => {
481
574
  if (!item.loadFailed)
482
575
  return false;
@@ -493,7 +586,7 @@ export function createUsageService(deps) {
493
586
  }
494
587
  let dirty = false;
495
588
  const diskOnlyUpdates = [];
496
- for (const { sessionID, dateKey, computed, fullUsage, persist, cursor } of fetched) {
589
+ for (const { sessionID, dateKey, computed, fullUsage, persist, cursor, } of fetched) {
497
590
  if (computed.assistantMessages > 0) {
498
591
  computed.sessionCount = 1;
499
592
  mergeUsage(usage, computed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",