@leo000001/opencode-quota-sidebar 2.0.0 → 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.
@@ -45,14 +45,17 @@ class ChunkCache {
45
45
  constructor(maxSize = 64) {
46
46
  this.maxSize = maxSize;
47
47
  }
48
- get(dateKey) {
49
- const entry = this.cache.get(dateKey);
48
+ key(rootPath, dateKey) {
49
+ return `${path.resolve(rootPath)}::${dateKey}`;
50
+ }
51
+ get(rootPath, dateKey) {
52
+ const entry = this.cache.get(this.key(rootPath, dateKey));
50
53
  if (!entry)
51
54
  return undefined;
52
55
  entry.accessedAt = Date.now();
53
56
  return entry.sessions;
54
57
  }
55
- set(dateKey, sessions) {
58
+ set(rootPath, dateKey, sessions) {
56
59
  if (this.cache.size >= this.maxSize) {
57
60
  // Evict least recently accessed
58
61
  let oldestKey;
@@ -66,17 +69,20 @@ class ChunkCache {
66
69
  if (oldestKey)
67
70
  this.cache.delete(oldestKey);
68
71
  }
69
- this.cache.set(dateKey, { sessions, accessedAt: Date.now() });
72
+ this.cache.set(this.key(rootPath, dateKey), {
73
+ sessions,
74
+ accessedAt: Date.now(),
75
+ });
70
76
  }
71
- invalidate(dateKey) {
72
- this.cache.delete(dateKey);
77
+ invalidate(rootPath, dateKey) {
78
+ this.cache.delete(this.key(rootPath, dateKey));
73
79
  }
74
80
  }
75
81
  const chunkCache = new ChunkCache();
76
82
  export async function readDayChunk(rootPath, dateKey) {
77
83
  if (!isDateKey(dateKey))
78
84
  return {};
79
- const cached = chunkCache.get(dateKey);
85
+ const cached = chunkCache.get(rootPath, dateKey);
80
86
  if (cached)
81
87
  return cached;
82
88
  const filePath = chunkFilePath(rootPath, dateKey);
@@ -101,7 +107,7 @@ export async function readDayChunk(rootPath, dateKey) {
101
107
  acc[sessionID] = parsedSession;
102
108
  return acc;
103
109
  }, {});
104
- chunkCache.set(dateKey, sessions);
110
+ chunkCache.set(rootPath, dateKey, sessions);
105
111
  return sessions;
106
112
  }
107
113
  /**
@@ -174,13 +180,18 @@ export async function writeDayChunk(rootPath, dateKey, sessions) {
174
180
  throw new Error(`unsafe chunk root at ${rootPath}`);
175
181
  }
176
182
  await mkdirpNoSymlink(rootPath, path.dirname(filePath));
183
+ if (Object.keys(sessions).length === 0) {
184
+ await fs.rm(filePath, { force: true }).catch(() => undefined);
185
+ chunkCache.invalidate(rootPath, dateKey);
186
+ return;
187
+ }
177
188
  const chunk = {
178
189
  version: 1,
179
190
  dateKey,
180
191
  sessions,
181
192
  };
182
193
  await safeWriteFile(filePath, `${JSON.stringify(chunk, null, 2)}\n`);
183
- chunkCache.invalidate(dateKey);
194
+ chunkCache.invalidate(rootPath, dateKey);
184
195
  }
185
196
  export async function discoverChunks(rootPath) {
186
197
  const years = await fs.readdir(rootPath).catch(() => []);
@@ -1,4 +1,5 @@
1
1
  import { asNumber, isRecord } from './helpers.js';
2
+ import { normalizeTimestampMs } from './storage_dates.js';
2
3
  function parseSessionTitleState(value) {
3
4
  if (!isRecord(value))
4
5
  return undefined;
@@ -107,7 +108,7 @@ export function parseSessionState(value) {
107
108
  const title = parseSessionTitleState(value);
108
109
  if (!title)
109
110
  return undefined;
110
- const createdAt = asNumber(value.createdAt, 0);
111
+ const createdAt = normalizeTimestampMs(value.createdAt, 0);
111
112
  if (!createdAt)
112
113
  return undefined;
113
114
  return {
@@ -115,6 +116,7 @@ export function parseSessionState(value) {
115
116
  createdAt,
116
117
  parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
117
118
  usage: parseCachedUsage(value.usage),
119
+ dirty: value.dirty === true,
118
120
  cursor: parseCursor(value.cursor),
119
121
  };
120
122
  }
package/dist/title.js CHANGED
@@ -3,16 +3,15 @@ function sanitizeTitleFragment(value) {
3
3
  .replace(/[\x00-\x1F\x7F-\x9F]/g, ' ')
4
4
  .trimEnd();
5
5
  }
6
- function isStrongDecoratedDetail(line) {
6
+ function isCoreDecoratedDetail(line) {
7
7
  if (!line)
8
8
  return false;
9
- if (/^Input\s+\S+\s+Output(?:\s+\S+)?/.test(line))
9
+ if (/^Input\s+\$?[\d.,]+[kKmM]?(?:\s+Output(?:\s+\$?[\d.,]+[kKmM]?)?)?~?$/.test(line)) {
10
10
  return true;
11
- if (/^Cache\s+(Read|Write)\s+\S+/.test(line))
12
- return true;
13
- if (/^Cache(?:\s+Read)?\s+Coverage\s+\S+/.test(line))
11
+ }
12
+ if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
14
13
  return true;
15
- if (/^\$\S+\s+as API cost\b/.test(line))
14
+ if (/^\$\S+\s+as API cost$/.test(line))
16
15
  return true;
17
16
  // Single-line compact mode compatibility.
18
17
  if (/^I(?:nput)?\s+\$?\d[\d.,]*[kKmM]?\s+O(?:utput)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
@@ -23,12 +22,26 @@ function isStrongDecoratedDetail(line) {
23
22
  return true;
24
23
  return false;
25
24
  }
26
- function isQuotaLikeProviderDetail(line) {
25
+ function isSingleLineDecoratedPrefix(line) {
27
26
  if (!line)
28
27
  return false;
29
- if (!/^(OpenAI|Copilot|Anthropic|RightCode|RC)\b/.test(line))
30
- return false;
31
- return /\b(Rst|Exp\+?|Balance|Remaining)\b|\d{1,3}%/.test(line);
28
+ if (/^Input\s+\$?[\d.,]+[kKmM]?~?$/.test(line))
29
+ return true;
30
+ if (/^Input\s+\$?[\d.,]+[kKmM]?\s+Output(?:\s+\$?[\d.,]+[kKmM]?~?)?$/.test(line)) {
31
+ return true;
32
+ }
33
+ if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?(?:~|$)/.test(line)) {
34
+ return true;
35
+ }
36
+ if (/^Cache(?:\s+Read)?\s+Coverage\s+\d[\d.,]*(?:%|~)$/.test(line)) {
37
+ return true;
38
+ }
39
+ if (/^\$\S+\s+as API cost(?:~|$)/.test(line))
40
+ return true;
41
+ return false;
42
+ }
43
+ function isSingleLineDetailPrefix(line) {
44
+ return isCoreDecoratedDetail(line) || isSingleLineDecoratedPrefix(line);
32
45
  }
33
46
  function decoratedSingleLineBase(line) {
34
47
  const parts = sanitizeTitleFragment(line)
@@ -36,18 +49,28 @@ function decoratedSingleLineBase(line) {
36
49
  .map((part) => part.trim());
37
50
  if (parts.length < 2)
38
51
  return undefined;
52
+ if (isSingleLineDetailPrefix(parts[0] || ''))
53
+ return undefined;
39
54
  const details = parts.slice(1);
40
- if (!details.some((detail) => isStrongDecoratedDetail(detail))) {
55
+ if (!details.some((detail) => isSingleLineDetailPrefix(detail))) {
41
56
  return undefined;
42
57
  }
43
58
  return parts[0] || 'Session';
44
59
  }
45
60
  export function normalizeBaseTitle(title) {
46
- const firstLine = stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
61
+ const safeTitle = canonicalizeTitle(title) || 'Session';
62
+ const firstLine = stripAnsi(safeTitle).split(/\r?\n/, 1)[0] || 'Session';
47
63
  const decoratedBase = decoratedSingleLineBase(firstLine);
48
64
  if (decoratedBase)
49
65
  return decoratedBase;
50
- return sanitizeTitleFragment(firstLine) || 'Session';
66
+ const lines = stripAnsi(safeTitle).split(/\r?\n/);
67
+ if (lines.length > 1) {
68
+ const detail = lines.slice(1).map((line) => sanitizeTitleFragment(line).trim());
69
+ if (detail.some((line) => isCoreDecoratedDetail(line))) {
70
+ return sanitizeTitleFragment(firstLine) || 'Session';
71
+ }
72
+ }
73
+ return safeTitle;
51
74
  }
52
75
  export function stripAnsi(value) {
53
76
  // Remove terminal escape sequences. Sidebar titles must be plain text.
@@ -93,6 +116,5 @@ export function looksDecorated(title) {
93
116
  const detail = lines
94
117
  .slice(1)
95
118
  .map((line) => sanitizeTitleFragment(line).trim());
96
- return (detail.some((line) => isStrongDecoratedDetail(line)) ||
97
- detail.some((line) => isQuotaLikeProviderDetail(line)));
119
+ return detail.some((line) => isCoreDecoratedDetail(line));
98
120
  }
@@ -26,5 +26,7 @@ export declare function createTitleApplicator(deps: {
26
26
  }) => Promise<void>;
27
27
  restoreSessionTitle: (sessionID: string) => Promise<void>;
28
28
  restoreAllVisibleTitles: () => Promise<void>;
29
+ refreshAllTouchedTitles: () => Promise<void>;
30
+ refreshAllVisibleTitles: () => Promise<void>;
29
31
  forgetSession: (sessionID: string) => void;
30
32
  };
@@ -1,4 +1,4 @@
1
- import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normalizeBaseTitle, } from './title.js';
1
+ import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, } from './title.js';
2
2
  import { swallow, debug, mapConcurrent } from './helpers.js';
3
3
  export function createTitleApplicator(deps) {
4
4
  const pendingAppliedTitle = new Map();
@@ -44,7 +44,7 @@ export function createTitleApplicator(deps) {
44
44
  }
45
45
  }
46
46
  else {
47
- const nextBase = normalizeBaseTitle(currentTitle);
47
+ const nextBase = canonicalizeTitle(currentTitle) || 'Session';
48
48
  if (sessionState.baseTitle !== nextBase) {
49
49
  sessionState.baseTitle = nextBase;
50
50
  stateMutated = true;
@@ -61,6 +61,8 @@ export function createTitleApplicator(deps) {
61
61
  ? await deps.getQuotaSnapshots(quotaProviders)
62
62
  : [];
63
63
  const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config);
64
+ if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
65
+ return;
64
66
  if (canonicalizeTitleForCompare(nextTitle) ===
65
67
  canonicalizeTitleForCompare(session.data.title)) {
66
68
  if (looksDecorated(session.data.title)) {
@@ -129,11 +131,14 @@ export function createTitleApplicator(deps) {
129
131
  canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle || '')) {
130
132
  return;
131
133
  }
132
- if (looksDecorated(args.incomingTitle)) {
133
- debug(`ignoring late decorated echo for session ${args.sessionID}`);
134
- return;
134
+ if (looksDecorated(args.incomingTitle) && args.sessionState.lastAppliedTitle) {
135
+ if (canonicalizeTitleForCompare(args.incomingTitle) ===
136
+ canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle)) {
137
+ debug(`ignoring late decorated echo for session ${args.sessionID}`);
138
+ return;
139
+ }
135
140
  }
136
- args.sessionState.baseTitle = normalizeBaseTitle(args.incomingTitle);
141
+ args.sessionState.baseTitle = canonicalizeTitle(args.incomingTitle) || 'Session';
137
142
  args.sessionState.lastAppliedTitle = undefined;
138
143
  deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
139
144
  deps.scheduleSave();
@@ -150,10 +155,16 @@ export function createTitleApplicator(deps) {
150
155
  if (!session)
151
156
  return;
152
157
  const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
153
- const baseTitle = normalizeBaseTitle(sessionState.baseTitle);
154
- if (session.data.title === baseTitle)
158
+ const baseTitle = canonicalizeTitle(sessionState.baseTitle) || 'Session';
159
+ if (session.data.title === baseTitle) {
160
+ if (sessionState.lastAppliedTitle !== undefined) {
161
+ sessionState.lastAppliedTitle = undefined;
162
+ deps.markDirty(deps.state.sessionDateMap[sessionID]);
163
+ deps.scheduleSave();
164
+ }
155
165
  return;
156
- await deps.client.session
166
+ }
167
+ const updated = await deps.client.session
157
168
  .update({
158
169
  path: { id: sessionID },
159
170
  query: { directory: deps.directory },
@@ -161,22 +172,39 @@ export function createTitleApplicator(deps) {
161
172
  throwOnError: true,
162
173
  })
163
174
  .catch(swallow('restoreSessionTitle:update'));
175
+ if (!updated)
176
+ return;
164
177
  sessionState.lastAppliedTitle = undefined;
165
178
  deps.markDirty(deps.state.sessionDateMap[sessionID]);
166
179
  deps.scheduleSave();
167
180
  };
168
181
  const restoreAllVisibleTitles = async () => {
182
+ const touched = Object.entries(deps.state.sessions)
183
+ .filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
184
+ .map(([sessionID]) => sessionID);
185
+ await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
186
+ await restoreSessionTitle(sessionID);
187
+ });
188
+ };
189
+ const refreshAllTouchedTitles = async () => {
190
+ const touched = Object.entries(deps.state.sessions)
191
+ .filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
192
+ .map(([sessionID]) => sessionID);
193
+ await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
194
+ await applyTitle(sessionID);
195
+ });
196
+ };
197
+ const refreshAllVisibleTitles = async () => {
169
198
  const list = await deps.client.session
170
199
  .list({
171
200
  query: { directory: deps.directory },
172
201
  throwOnError: true,
173
202
  })
174
- .catch(swallow('restoreAllVisibleTitles:list'));
203
+ .catch(swallow('refreshAllVisibleTitles:list'));
175
204
  if (!list?.data)
176
205
  return;
177
- const touched = list.data.filter((s) => deps.state.sessions[s.id]?.lastAppliedTitle);
178
- await mapConcurrent(touched, deps.restoreConcurrency, async (s) => {
179
- await restoreSessionTitle(s.id);
206
+ await mapConcurrent(list.data, deps.restoreConcurrency, async (session) => {
207
+ await applyTitle(session.id);
180
208
  });
181
209
  };
182
210
  return {
@@ -184,6 +212,8 @@ export function createTitleApplicator(deps) {
184
212
  handleSessionUpdatedTitle,
185
213
  restoreSessionTitle,
186
214
  restoreAllVisibleTitles,
215
+ refreshAllTouchedTitles,
216
+ refreshAllVisibleTitles,
187
217
  forgetSession,
188
218
  };
189
219
  }
@@ -5,5 +5,7 @@ export declare function createTitleRefreshScheduler(options: {
5
5
  schedule: (sessionID: string, delay?: number) => void;
6
6
  apply: (sessionID: string) => Promise<void>;
7
7
  cancel: (sessionID: string) => void;
8
+ cancelAll: () => void;
9
+ waitForIdle: () => Promise<void>;
8
10
  dispose: () => void;
9
11
  };
@@ -31,16 +31,27 @@ export function createTitleRefreshScheduler(options) {
31
31
  clearTimeout(timer);
32
32
  refreshTimer.delete(sessionID);
33
33
  };
34
- const dispose = () => {
34
+ const cancelAll = () => {
35
35
  for (const timer of refreshTimer.values())
36
36
  clearTimeout(timer);
37
37
  refreshTimer.clear();
38
+ };
39
+ const waitForIdle = async () => {
40
+ const inflight = Array.from(applyLocks.values());
41
+ if (inflight.length === 0)
42
+ return;
43
+ await Promise.allSettled(inflight);
44
+ };
45
+ const dispose = () => {
46
+ cancelAll();
38
47
  applyLocks.clear();
39
48
  };
40
49
  return {
41
50
  schedule,
42
51
  apply: applyLocked,
43
52
  cancel,
53
+ cancelAll,
54
+ waitForIdle,
44
55
  dispose,
45
56
  };
46
57
  }
package/dist/tools.d.ts CHANGED
@@ -4,8 +4,13 @@ export declare function createQuotaSidebarTools(deps: {
4
4
  getTitleEnabled: () => boolean;
5
5
  setTitleEnabled: (enabled: boolean) => void;
6
6
  scheduleSave: () => void;
7
+ flushSave: () => Promise<void>;
7
8
  refreshSessionTitle: (sessionID: string, delay?: number) => void;
9
+ cancelAllTitleRefreshes: () => void;
10
+ waitForTitleRefreshIdle: () => Promise<void>;
8
11
  restoreAllVisibleTitles: () => Promise<void>;
12
+ refreshAllTouchedTitles: () => Promise<void>;
13
+ refreshAllVisibleTitles: () => Promise<void>;
9
14
  showToast: (period: 'session' | 'day' | 'week' | 'month' | 'toggle', message: string) => Promise<void>;
10
15
  summarizeForTool: (period: 'session' | 'day' | 'week' | 'month', sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
11
16
  getQuotaSnapshots: (providerIDs: string[], options?: {
package/dist/tools.js CHANGED
@@ -18,7 +18,6 @@ export function createQuotaSidebarTools(deps) {
18
18
  ? (args.includeChildren ?? deps.config.sidebar.includeChildren)
19
19
  : false;
20
20
  const usage = await deps.summarizeForTool(period, context.sessionID, includeChildren);
21
- deps.scheduleSave();
22
21
  // For quota_summary, always show all subscription quota balances,
23
22
  // regardless of which providers were used in the session.
24
23
  const quotas = await deps.getQuotaSnapshots([], { allowDefault: true });
@@ -47,16 +46,21 @@ export function createQuotaSidebarTools(deps) {
47
46
  const next = args.enabled !== undefined ? args.enabled : !current;
48
47
  deps.setTitleEnabled(next);
49
48
  deps.scheduleSave();
49
+ await deps.flushSave();
50
50
  if (next) {
51
- // Turning on — re-render current session immediately
51
+ // Turning on — refresh visible sessions, plus touched sessions as backup.
52
+ await deps.refreshAllVisibleTitles();
53
+ await deps.refreshAllTouchedTitles();
52
54
  deps.refreshSessionTitle(context.sessionID, 0);
53
55
  await deps.showToast('toggle', 'Sidebar usage display: ON');
54
- return 'Sidebar usage display is now ON. Session titles will show token usage and quota.';
56
+ return 'Sidebar usage display is now ON. Visible session titles are refreshing to show token usage and quota.';
55
57
  }
56
58
  // Turning off — restore all touched sessions to base titles
59
+ deps.cancelAllTitleRefreshes();
60
+ await deps.waitForTitleRefreshIdle();
57
61
  await deps.restoreAllVisibleTitles();
58
62
  await deps.showToast('toggle', 'Sidebar usage display: OFF');
59
- return 'Sidebar usage display is now OFF. Session titles restored to original.';
63
+ return 'Sidebar usage display is now OFF. Restore was attempted for touched session titles.';
60
64
  },
61
65
  }),
62
66
  };
package/dist/types.d.ts CHANGED
@@ -47,6 +47,20 @@ export type CacheUsageBuckets = {
47
47
  readOnly: CacheUsageBucket;
48
48
  readWrite: CacheUsageBucket;
49
49
  };
50
+ /**
51
+ * Derived cache coverage metrics.
52
+ *
53
+ * - `cacheCoverage`: fraction of prompt surface covered by read-write cache
54
+ * (`(cacheRead + cacheWrite) / (input + cacheRead + cacheWrite)`).
55
+ * Only defined when the read-write bucket has traffic.
56
+ * - `cacheReadCoverage`: fraction of prompt surface served from read-only cache
57
+ * (`cacheRead / (input + cacheRead)`).
58
+ * Only defined when the read-only bucket has traffic.
59
+ */
60
+ export type CacheCoverageMetrics = {
61
+ cacheCoverage: number | undefined;
62
+ cacheReadCoverage: number | undefined;
63
+ };
50
64
  export type CachedProviderUsage = {
51
65
  input: number;
52
66
  output: number;
@@ -72,7 +86,13 @@ export type CachedSessionUsage = {
72
86
  /** Equivalent API billing cost (USD) computed from model pricing. */
73
87
  apiCost: number;
74
88
  assistantMessages: number;
75
- /** Cache coverage buckets grouped by model cache behavior. */
89
+ /**
90
+ * Cache coverage buckets grouped by model cache behavior.
91
+ *
92
+ * `undefined` when no cache-capable models were used or data predates
93
+ * billingVersion 3. The fallback in `resolvedCacheUsageBuckets()` derives
94
+ * approximate buckets from top-level `cacheRead`/`cacheWrite` when missing.
95
+ */
76
96
  cacheBuckets?: CacheUsageBuckets;
77
97
  providers: Record<string, CachedProviderUsage>;
78
98
  };
@@ -90,6 +110,8 @@ export type SessionState = SessionTitleState & {
90
110
  /** Parent session ID for subagent child sessions. */
91
111
  parentID?: string;
92
112
  usage?: CachedSessionUsage;
113
+ /** Persisted dirtiness flag so descendant aggregation survives restart. */
114
+ dirty?: boolean;
93
115
  /** Incremental aggregation cursor (P1). */
94
116
  cursor?: IncrementalCursor;
95
117
  };
@@ -104,6 +126,8 @@ export type QuotaSidebarState = {
104
126
  titleEnabled: boolean;
105
127
  sessionDateMap: Record<string, string>;
106
128
  sessions: Record<string, SessionState>;
129
+ /** Tombstones for sessions deleted from memory but not yet purged from day chunks. */
130
+ deletedSessionDateMap: Record<string, string>;
107
131
  quotaCache: Record<string, QuotaSnapshot>;
108
132
  };
109
133
  export type QuotaSidebarConfig = {
package/dist/usage.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
- import type { CacheCoverageMode, CacheUsageBuckets, CachedSessionUsage, IncrementalCursor } from './types.js';
3
- export declare const USAGE_BILLING_CACHE_VERSION = 3;
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;
@@ -35,14 +41,14 @@ export type UsageOptions = {
35
41
  /** Cache-behavior classifier for the message model/provider. */
36
42
  classifyCacheMode?: (message: AssistantMessage) => CacheCoverageMode;
37
43
  };
38
- export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): {
39
- cacheCoverage: number | undefined;
40
- cacheReadCoverage: number | undefined;
41
- };
44
+ export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
42
45
  export declare function emptyUsageSummary(): UsageSummary;
43
46
  export declare function summarizeMessages(entries: Array<{
44
47
  info: Message;
45
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;
46
52
  /**
47
53
  * P1: Incremental usage aggregation.
48
54
  * Only processes messages newer than the cursor. Returns updated cursor.
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);
@@ -101,7 +132,6 @@ export function emptyUsageSummary() {
101
132
  apiCost: 0,
102
133
  assistantMessages: 0,
103
134
  sessionCount: 0,
104
- cacheBuckets: emptyCacheUsageBuckets(),
105
135
  providers: {},
106
136
  };
107
137
  }
@@ -170,17 +200,37 @@ function addMessageUsage(target, message, options) {
170
200
  addMessageCacheUsage(buckets.readWrite, message);
171
201
  }
172
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;
218
+ }
173
219
  export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
174
220
  const summary = emptyUsageSummary();
175
221
  summary.sessionCount = sessionCount;
176
222
  for (const entry of entries) {
177
- if (!isAssistant(entry.info))
223
+ if (!isCompletedAssistantInRange(entry.info, startAt))
178
224
  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)
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))
184
234
  continue;
185
235
  addMessageUsage(summary, entry.info, options);
186
236
  }
@@ -376,9 +426,9 @@ export function mergeUsage(target, source, options) {
376
426
  target.apiCost += source.apiCost;
377
427
  target.assistantMessages += source.assistantMessages;
378
428
  target.sessionCount += source.sessionCount;
379
- const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
380
429
  const sourceBuckets = source.cacheBuckets;
381
430
  if (sourceBuckets) {
431
+ const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
382
432
  mergeCacheUsageBucket(targetBuckets.readOnly, sourceBuckets.readOnly);
383
433
  mergeCacheUsageBucket(targetBuckets.readWrite, sourceBuckets.readWrite);
384
434
  }