@leo000001/opencode-quota-sidebar 1.0.2 → 1.2.0

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
@@ -13,6 +13,20 @@ export function emptyUsageSummary() {
13
13
  providers: {},
14
14
  };
15
15
  }
16
+ function emptyProviderUsage(providerID) {
17
+ return {
18
+ providerID,
19
+ input: 0,
20
+ output: 0,
21
+ reasoning: 0,
22
+ cacheRead: 0,
23
+ cacheWrite: 0,
24
+ total: 0,
25
+ cost: 0,
26
+ apiCost: 0,
27
+ assistantMessages: 0,
28
+ };
29
+ }
16
30
  function isAssistant(message) {
17
31
  return message.role === 'assistant';
18
32
  }
@@ -44,18 +58,7 @@ function addMessageUsage(target, message, options) {
44
58
  target.cost += cost;
45
59
  target.apiCost += apiCost;
46
60
  const provider = target.providers[message.providerID] ||
47
- {
48
- providerID: message.providerID,
49
- input: 0,
50
- output: 0,
51
- reasoning: 0,
52
- cacheRead: 0,
53
- cacheWrite: 0,
54
- total: 0,
55
- cost: 0,
56
- apiCost: 0,
57
- assistantMessages: 0,
58
- };
61
+ emptyProviderUsage(message.providerID);
59
62
  provider.input += message.tokens.input;
60
63
  provider.output += output;
61
64
  provider.cacheRead += message.tokens.cache.read;
@@ -72,7 +75,9 @@ export function summarizeMessages(entries, startAt = 0, sessionCount = 1, option
72
75
  for (const entry of entries) {
73
76
  if (!isAssistant(entry.info))
74
77
  continue;
75
- if (!entry.info.time.completed)
78
+ if (typeof entry.info.time.completed !== 'number')
79
+ continue;
80
+ if (!Number.isFinite(entry.info.time.completed))
76
81
  continue;
77
82
  if (entry.info.time.created < startAt)
78
83
  continue;
@@ -87,7 +92,11 @@ export function summarizeMessages(entries, startAt = 0, sessionCount = 1, option
87
92
  */
88
93
  export function summarizeMessagesIncremental(entries, existingUsage, cursor, forceRescan, options) {
89
94
  // If no cursor or force rescan, do full scan
90
- if (forceRescan || !cursor?.lastMessageId || !existingUsage) {
95
+ if (forceRescan ||
96
+ !cursor?.lastMessageId ||
97
+ typeof cursor.lastMessageTime !== 'number' ||
98
+ !Number.isFinite(cursor.lastMessageTime) ||
99
+ !existingUsage) {
91
100
  const usage = summarizeMessages(entries, 0, 1, options);
92
101
  const lastMsg = findLastCompletedAssistant(entries);
93
102
  return {
@@ -95,31 +104,102 @@ export function summarizeMessagesIncremental(entries, existingUsage, cursor, for
95
104
  cursor: {
96
105
  lastMessageId: lastMsg?.id,
97
106
  lastMessageTime: lastMsg?.time.completed ?? undefined,
107
+ lastMessageIdsAtTime: lastMsg?.time.completed === undefined
108
+ ? undefined
109
+ : collectCompletedAssistantIdsAt(entries, lastMsg.time.completed),
98
110
  },
99
111
  };
100
112
  }
101
- // Incremental: start from existing usage, only process new messages
113
+ // Incremental: start from existing usage, only process new messages.
114
+ // Order-independent: use completed-time cursor (with id tie-breaker).
102
115
  const summary = fromCachedSessionUsage(existingUsage, 1);
116
+ const cursorTime = cursor.lastMessageTime;
117
+ const cursorID = cursor.lastMessageId;
118
+ const cursorIDsAtTime = Array.isArray(cursor.lastMessageIdsAtTime)
119
+ ? new Set(cursor.lastMessageIdsAtTime)
120
+ : undefined;
121
+ // If the cursor doesn't record ids-at-time, and we see other messages with the
122
+ // same completed timestamp but "earlier" ids, the id tie-breaker can miss
123
+ // newly-arrived messages. Force a full rescan once to initialize ids-at-time.
124
+ if (!cursorIDsAtTime) {
125
+ for (const entry of entries) {
126
+ const msg = entry.info;
127
+ if (!isAssistant(msg))
128
+ continue;
129
+ if (typeof msg.time.completed !== 'number')
130
+ continue;
131
+ if (!Number.isFinite(msg.time.completed))
132
+ continue;
133
+ if (msg.id === cursorID)
134
+ continue;
135
+ if (msg.time.completed === cursorTime &&
136
+ msg.id.localeCompare(cursorID) < 0) {
137
+ const usage = summarizeMessages(entries, 0, 1, options);
138
+ const lastMsg = findLastCompletedAssistant(entries);
139
+ return {
140
+ usage,
141
+ cursor: {
142
+ lastMessageId: lastMsg?.id,
143
+ lastMessageTime: lastMsg?.time.completed ?? undefined,
144
+ lastMessageIdsAtTime: lastMsg?.time.completed === undefined
145
+ ? undefined
146
+ : collectCompletedAssistantIdsAt(entries, lastMsg.time.completed),
147
+ },
148
+ };
149
+ }
150
+ }
151
+ }
152
+ const isAfterCursor = (message) => {
153
+ const completed = message.time.completed;
154
+ if (typeof completed !== 'number' || !Number.isFinite(completed))
155
+ return false;
156
+ if (completed > cursorTime)
157
+ return true;
158
+ if (completed < cursorTime)
159
+ return false;
160
+ if (cursorIDsAtTime) {
161
+ return !cursorIDsAtTime.has(message.id);
162
+ }
163
+ // Same timestamp: best-effort tie-breaker.
164
+ return message.id.localeCompare(cursorID) > 0;
165
+ };
166
+ const newerThan = (left, right) => {
167
+ if (left.time !== right.time)
168
+ return left.time > right.time;
169
+ return left.id.localeCompare(right.id) > 0;
170
+ };
103
171
  let foundCursor = false;
104
- let newCursor = { ...cursor };
172
+ let nextCursor = { ...cursor };
105
173
  for (const entry of entries) {
106
- if (!isAssistant(entry.info))
174
+ const msg = entry.info;
175
+ if (!isAssistant(msg))
107
176
  continue;
108
- if (!entry.info.time.completed)
177
+ if (typeof msg.time.completed !== 'number')
109
178
  continue;
110
- // Skip messages we've already processed
111
- if (!foundCursor) {
112
- if (entry.info.id === cursor.lastMessageId) {
113
- foundCursor = true;
114
- }
179
+ if (!Number.isFinite(msg.time.completed))
115
180
  continue;
116
- }
117
- // Process new message
118
- addMessageUsage(summary, entry.info, options);
119
- newCursor = {
120
- lastMessageId: entry.info.id,
121
- lastMessageTime: entry.info.time.completed ?? undefined,
181
+ if (msg.id === cursorID)
182
+ foundCursor = true;
183
+ if (!isAfterCursor(msg))
184
+ continue;
185
+ addMessageUsage(summary, msg, options);
186
+ const candidate = { id: msg.id, time: msg.time.completed };
187
+ const current = {
188
+ id: nextCursor.lastMessageId || cursorID,
189
+ time: nextCursor.lastMessageTime ?? cursorTime,
122
190
  };
191
+ if (newerThan(candidate, current)) {
192
+ nextCursor = {
193
+ lastMessageId: msg.id,
194
+ lastMessageTime: msg.time.completed,
195
+ lastMessageIdsAtTime: [msg.id],
196
+ };
197
+ }
198
+ else if (nextCursor.lastMessageTime === msg.time.completed) {
199
+ const ids = new Set(nextCursor.lastMessageIdsAtTime || []);
200
+ ids.add(msg.id);
201
+ nextCursor.lastMessageIdsAtTime = Array.from(ids).sort();
202
+ }
123
203
  }
124
204
  // If we never found the cursor message, the history may have been modified.
125
205
  // Fall back to full rescan.
@@ -131,18 +211,50 @@ export function summarizeMessagesIncremental(entries, existingUsage, cursor, for
131
211
  cursor: {
132
212
  lastMessageId: lastMsg?.id,
133
213
  lastMessageTime: lastMsg?.time.completed ?? undefined,
214
+ lastMessageIdsAtTime: lastMsg?.time.completed === undefined
215
+ ? undefined
216
+ : collectCompletedAssistantIdsAt(entries, lastMsg.time.completed),
134
217
  },
135
218
  };
136
219
  }
137
- return { usage: summary, cursor: newCursor };
220
+ return { usage: summary, cursor: nextCursor };
221
+ }
222
+ function collectCompletedAssistantIdsAt(entries, completedTime) {
223
+ const ids = [];
224
+ for (const entry of entries) {
225
+ const msg = entry.info;
226
+ if (!isAssistant(msg))
227
+ continue;
228
+ if (typeof msg.time.completed !== 'number')
229
+ continue;
230
+ if (!Number.isFinite(msg.time.completed))
231
+ continue;
232
+ if (msg.time.completed !== completedTime)
233
+ continue;
234
+ ids.push(msg.id);
235
+ }
236
+ return Array.from(new Set(ids)).sort();
138
237
  }
139
238
  function findLastCompletedAssistant(entries) {
140
- for (let i = entries.length - 1; i >= 0; i--) {
141
- const msg = entries[i].info;
142
- if (isAssistant(msg) && msg.time.completed)
143
- return msg;
239
+ let best;
240
+ let bestTime = -Infinity;
241
+ let bestID = '';
242
+ for (const entry of entries) {
243
+ const msg = entry.info;
244
+ if (!isAssistant(msg))
245
+ continue;
246
+ if (typeof msg.time.completed !== 'number')
247
+ continue;
248
+ if (!Number.isFinite(msg.time.completed))
249
+ continue;
250
+ const t = msg.time.completed;
251
+ if (t > bestTime || (t === bestTime && msg.id.localeCompare(bestID) > 0)) {
252
+ best = msg;
253
+ bestTime = t;
254
+ bestID = msg.id;
255
+ }
144
256
  }
145
- return undefined;
257
+ return best;
146
258
  }
147
259
  export function mergeUsage(target, source) {
148
260
  target.input += source.input;
@@ -156,18 +268,7 @@ export function mergeUsage(target, source) {
156
268
  target.sessionCount += source.sessionCount;
157
269
  for (const provider of Object.values(source.providers)) {
158
270
  const existing = target.providers[provider.providerID] ||
159
- {
160
- providerID: provider.providerID,
161
- input: 0,
162
- output: 0,
163
- reasoning: 0,
164
- cacheRead: 0,
165
- cacheWrite: 0,
166
- total: 0,
167
- cost: 0,
168
- apiCost: 0,
169
- assistantMessages: 0,
170
- };
271
+ emptyProviderUsage(provider.providerID);
171
272
  existing.input += provider.input;
172
273
  existing.output += provider.output;
173
274
  existing.cacheRead += provider.cacheRead;
@@ -0,0 +1,31 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import { type UsageSummary } from './usage.js';
3
+ import type { QuotaSidebarConfig, QuotaSidebarState } from './types.js';
4
+ type DescendantsResolver = {
5
+ listDescendantSessionIDs: (sessionID: string, opts: {
6
+ maxDepth: number;
7
+ maxSessions: number;
8
+ concurrency: number;
9
+ }) => Promise<string[]>;
10
+ };
11
+ type Persistence = {
12
+ markDirty: (dateKey: string | undefined) => void;
13
+ scheduleSave: () => void;
14
+ flushSave: () => Promise<void>;
15
+ };
16
+ export declare function createUsageService(deps: {
17
+ state: QuotaSidebarState;
18
+ config: QuotaSidebarConfig;
19
+ statePath: string;
20
+ client: PluginInput['client'];
21
+ directory: string;
22
+ persistence: Persistence;
23
+ descendantsResolver: DescendantsResolver;
24
+ }): {
25
+ summarizeSessionUsageForDisplay: (sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
26
+ summarizeForTool: (period: "session" | "day" | "week" | "month", sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
27
+ markSessionDirty: (sessionID: string) => void;
28
+ markForceRescan: (sessionID: string) => void;
29
+ forgetSession: (sessionID: string) => void;
30
+ };
31
+ export {};